milwright commited on
Commit
5dd5427
·
1 Parent(s): 2a047b0

feat: minimal deployment with essential JavaScript modules and complete CSS

Browse files

- Core game modules: clozeGameEngine.js, aiService.js, bookDataService.js
- Chat functionality: chatInterface.js, conversationManager.js
- Main app entry point: app.js
- Complete typewriter styling: styles.css with Tailwind integration
- Proper index.html with CDN and module references
- FastAPI server with static file serving

app.py CHANGED
@@ -1,8 +1,12 @@
1
  from fastapi import FastAPI
2
  from fastapi.responses import FileResponse
 
3
 
4
  app = FastAPI()
5
 
 
 
 
6
  @app.get("/")
7
  async def read_root():
8
  return FileResponse("index.html")
 
1
  from fastapi import FastAPI
2
  from fastapi.responses import FileResponse
3
+ from fastapi.staticfiles import StaticFiles
4
 
5
  app = FastAPI()
6
 
7
+ # Mount static files
8
+ app.mount("/src", StaticFiles(directory="src"), name="src")
9
+
10
  @app.get("/")
11
  async def read_root():
12
  return FileResponse("index.html")
index.html CHANGED
@@ -3,209 +3,55 @@
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
- <style>
10
- body { font-family: 'Special Elite', monospace; background: #faf7f0; }
11
- .paper-sheet { background: #fefcf7; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
12
- .cloze-input { border: none; border-bottom: 2px solid #000; background: transparent; text-align: center; font-family: inherit; }
13
- .typewriter-button { background: #f5f1e8; border: 2px solid #000; font-family: inherit; padding: 8px 16px; cursor: pointer; }
14
- .correct { background-color: rgba(16, 185, 129, 0.1); border-color: #10b981; }
15
- .incorrect { background-color: rgba(239, 68, 68, 0.1); border-color: #ef4444; }
16
- </style>
17
  </head>
18
- <body class="min-h-screen p-4">
19
- <div class="max-w-4xl mx-auto">
20
  <header class="text-center mb-8">
21
  <div class="flex items-center justify-center gap-3 mb-2">
22
- <span class="text-4xl">📚</span>
23
- <h1 class="text-4xl font-bold">Cloze Reader</h1>
24
  </div>
25
- <p class="text-gray-600">Fill in the blanks to practice reading comprehension</p>
26
  </header>
27
 
28
- <main id="game-container">
29
  <div id="loading" class="text-center py-8">
30
- <p class="text-lg">Loading passages...</p>
31
  </div>
32
 
33
  <div id="game-area" class="paper-sheet rounded-lg p-6 hidden">
34
- <div id="book-info" class="text-sm italic text-gray-600 mb-4"></div>
35
- <div id="round-info" class="text-sm bg-amber-100 px-3 py-1 rounded-full inline-block mb-4"></div>
36
- <div id="contextualization" class="bg-amber-50 border-l-4 border-amber-500 p-3 mb-4 text-sm"></div>
37
- <div id="passage-content" class="text-lg leading-relaxed mb-6"></div>
38
- <div id="hints-section" class="bg-yellow-50 border-l-4 border-yellow-500 p-3 mb-4 hidden">
39
- <div class="font-semibold mb-2 text-sm">💡 Hints:</div>
40
- <div id="hints-list" class="text-sm space-y-1"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  </div>
42
- <div class="flex gap-4 justify-center flex-wrap">
43
- <button id="submit-btn" class="typewriter-button">Submit</button>
44
- <button id="next-btn" class="typewriter-button hidden">Next Passage</button>
45
- <button id="hint-btn" class="typewriter-button">Show Hints</button>
46
- </div>
47
- <div id="result" class="mt-4 text-center font-semibold"></div>
48
  </div>
49
  </main>
50
  </div>
51
 
52
- <script>
53
- // Simple cloze reader implementation
54
- class SimpleClozeReader {
55
- constructor() {
56
- this.currentLevel = 1;
57
- this.currentRound = 1;
58
- this.blanks = [];
59
- this.hints = [];
60
- this.books = [
61
- {
62
- title: "Pride and Prejudice",
63
- author: "Jane Austen",
64
- 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."
65
- },
66
- {
67
- title: "The Adventures of Tom Sawyer",
68
- author: "Mark Twain",
69
- 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."
70
- }
71
- ];
72
- this.init();
73
- }
74
-
75
- init() {
76
- this.setupEventListeners();
77
- this.startNewRound();
78
- }
79
-
80
- setupEventListeners() {
81
- document.getElementById('submit-btn').onclick = () => this.submitAnswers();
82
- document.getElementById('next-btn').onclick = () => this.nextRound();
83
- document.getElementById('hint-btn').onclick = () => this.toggleHints();
84
- }
85
-
86
- startNewRound() {
87
- const book = this.books[Math.floor(Math.random() * this.books.length)];
88
- const blanksCount = this.currentLevel <= 2 ? 1 : this.currentLevel <= 4 ? 2 : 3;
89
-
90
- // Simple word selection
91
- const words = book.text.split(' ');
92
- const selectedIndices = [];
93
- const contentWords = ['truth', 'man', 'fortune', 'wife', 'feelings', 'neighbourhood', 'families', 'daughters', 'answer', 'boy', 'lady', 'spectacles', 'room'];
94
-
95
- for (let i = 0; i < blanksCount && i < contentWords.length; i++) {
96
- const wordIndex = words.findIndex(w => w.toLowerCase().includes(contentWords[i]));
97
- if (wordIndex !== -1) selectedIndices.push(wordIndex);
98
- }
99
-
100
- // Create blanks
101
- this.blanks = selectedIndices.map((index, i) => ({
102
- index: i,
103
- originalWord: words[index].replace(/[^\w]/g, ''),
104
- wordIndex: index
105
- }));
106
-
107
- // Create hints
108
- this.hints = this.blanks.map((blank, i) => {
109
- const word = blank.originalWord;
110
- if (this.currentLevel <= 2) {
111
- return `${word.length} letters, starts with "${word[0]}", ends with "${word[word.length-1]}"`;
112
- } else {
113
- return `${word.length} letters, starts with "${word[0]}"`;
114
- }
115
- });
116
-
117
- // Create display text
118
- let displayText = book.text;
119
- this.blanks.forEach((blank, i) => {
120
- const word = words[blank.wordIndex];
121
- const input = `<input type="text" class="cloze-input w-20 mx-1" data-index="${i}" placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}">`;
122
- displayText = displayText.replace(word, input);
123
- });
124
-
125
- // Update UI
126
- document.getElementById('book-info').innerHTML = `<strong>${book.title}</strong> by ${book.author}`;
127
- document.getElementById('round-info').textContent = `Level ${this.currentLevel} • ${blanksCount} blank${blanksCount > 1 ? 's' : ''}`;
128
- document.getElementById('contextualization').innerHTML = `📚 Practice with classic literature from ${book.author}'s "${book.title}"`;
129
- document.getElementById('passage-content').innerHTML = displayText;
130
- document.getElementById('hints-list').innerHTML = this.hints.map((hint, i) => `<div>${i+1}. ${hint}</div>`).join('');
131
-
132
- // Show game area
133
- document.getElementById('loading').classList.add('hidden');
134
- document.getElementById('game-area').classList.remove('hidden');
135
- document.getElementById('hints-section').classList.add('hidden');
136
- document.getElementById('result').textContent = '';
137
- document.getElementById('submit-btn').classList.remove('hidden');
138
- document.getElementById('next-btn').classList.add('hidden');
139
- }
140
-
141
- submitAnswers() {
142
- const inputs = document.querySelectorAll('.cloze-input');
143
- let correct = 0;
144
-
145
- inputs.forEach((input, i) => {
146
- const userAnswer = input.value.trim().toLowerCase();
147
- const correctAnswer = this.blanks[i].originalWord.toLowerCase();
148
-
149
- if (userAnswer === correctAnswer) {
150
- input.classList.add('correct');
151
- correct++;
152
- } else {
153
- input.classList.add('incorrect');
154
- // Show correct answer
155
- const span = document.createElement('span');
156
- span.className = 'text-green-600 font-semibold ml-2 text-sm';
157
- span.textContent = `✓ ${this.blanks[i].originalWord}`;
158
- input.parentNode.appendChild(span);
159
- }
160
- input.disabled = true;
161
- });
162
-
163
- const percentage = Math.round((correct / this.blanks.length) * 100);
164
- const passed = correct >= (this.blanks.length === 1 ? 1 : this.blanks.length - 1);
165
-
166
- let message = `Score: ${correct}/${this.blanks.length} (${percentage}%)`;
167
- if (passed) {
168
- message += ` - Excellent! Advancing to Level ${this.currentLevel + 1}! 🎉`;
169
- document.getElementById('result').className = 'mt-4 text-center font-semibold text-green-600';
170
- this.currentLevel++;
171
- } else {
172
- message += ` - Keep practicing! 💪`;
173
- document.getElementById('result').className = 'mt-4 text-center font-semibold text-red-600';
174
- }
175
-
176
- document.getElementById('result').textContent = message;
177
- document.getElementById('submit-btn').classList.add('hidden');
178
- document.getElementById('next-btn').classList.remove('hidden');
179
- this.currentRound++;
180
- }
181
-
182
- nextRound() {
183
- document.querySelectorAll('.cloze-input').forEach(input => {
184
- input.classList.remove('correct', 'incorrect');
185
- input.disabled = false;
186
- input.value = '';
187
- });
188
- document.querySelectorAll('.text-green-600').forEach(el => el.remove());
189
- this.startNewRound();
190
- }
191
-
192
- toggleHints() {
193
- const hintsSection = document.getElementById('hints-section');
194
- const btn = document.getElementById('hint-btn');
195
- if (hintsSection.classList.contains('hidden')) {
196
- hintsSection.classList.remove('hidden');
197
- btn.textContent = 'Hide Hints';
198
- } else {
199
- hintsSection.classList.add('hidden');
200
- btn.textContent = 'Show Hints';
201
- }
202
- }
203
- }
204
-
205
- // Initialize when page loads
206
- document.addEventListener('DOMContentLoaded', () => {
207
- new SimpleClozeReader();
208
- });
209
- </script>
210
  </body>
211
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Cloze Reader - Minimal</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
  </head>
11
+ <body class="min-h-screen">
12
+ <div id="app" class="container mx-auto px-4 py-8 max-w-4xl">
13
  <header class="text-center mb-8">
14
  <div class="flex items-center justify-center gap-3 mb-2">
15
+ <img src="./icon.png" alt="Cloze Reader" class="w-12 h-12">
16
+ <h1 class="text-4xl font-bold typewriter-text">Cloze Reader</h1>
17
  </div>
18
+ <p class="typewriter-subtitle">Fill in the blanks to practice reading comprehension</p>
19
  </header>
20
 
21
+ <main id="game-container" class="space-y-6">
22
  <div id="loading" class="text-center py-8">
23
+ <p class="text-lg loading-text">Loading passages...</p>
24
  </div>
25
 
26
  <div id="game-area" class="paper-sheet rounded-lg p-6 hidden">
27
+ <div class="paper-content">
28
+ <div class="flex justify-between items-center mb-4">
29
+ <div id="book-info" class="biblio-info"></div>
30
+ <div id="round-info" class="round-badge px-3 py-1 rounded-full text-sm"></div>
31
+ </div>
32
+ <div id="contextualization" class="context-box mb-4 p-3 rounded-lg"></div>
33
+ <div id="passage-content" class="prose max-w-none mb-6"></div>
34
+ <div id="hints-section" class="hints-box mb-4 p-3 rounded-lg">
35
+ <div class="text-sm font-semibold mb-2">💡 Hints:</div>
36
+ <div id="hints-list" class="text-sm space-y-1"></div>
37
+ </div>
38
+ <div id="controls" class="flex gap-4 justify-center flex-wrap">
39
+ <button type="button" id="submit-btn" class="typewriter-button">
40
+ Submit
41
+ </button>
42
+ <button type="button" id="next-btn" class="typewriter-button hidden">
43
+ Next Passage
44
+ </button>
45
+ <button type="button" id="hint-btn" class="typewriter-button">
46
+ Show Hints
47
+ </button>
48
+ </div>
49
+ <div id="result" class="mt-4 text-center result-text"></div>
50
  </div>
 
 
 
 
 
 
51
  </div>
52
  </main>
53
  </div>
54
 
55
+ <script src="./src/app.js" type="module"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  </body>
57
  </html>
src/app.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Main application entry point
2
+ import { ClozeGameEngine } from './clozeGameEngine.js';
3
+ import { BookDataService } from './bookDataService.js';
4
+ import { AIService } from './aiService.js';
5
+ import { ChatInterface } from './chatInterface.js';
6
+ import { ConversationManager } from './conversationManager.js';
7
+
8
+ class ClozeReaderApp {
9
+ constructor() {
10
+ this.bookDataService = new BookDataService();
11
+ this.aiService = new AIService();
12
+ this.gameEngine = new ClozeGameEngine(this.bookDataService, this.aiService);
13
+ this.chatInterface = new ChatInterface(this.aiService);
14
+ this.conversationManager = new ConversationManager(this.aiService);
15
+
16
+ this.init();
17
+ }
18
+
19
+ async init() {
20
+ try {
21
+ // Initialize services
22
+ await this.bookDataService.initialize();
23
+
24
+ // Start the game
25
+ await this.gameEngine.initialize();
26
+
27
+ // Initialize chat interface
28
+ this.chatInterface.initialize();
29
+
30
+ console.log('Cloze Reader application initialized successfully');
31
+ } catch (error) {
32
+ console.error('Failed to initialize application:', error);
33
+ this.showError('Failed to load the application. Please refresh the page.');
34
+ }
35
+ }
36
+
37
+ showError(message) {
38
+ const loadingElement = document.getElementById('loading');
39
+ if (loadingElement) {
40
+ loadingElement.innerHTML = `<p class="text-red-600">${message}</p>`;
41
+ }
42
+ }
43
+ }
44
+
45
+ // Initialize the application when DOM is loaded
46
+ document.addEventListener('DOMContentLoaded', () => {
47
+ new ClozeReaderApp();
48
+ });
src/bookDataService.js ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.books = [];
8
+ this.isLoaded = false;
9
+ this.streamingEnabled = false;
10
+ this.cache = new Map();
11
+ this.preloadedBooks = [];
12
+ }
13
+
14
+ // Local fallback books for when HF streaming is unavailable
15
+ getSampleBooks() {
16
+ return [
17
+ {
18
+ id: 1,
19
+ title: "Pride and Prejudice",
20
+ author: "Jane Austen",
21
+ 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."
22
+ },
23
+ {
24
+ id: 2,
25
+ title: "The Adventures of Tom Sawyer",
26
+ author: "Mark Twain",
27
+ 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--\""
28
+ },
29
+ {
30
+ id: 3,
31
+ title: "Great Expectations",
32
+ author: "Charles Dickens",
33
+ 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."
34
+ },
35
+ {
36
+ id: 4,
37
+ title: "Alice's Adventures in Wonderland",
38
+ author: "Lewis Carroll",
39
+ 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."
40
+ },
41
+ {
42
+ id: 5,
43
+ title: "The Picture of Dorian Gray",
44
+ author: "Oscar Wilde",
45
+ 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."
46
+ },
47
+ {
48
+ id: 6,
49
+ title: "Moby Dick",
50
+ author: "Herman Melville",
51
+ 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."
52
+ },
53
+ {
54
+ id: 7,
55
+ title: "Jane Eyre",
56
+ author: "Charlotte Bronte",
57
+ 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."
58
+ },
59
+ {
60
+ id: 8,
61
+ title: "The Count of Monte Cristo",
62
+ author: "Alexandre Dumas",
63
+ 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."
64
+ },
65
+ {
66
+ id: 9,
67
+ title: "Wuthering Heights",
68
+ author: "Emily Bronte",
69
+ 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."
70
+ },
71
+ {
72
+ id: 10,
73
+ title: "Frankenstein",
74
+ author: "Mary Shelley",
75
+ 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?"
76
+ }
77
+ ];
78
+ }
79
+
80
+ async loadDataset() {
81
+ try {
82
+ // Try to connect to HF Datasets API
83
+ await this.initializeStreaming();
84
+
85
+ if (this.streamingEnabled) {
86
+ // Preload some books for immediate access
87
+ await this.preloadBooks(100);
88
+ console.log(`✅ HF Streaming enabled: ${this.preloadedBooks.length} books preloaded`);
89
+ } else {
90
+ // Fall back to local samples
91
+ this.books = this.getSampleBooks();
92
+ console.log(`⚠️ Using local samples: ${this.books.length} books available`);
93
+ }
94
+
95
+ this.isLoaded = true;
96
+ return this.books;
97
+ } catch (error) {
98
+ console.error('Error loading dataset:', error);
99
+ // Ensure we always have local fallback
100
+ this.books = this.getSampleBooks();
101
+ this.isLoaded = true;
102
+ return this.books;
103
+ }
104
+ }
105
+
106
+ async initializeStreaming() {
107
+ try {
108
+ // Test HF Datasets API availability
109
+ const testUrl = `${this.apiBase}/splits?dataset=${this.datasetName}`;
110
+ const response = await fetch(testUrl);
111
+
112
+ if (response.ok) {
113
+ const data = await response.json();
114
+ // Check if English split is available
115
+ const hasEnglish = data.splits?.some(split =>
116
+ split.split === 'en' && split.config === 'default'
117
+ );
118
+
119
+ this.streamingEnabled = hasEnglish || data.splits?.length > 0;
120
+ console.log(`🔗 HF Datasets API: ${this.streamingEnabled ? 'Available' : 'Unavailable'}`);
121
+ }
122
+ } catch (error) {
123
+ console.warn('HF Datasets API test failed:', error);
124
+ this.streamingEnabled = false;
125
+ }
126
+ }
127
+
128
+ async preloadBooks(count = 100) {
129
+ if (!this.streamingEnabled) return;
130
+
131
+ try {
132
+ // Fetch a batch of books from HF Datasets (correct API format)
133
+ const url = `${this.apiBase}/rows?dataset=${this.datasetName}&config=default&split=en&offset=0&length=${count}`;
134
+ const response = await fetch(url);
135
+
136
+ if (response.ok) {
137
+ const data = await response.json();
138
+
139
+ // Process and filter books
140
+ this.preloadedBooks = data.rows
141
+ .map(row => this.processHFBook(row.row))
142
+ .filter(book => this.isValidForCloze(book));
143
+
144
+ console.log(`📚 Preloaded ${this.preloadedBooks.length} suitable books`);
145
+ }
146
+ } catch (error) {
147
+ console.warn('Failed to preload books:', error);
148
+ }
149
+ }
150
+
151
+ processHFBook(rowData) {
152
+ // Extract and clean book data from HF format
153
+ const originalText = rowData.text || '';
154
+ const cleanedText = this.cleanProjectGutenbergText(originalText);
155
+
156
+ // Try multiple metadata extraction approaches
157
+ const extractedMetadata = this.extractMetadata(originalText);
158
+
159
+ // Use HF dataset fields if available, otherwise use extracted metadata
160
+ const title = rowData.title || extractedMetadata.title || 'Classic Literature';
161
+ const author = rowData.author || extractedMetadata.author || 'Unknown Author';
162
+
163
+ return {
164
+ id: rowData.id || Math.random().toString(36),
165
+ title: title,
166
+ author: author,
167
+ text: cleanedText,
168
+ language: rowData.language || 'en',
169
+ source: 'project_gutenberg'
170
+ };
171
+ }
172
+
173
+ cleanProjectGutenbergText(text) {
174
+ if (!text) return '';
175
+
176
+ let cleaned = text;
177
+
178
+ // Remove Project Gutenberg start markers and everything before
179
+ const startPatterns = [
180
+ /\*\*\* START OF .*? \*\*\*/i,
181
+ /\*\*\*START OF .*?\*\*\*/i,
182
+ /START OF THE PROJECT GUTENBERG/i,
183
+ /GUTENBERG.*?EBOOK/i
184
+ ];
185
+
186
+ for (const pattern of startPatterns) {
187
+ const match = cleaned.match(pattern);
188
+ if (match) {
189
+ const startIndex = match.index + match[0].length;
190
+ // Skip to next line
191
+ const nextLine = cleaned.indexOf('\n', startIndex);
192
+ if (nextLine !== -1) {
193
+ cleaned = cleaned.substring(nextLine + 1);
194
+ }
195
+ break;
196
+ }
197
+ }
198
+
199
+ // Remove Project Gutenberg end markers and everything after
200
+ const endPatterns = [
201
+ /\*\*\* END OF .*? \*\*\*/i,
202
+ /\*\*\*END OF .*?\*\*\*/i,
203
+ /END OF THE PROJECT GUTENBERG/i,
204
+ /End of the Project Gutenberg/i
205
+ ];
206
+
207
+ for (const pattern of endPatterns) {
208
+ const match = cleaned.match(pattern);
209
+ if (match) {
210
+ cleaned = cleaned.substring(0, match.index);
211
+ break;
212
+ }
213
+ }
214
+
215
+ // Remove common Project Gutenberg artifacts
216
+ cleaned = cleaned
217
+ .replace(/\r\n/g, '\n') // Normalize line endings
218
+ .replace(/produced from images generously.*?\n/gi, '') // Remove scanning notes
219
+ .replace(/\n\s*\n\s*\n+/g, '\n\n') // Remove excessive line breaks
220
+ .replace(/^\s*CHAPTER.*$/gm, '') // Remove chapter headers
221
+ .replace(/^\s*Chapter.*$/gm, '') // Remove chapter headers
222
+ .replace(/^\s*\d+\s*$/gm, '') // Remove page numbers
223
+ .replace(/^\s*\[.*?\]\s*$/gm, '') // Remove bracketed notes
224
+ .replace(/^\s*_.*_\s*$/gm, '') // Remove italic notes
225
+ .replace(/[_*]/g, '') // Remove underscores and asterisks
226
+ .trim();
227
+
228
+ // Find the actual start of narrative content
229
+ const lines = cleaned.split('\n');
230
+ let contentStart = 0;
231
+
232
+ for (let i = 0; i < Math.min(50, lines.length); i++) {
233
+ const line = lines[i].trim();
234
+
235
+ // Skip empty lines, title pages, and metadata
236
+ if (!line ||
237
+ line.includes('Title:') ||
238
+ line.includes('Author:') ||
239
+ line.includes('Release Date:') ||
240
+ line.includes('Language:') ||
241
+ line.includes('Character set') ||
242
+ line.includes('www.gutenberg') ||
243
+ line.includes('Project Gutenberg') ||
244
+ line.length < 20) {
245
+ contentStart = i + 1;
246
+ continue;
247
+ }
248
+
249
+ // Found actual content
250
+ break;
251
+ }
252
+
253
+ if (contentStart > 0 && contentStart < lines.length) {
254
+ cleaned = lines.slice(contentStart).join('\n').trim();
255
+ }
256
+
257
+ return cleaned;
258
+ }
259
+
260
+ extractMetadata(text) {
261
+ const metadata = { title: 'Classic Literature', author: 'Unknown Author' };
262
+
263
+ if (!text) return metadata;
264
+
265
+ // Look for the standard Project Gutenberg header format
266
+ const firstLine = text.split('\n')[0].trim();
267
+
268
+ // Parse the standard format: "The Project Gutenberg EBook of [TITLE], by [AUTHOR]"
269
+ const pgMatch = firstLine.match(/^.*?The Project Gutenberg EBook of (.+?),\s*by\s+(.+?)$/i);
270
+ if (pgMatch) {
271
+ const title = pgMatch[1].trim();
272
+ const author = pgMatch[2].trim();
273
+
274
+ if (title && this.isValidTitle(title)) {
275
+ metadata.title = this.cleanMetadataField(title);
276
+ }
277
+ if (author && this.isValidAuthor(author)) {
278
+ metadata.author = this.cleanMetadataField(author);
279
+ }
280
+
281
+ return metadata;
282
+ }
283
+
284
+ // Fallback: Look for explicit Title: and Author: fields in first 50 lines
285
+ const lines = text.split('\n').slice(0, 50);
286
+
287
+ for (let i = 0; i < lines.length; i++) {
288
+ const line = lines[i].trim();
289
+
290
+ if (line.startsWith('Title:')) {
291
+ const title = line.replace('Title:', '').trim();
292
+ if (title && title.length > 1) {
293
+ metadata.title = this.cleanMetadataField(title);
294
+ }
295
+ } else if (line.startsWith('Author:')) {
296
+ const author = line.replace('Author:', '').trim();
297
+ if (author && author.length > 1) {
298
+ metadata.author = this.cleanMetadataField(author);
299
+ }
300
+ }
301
+ }
302
+
303
+ return metadata;
304
+ }
305
+
306
+ cleanMetadataField(field) {
307
+ return field
308
+ .replace(/\[.*?\]/g, '') // Remove bracketed info
309
+ .replace(/\s+/g, ' ') // Normalize whitespace
310
+ .trim();
311
+ }
312
+
313
+ isValidTitle(title) {
314
+ if (!title || title.length < 3 || title.length > 100) return false;
315
+ // Avoid fragments that are clearly not titles
316
+ if (title.includes('Project Gutenberg') ||
317
+ title.includes('www.') ||
318
+ title.includes('produced from') ||
319
+ title.includes('images generously')) return false;
320
+ return true;
321
+ }
322
+
323
+ isValidAuthor(author) {
324
+ if (!author || author.length < 3 || author.length > 50) return false;
325
+ // Basic validation - should look like a name
326
+ if (author.includes('Project Gutenberg') ||
327
+ author.includes('www.') ||
328
+ author.includes('produced from')) return false;
329
+ return true;
330
+ }
331
+
332
+ isValidForCloze(book) {
333
+ if (!book.text) return false;
334
+
335
+ const textLength = book.text.length;
336
+
337
+ // Filter criteria for cloze exercises
338
+ if (textLength < 5000) return false; // Too short
339
+ if (textLength > 500000) return false; // Too long for performance
340
+
341
+ // Check for excessive formatting (likely reference material)
342
+ const lineBreakRatio = (book.text.match(/\n\n/g) || []).length / textLength;
343
+ if (lineBreakRatio > 0.01) return false; // Too fragmented
344
+
345
+ // Ensure it has actual narrative content
346
+ const sentenceCount = (book.text.match(/[.!?]+/g) || []).length;
347
+ if (sentenceCount < 20) return false; // Too few sentences
348
+
349
+ return true;
350
+ }
351
+
352
+ async getRandomBook() {
353
+ if (!this.isLoaded) {
354
+ throw new Error('Dataset not loaded');
355
+ }
356
+
357
+ // Prioritize preloaded books for fast access (90% chance)
358
+ if (this.streamingEnabled && this.preloadedBooks.length > 0 && Math.random() > 0.1) {
359
+ const randomIndex = Math.floor(Math.random() * this.preloadedBooks.length);
360
+ return this.preloadedBooks[randomIndex];
361
+ }
362
+
363
+ // Use local samples for remaining 10% + fallback
364
+ const fallbackBooks = this.books.length > 0 ? this.books : this.getSampleBooks();
365
+ const randomIndex = Math.floor(Math.random() * fallbackBooks.length);
366
+ return fallbackBooks[randomIndex];
367
+ }
368
+
369
+ async getStreamingBook() {
370
+ // Use preloaded books for immediate access
371
+ if (this.preloadedBooks.length > 0) {
372
+ const randomIndex = Math.floor(Math.random() * this.preloadedBooks.length);
373
+ return this.preloadedBooks[randomIndex];
374
+ }
375
+
376
+ // If no preloaded books, try to fetch directly
377
+ try {
378
+ const offset = Math.floor(Math.random() * 1000); // Random offset
379
+ const url = `${this.apiBase}/rows?dataset=${this.datasetName}&config=default&split=en&offset=${offset}&length=1`;
380
+ const response = await fetch(url);
381
+
382
+ if (response.ok) {
383
+ const data = await response.json();
384
+ if (data.rows && data.rows.length > 0) {
385
+ const book = this.processHFBook(data.rows[0].row);
386
+ if (this.isValidForCloze(book)) {
387
+ return book;
388
+ }
389
+ }
390
+ }
391
+ } catch (error) {
392
+ console.warn('Direct streaming failed:', error);
393
+ }
394
+
395
+ return null;
396
+ }
397
+
398
+ async getBooksByDifficulty(level) {
399
+ const difficultyRanges = {
400
+ 1: { min: 5000, max: 30000 }, // Short stories/novellas
401
+ 2: { min: 25000, max: 80000 }, // Medium novels
402
+ 3: { min: 60000, max: 200000 } // Long novels
403
+ };
404
+
405
+ const range = difficultyRanges[Math.min(level, 3)];
406
+
407
+ if (this.streamingEnabled && this.preloadedBooks.length > 0) {
408
+ // Filter preloaded books by difficulty
409
+ const suitable = this.preloadedBooks.filter(book =>
410
+ book.text.length >= range.min && book.text.length <= range.max
411
+ );
412
+
413
+ if (suitable.length > 0) {
414
+ const randomIndex = Math.floor(Math.random() * suitable.length);
415
+ return suitable[randomIndex];
416
+ }
417
+ }
418
+
419
+ // Fallback to local filtering
420
+ const fallbackBooks = this.books.length > 0 ? this.books : this.getSampleBooks();
421
+ const filtered = fallbackBooks.filter(book =>
422
+ book.text.length >= range.min && book.text.length <= range.max
423
+ );
424
+
425
+ if (filtered.length > 0) {
426
+ const randomIndex = Math.floor(Math.random() * filtered.length);
427
+ return filtered[randomIndex];
428
+ }
429
+
430
+ // If no books match difficulty, return any available book
431
+ return await this.getRandomBook();
432
+ }
433
+
434
+ getBookById(id) {
435
+ // Search in both preloaded and local books
436
+ const allBooks = [...this.preloadedBooks, ...this.books];
437
+ return allBooks.find(book => book.id === id);
438
+ }
439
+
440
+ searchBooks(query) {
441
+ if (!query) return [...this.preloadedBooks, ...this.books];
442
+
443
+ const lowerQuery = query.toLowerCase();
444
+ const allBooks = [...this.preloadedBooks, ...this.books];
445
+ return allBooks.filter(book =>
446
+ book.title.toLowerCase().includes(lowerQuery) ||
447
+ book.author.toLowerCase().includes(lowerQuery)
448
+ );
449
+ }
450
+
451
+ // Health check for streaming status
452
+ getStatus() {
453
+ return {
454
+ streamingEnabled: this.streamingEnabled,
455
+ preloadedBooks: this.preloadedBooks.length,
456
+ localBooks: this.books.length,
457
+ totalAvailable: this.preloadedBooks.length + this.books.length,
458
+ source: this.streamingEnabled ? 'HuggingFace Datasets' : 'Local Samples'
459
+ };
460
+ }
461
+
462
+ // Refresh preloaded books cache
463
+ async refreshCache() {
464
+ if (this.streamingEnabled) {
465
+ await this.preloadBooks(100);
466
+ console.log(`🔄 Cache refreshed: ${this.preloadedBooks.length} books`);
467
+ }
468
+ }
469
+ }
470
+
471
+ export default new HuggingFaceDatasetService();
src/chatInterface.js ADDED
@@ -0,0 +1,334 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Chat UI components for contextual hints
2
+ class ChatUI {
3
+ constructor(gameLogic) {
4
+ this.game = gameLogic;
5
+ this.activeChatBlank = null;
6
+ this.chatModal = null;
7
+ this.isOpen = false;
8
+ this.messageHistory = new Map(); // blankId -> array of messages for persistent history
9
+ this.setupChatModal();
10
+ }
11
+
12
+ // Create and setup chat modal
13
+ setupChatModal() {
14
+ // Create modal HTML
15
+ const modalHTML = `
16
+ <div id="chat-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden flex items-center justify-center">
17
+ <div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[80vh] flex flex-col">
18
+ <!-- Header -->
19
+ <div class="flex items-center justify-between p-4 border-b">
20
+ <h3 id="chat-title" class="text-lg font-semibold text-gray-900">
21
+ Chat about Word #1
22
+ </h3>
23
+ <button id="chat-close" class="text-gray-400 hover:text-gray-600">
24
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
25
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
26
+ </svg>
27
+ </button>
28
+ </div>
29
+
30
+ <!-- Chat messages area -->
31
+ <div id="chat-messages" class="flex-1 overflow-y-auto p-4 min-h-[200px] max-h-[400px]">
32
+ <div class="text-center text-gray-500 text-sm">
33
+ Ask me anything about this word! I can help with meaning, context, grammar, or give you hints.
34
+ </div>
35
+ </div>
36
+
37
+ <!-- Suggested questions -->
38
+ <div id="suggested-questions" class="px-4 py-2 border-t border-gray-100">
39
+ <div id="suggestion-buttons" class="flex flex-wrap gap-1">
40
+ <!-- Suggestion buttons will be inserted here -->
41
+ </div>
42
+ </div>
43
+
44
+ <!-- Question buttons area -->
45
+ <div class="p-4 border-t">
46
+ <div class="text-sm text-gray-600 mb-3">Choose a question:</div>
47
+ <div id="question-buttons" class="grid grid-cols-2 gap-2">
48
+ <!-- Question buttons will be inserted here -->
49
+ </div>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ `;
54
+
55
+ // Insert modal into page
56
+ document.body.insertAdjacentHTML('beforeend', modalHTML);
57
+
58
+ this.chatModal = document.getElementById('chat-modal');
59
+ this.setupEventListeners();
60
+ }
61
+
62
+ // Setup event listeners for chat modal
63
+ setupEventListeners() {
64
+ const closeBtn = document.getElementById('chat-close');
65
+
66
+ // Close modal
67
+ closeBtn.addEventListener('click', () => this.closeChat());
68
+ this.chatModal.addEventListener('click', (e) => {
69
+ if (e.target === this.chatModal) this.closeChat();
70
+ });
71
+
72
+ // ESC key to close
73
+ document.addEventListener('keydown', (e) => {
74
+ if (e.key === 'Escape' && this.isOpen) this.closeChat();
75
+ });
76
+ }
77
+
78
+ // Open chat for specific blank
79
+ async openChat(blankIndex) {
80
+ this.activeChatBlank = blankIndex;
81
+ this.isOpen = true;
82
+
83
+ // Update title
84
+ const title = document.getElementById('chat-title');
85
+ title.textContent = `Help with Word #${blankIndex + 1}`;
86
+
87
+ // Restore previous messages or show intro
88
+ this.restoreMessages(blankIndex);
89
+
90
+ // Load question buttons
91
+ this.loadQuestionButtons();
92
+
93
+ // Show modal
94
+ this.chatModal.classList.remove('hidden');
95
+ }
96
+
97
+ // Close chat modal
98
+ closeChat() {
99
+ this.isOpen = false;
100
+ this.chatModal.classList.add('hidden');
101
+ this.activeChatBlank = null;
102
+ }
103
+
104
+ // Clear messages and show intro
105
+ clearMessages() {
106
+ const messagesContainer = document.getElementById('chat-messages');
107
+ messagesContainer.innerHTML = `
108
+ <div class="text-center text-gray-500 text-sm">
109
+ Choose a question below to get help with this word.
110
+ </div>
111
+ `;
112
+ }
113
+
114
+ // Restore messages for a specific blank or show intro
115
+ restoreMessages(blankIndex) {
116
+ const messagesContainer = document.getElementById('chat-messages');
117
+ const blankId = `blank_${blankIndex}`;
118
+ const history = this.messageHistory.get(blankId);
119
+
120
+ if (history && history.length > 0) {
121
+ // Restore previous messages
122
+ messagesContainer.innerHTML = '';
123
+ history.forEach(msg => {
124
+ this.displayMessage(msg.sender, msg.content, msg.isUser);
125
+ });
126
+ } else {
127
+ // Show intro for new conversation
128
+ this.clearMessages();
129
+ }
130
+ }
131
+
132
+ // Display a message without storing it (used for restoration)
133
+ displayMessage(sender, content, isUser) {
134
+ const messagesContainer = document.getElementById('chat-messages');
135
+ const alignment = isUser ? 'flex justify-end' : 'flex justify-start';
136
+ const messageClass = isUser
137
+ ? 'bg-blue-500 text-white'
138
+ : 'bg-gray-100 text-gray-900';
139
+ const displaySender = isUser ? 'You' : sender;
140
+
141
+ const messageHTML = `
142
+ <div class="mb-3 ${alignment}">
143
+ <div class="${messageClass} rounded-lg px-3 py-2 max-w-[80%]">
144
+ <div class="text-xs font-medium mb-1">${displaySender}</div>
145
+ <div class="text-sm">${this.escapeHtml(content)}</div>
146
+ </div>
147
+ </div>
148
+ `;
149
+
150
+ messagesContainer.insertAdjacentHTML('beforeend', messageHTML);
151
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
152
+ }
153
+
154
+ // Clear all chat history (called when round ends)
155
+ clearChatHistory() {
156
+ this.messageHistory.clear();
157
+ }
158
+
159
+ // Load question buttons with disabled state for used questions
160
+ loadQuestionButtons() {
161
+ const buttonsContainer = document.getElementById('question-buttons');
162
+ const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
163
+
164
+ let html = '';
165
+ questions.forEach(question => {
166
+ const isDisabled = question.used;
167
+ const buttonClass = isDisabled
168
+ ? 'question-btn px-3 py-2 bg-gray-200 text-gray-500 rounded cursor-not-allowed text-sm font-medium'
169
+ : 'question-btn px-3 py-2 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 text-sm font-medium';
170
+
171
+ html += `
172
+ <button class="${buttonClass}"
173
+ data-type="${question.type}"
174
+ ${isDisabled ? 'disabled' : ''}>
175
+ ${question.text}${isDisabled ? ' ✓' : ''}
176
+ </button>
177
+ `;
178
+ });
179
+
180
+ buttonsContainer.innerHTML = html;
181
+
182
+ // Add click listeners to individual question buttons (not the container)
183
+ buttonsContainer.querySelectorAll('.question-btn').forEach(btn => {
184
+ btn.addEventListener('click', (e) => {
185
+ if (!btn.disabled) {
186
+ e.preventDefault();
187
+ e.stopPropagation();
188
+ const questionType = btn.dataset.type;
189
+ this.askQuestion(questionType);
190
+ }
191
+ });
192
+ });
193
+ }
194
+
195
+ // Ask a specific question
196
+ async askQuestion(questionType) {
197
+ if (this.activeChatBlank === null) return;
198
+
199
+ // Get current user input for the blank
200
+ const currentInput = this.getCurrentBlankInput();
201
+
202
+ // Get the actual question text from the button that was clicked
203
+ const questions = this.game.getSuggestedQuestionsForBlank(this.activeChatBlank);
204
+ const selectedQuestion = questions.find(q => q.type === questionType);
205
+ const questionText = selectedQuestion ? selectedQuestion.text : this.getQuestionText(questionType);
206
+
207
+ // Show question and loading
208
+ this.addMessageToChat('You', questionText, true);
209
+ this.showTypingIndicator();
210
+
211
+ try {
212
+ // Send to chat service with question type
213
+ const response = await this.game.askQuestionAboutBlank(
214
+ this.activeChatBlank,
215
+ questionType,
216
+ currentInput
217
+ );
218
+
219
+ this.hideTypingIndicator();
220
+
221
+ if (response.success) {
222
+ // Make sure we're displaying the response string, not the object
223
+ const responseText = typeof response.response === 'string'
224
+ ? response.response
225
+ : response.response.response || 'Sorry, I had trouble with that question.';
226
+ this.addMessageToChat('Cluemaster', responseText, false);
227
+
228
+ // Refresh question buttons to show the used question as disabled
229
+ this.loadQuestionButtons();
230
+ } else {
231
+ this.addMessageToChat('Cluemaster', response.message || 'Sorry, I had trouble with that question.', false);
232
+ }
233
+
234
+ } catch (error) {
235
+ this.hideTypingIndicator();
236
+ console.error('Chat error:', error);
237
+ this.addMessageToChat('Cluemaster', 'Sorry, I encountered an error. Please try again.', false);
238
+ }
239
+ }
240
+
241
+ // Get question text for display
242
+ getQuestionText(questionType) {
243
+ const questions = {
244
+ 'grammar': 'What type of word is this?',
245
+ 'meaning': 'What does this word mean?',
246
+ 'context': 'Why does this word fit here?',
247
+ 'clue': 'Give me a clue'
248
+ };
249
+ return questions[questionType] || questions['clue'];
250
+ }
251
+
252
+ // Get current input for the active blank
253
+ getCurrentBlankInput() {
254
+ const input = document.querySelector(`input[data-blank-index="${this.activeChatBlank}"]`);
255
+ return input ? input.value.trim() : '';
256
+ }
257
+
258
+ // Add message to chat display and store in history
259
+ addMessageToChat(sender, content, isUser) {
260
+ // Store message in history for current blank
261
+ if (this.activeChatBlank !== null) {
262
+ const blankId = `blank_${this.activeChatBlank}`;
263
+ if (!this.messageHistory.has(blankId)) {
264
+ this.messageHistory.set(blankId, []);
265
+ }
266
+
267
+ // Change "Tutor" to "Cluemaster" for display and storage
268
+ const displaySender = sender === 'Tutor' ? 'Cluemaster' : sender;
269
+
270
+ this.messageHistory.get(blankId).push({
271
+ sender: displaySender,
272
+ content: content,
273
+ isUser: isUser,
274
+ timestamp: Date.now()
275
+ });
276
+ }
277
+
278
+ // Display the message
279
+ this.displayMessage(sender === 'Tutor' ? 'Cluemaster' : sender, content, isUser);
280
+ }
281
+
282
+ // Show typing indicator
283
+ showTypingIndicator() {
284
+ const messagesContainer = document.getElementById('chat-messages');
285
+ const typingHTML = `
286
+ <div id="typing-indicator" class="mb-3 mr-auto max-w-[80%]">
287
+ <div class="bg-gray-100 text-gray-900 rounded-lg px-3 py-2">
288
+ <div class="text-xs font-medium mb-1">Cluemaster</div>
289
+ <div class="text-sm">
290
+ <span class="typing-dots">
291
+ <span>.</span><span>.</span><span>.</span>
292
+ </span>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ `;
297
+
298
+ messagesContainer.insertAdjacentHTML('beforeend', typingHTML);
299
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
300
+ }
301
+
302
+ // Hide typing indicator
303
+ hideTypingIndicator() {
304
+ const indicator = document.getElementById('typing-indicator');
305
+ if (indicator) indicator.remove();
306
+ }
307
+
308
+ // Escape HTML to prevent XSS
309
+ escapeHtml(text) {
310
+ const div = document.createElement('div');
311
+ div.textContent = text;
312
+ return div.innerHTML;
313
+ }
314
+
315
+ // Setup chat buttons for blanks
316
+ setupChatButtons() {
317
+ // Remove existing listeners
318
+ document.querySelectorAll('.chat-button').forEach(btn => {
319
+ btn.replaceWith(btn.cloneNode(true));
320
+ });
321
+
322
+ // Add new listeners
323
+ document.querySelectorAll('.chat-button').forEach(btn => {
324
+ btn.addEventListener('click', (e) => {
325
+ e.preventDefault();
326
+ e.stopPropagation();
327
+ const blankIndex = parseInt(btn.dataset.blankIndex);
328
+ this.openChat(blankIndex);
329
+ });
330
+ });
331
+ }
332
+ }
333
+
334
+ export default ChatUI;
src/clozeGameEngine.js ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
6
+ class ClozeGame {
7
+ constructor() {
8
+ this.currentBook = null;
9
+ this.originalText = '';
10
+ this.clozeText = '';
11
+ this.blanks = [];
12
+ this.userAnswers = [];
13
+ this.score = 0;
14
+ this.currentRound = 1;
15
+ this.currentLevel = 1; // Track difficulty level separately from round
16
+ this.contextualization = '';
17
+ this.hints = [];
18
+ this.chatService = new ChatService(aiService);
19
+ this.lastResults = null; // Store results for answer revelation
20
+ }
21
+
22
+ async initialize() {
23
+ try {
24
+ await bookDataService.loadDataset();
25
+ console.log('Game initialized successfully');
26
+ } catch (error) {
27
+ console.error('Failed to initialize game:', error);
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ async startNewRound() {
33
+ try {
34
+ // Get a random book (now async with HF streaming)
35
+ this.currentBook = await bookDataService.getRandomBook();
36
+
37
+ // Extract a coherent passage avoiding fragmented text
38
+ const fullText = this.currentBook.text;
39
+ let passage = this.extractCoherentPassage(fullText);
40
+
41
+ this.originalText = passage.trim();
42
+
43
+ // Run AI calls in parallel for faster loading
44
+ const [clozeResult, contextualizationResult] = await Promise.all([
45
+ this.createClozeText(),
46
+ this.generateContextualization()
47
+ ]);
48
+
49
+ return {
50
+ title: this.currentBook.title,
51
+ author: this.currentBook.author,
52
+ text: this.clozeText,
53
+ blanks: this.blanks,
54
+ contextualization: this.contextualization,
55
+ hints: this.hints
56
+ };
57
+ } catch (error) {
58
+ console.error('Error starting new round:', error);
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ extractCoherentPassage(text) {
64
+ // Simple elegant solution: start from middle third of book where actual content is
65
+ const textLength = text.length;
66
+ const startFromMiddle = Math.floor(textLength * 0.3); // Skip first 30%
67
+ const endAtThreeQuarters = Math.floor(textLength * 0.8); // Stop before last 20%
68
+
69
+ // Random position in the middle section
70
+ const availableLength = endAtThreeQuarters - startFromMiddle;
71
+ const randomOffset = Math.floor(Math.random() * Math.max(0, availableLength - 800));
72
+ const startIndex = startFromMiddle + randomOffset;
73
+
74
+ // Extract passage
75
+ let passage = text.substring(startIndex, startIndex + 800);
76
+
77
+ // Clean up start - find first complete sentence
78
+ const firstSentenceEnd = passage.search(/[.!?]\s+[A-Z]/);
79
+ if (firstSentenceEnd > 0 && firstSentenceEnd < 200) {
80
+ passage = passage.substring(firstSentenceEnd + 2);
81
+ }
82
+
83
+ // Clean up end - end at complete sentence
84
+ const lastSentenceEnd = passage.lastIndexOf('.');
85
+ if (lastSentenceEnd > 300) {
86
+ passage = passage.substring(0, lastSentenceEnd + 1);
87
+ }
88
+
89
+ return passage.trim();
90
+ }
91
+
92
+ async createClozeText() {
93
+ const words = this.originalText.split(' ');
94
+ // Progressive difficulty: levels 1-2 = 1 blank, levels 3-4 = 2 blanks, level 5+ = 3 blanks
95
+ let numberOfBlanks;
96
+ if (this.currentLevel <= 2) {
97
+ numberOfBlanks = 1;
98
+ } else if (this.currentLevel <= 4) {
99
+ numberOfBlanks = 2;
100
+ } else {
101
+ numberOfBlanks = 3;
102
+ }
103
+
104
+ // Update chat service with current level
105
+ this.chatService.setLevel(this.currentLevel);
106
+
107
+ // Always use AI for word selection with fallback
108
+ let significantWords;
109
+ try {
110
+ significantWords = await aiService.selectSignificantWords(
111
+ this.originalText,
112
+ numberOfBlanks
113
+ );
114
+ console.log('AI selected words:', significantWords);
115
+ } catch (error) {
116
+ console.warn('AI word selection failed, using manual fallback:', error);
117
+ significantWords = this.selectWordsManually(words, numberOfBlanks);
118
+ console.log('Manual selected words:', significantWords);
119
+ }
120
+
121
+ // Ensure we have valid words
122
+ if (!significantWords || significantWords.length === 0) {
123
+ console.warn('No words selected, using emergency fallback');
124
+ significantWords = this.selectWordsManually(words, numberOfBlanks);
125
+ }
126
+
127
+ // Find word indices for selected significant words, distributed throughout passage
128
+ const selectedIndices = [];
129
+ const wordsLower = words.map(w => w.toLowerCase().replace(/[^\w]/g, ''));
130
+
131
+ // Create sections of the passage to ensure distribution
132
+ const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks);
133
+
134
+ significantWords.forEach((significantWord, index) => {
135
+ // Clean the significant word for matching
136
+ const cleanSignificant = significantWord.toLowerCase().replace(/[^\w]/g, '');
137
+
138
+ // Look for the word within the appropriate section for better distribution
139
+ const sectionStart = passageSections[index] ? passageSections[index].start : 0;
140
+ const sectionEnd = passageSections[index] ? passageSections[index].end : words.length;
141
+
142
+ let wordIndex = -1;
143
+
144
+ // First try to find the word in the designated section
145
+ for (let i = sectionStart; i < sectionEnd; i++) {
146
+ if (wordsLower[i] === cleanSignificant && !selectedIndices.includes(i)) {
147
+ wordIndex = i;
148
+ break;
149
+ }
150
+ }
151
+
152
+ // If not found in section, look globally
153
+ if (wordIndex === -1) {
154
+ wordIndex = wordsLower.findIndex((word, idx) =>
155
+ word === cleanSignificant && !selectedIndices.includes(idx)
156
+ );
157
+ }
158
+
159
+ if (wordIndex !== -1) {
160
+ selectedIndices.push(wordIndex);
161
+ } else {
162
+ console.warn(`Could not find word "${significantWord}" in passage`);
163
+ }
164
+ });
165
+
166
+ // Log the matching results
167
+ console.log(`Found ${selectedIndices.length} of ${significantWords.length} words in passage`);
168
+
169
+ // If no words were matched, fall back to manual selection
170
+ if (selectedIndices.length === 0) {
171
+ console.warn('No AI words matched in passage, using manual selection');
172
+ const manualWords = this.selectWordsManually(words, numberOfBlanks);
173
+
174
+ // Try to match manual words
175
+ manualWords.forEach((manualWord, index) => {
176
+ const cleanManual = manualWord.toLowerCase().replace(/[^\w]/g, '');
177
+ const wordIndex = wordsLower.findIndex((word, idx) =>
178
+ word === cleanManual && !selectedIndices.includes(idx)
179
+ );
180
+
181
+ if (wordIndex !== -1) {
182
+ selectedIndices.push(wordIndex);
183
+ }
184
+ });
185
+
186
+ console.log(`After manual fallback: ${selectedIndices.length} words found`);
187
+ }
188
+
189
+ // Sort indices for easier processing
190
+ selectedIndices.sort((a, b) => a - b);
191
+
192
+ // Final safety check - if still no words found, pick random content words
193
+ if (selectedIndices.length === 0) {
194
+ console.error('Critical: No words could be selected, using emergency fallback');
195
+ const contentWords = words.map((word, idx) => ({ word: word.toLowerCase().replace(/[^\w]/g, ''), idx }))
196
+ .filter(item => item.word.length > 3 && !['the', 'and', 'but', 'for', 'are', 'was'].includes(item.word))
197
+ .slice(0, numberOfBlanks);
198
+
199
+ selectedIndices.push(...contentWords.map(item => item.idx));
200
+ console.log(`Emergency fallback selected ${selectedIndices.length} words`);
201
+ }
202
+
203
+ // Create blanks array and cloze text
204
+ this.blanks = [];
205
+ this.hints = [];
206
+ const clozeWords = [...words];
207
+
208
+ for (let i = 0; i < selectedIndices.length; i++) {
209
+ const index = selectedIndices[i];
210
+ const originalWord = words[index];
211
+ const cleanWord = originalWord.replace(/[^\w]/g, '');
212
+
213
+ const blankData = {
214
+ index: i,
215
+ originalWord: cleanWord,
216
+ wordIndex: index
217
+ };
218
+
219
+ this.blanks.push(blankData);
220
+
221
+ // Initialize chat context for this word
222
+ const wordContext = {
223
+ originalWord: cleanWord,
224
+ sentence: this.originalText,
225
+ passage: this.originalText,
226
+ bookTitle: this.currentBook.title,
227
+ author: this.currentBook.author,
228
+ wordPosition: index,
229
+ difficulty: this.calculateWordDifficulty(cleanWord, index, words)
230
+ };
231
+
232
+ this.chatService.initializeWordContext(`blank_${i}`, wordContext);
233
+
234
+ // Generate structural hint based on level
235
+ let structuralHint;
236
+ if (this.currentLevel <= 2) {
237
+ // Levels 1-2: show length, first letter, and last letter
238
+ structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}", ends with "${cleanWord[cleanWord.length - 1]}"`;
239
+ } else {
240
+ // Level 3+: show length and first letter only
241
+ structuralHint = `${cleanWord.length} letters, starts with "${cleanWord[0]}"`;
242
+ }
243
+ this.hints.push({ index: i, hint: structuralHint });
244
+
245
+ // Replace word with input field placeholder
246
+ clozeWords[index] = `___BLANK_${i}___`;
247
+ }
248
+
249
+ this.clozeText = clozeWords.join(' ');
250
+ this.userAnswers = new Array(this.blanks.length).fill('');
251
+
252
+ // Debug: Log the created cloze text
253
+ console.log('Created cloze text:', this.clozeText);
254
+ console.log('Number of blanks:', this.blanks.length);
255
+
256
+ return true; // Return success indicator
257
+ }
258
+
259
+ dividePassageIntoSections(totalWords, numberOfBlanks) {
260
+ const sections = [];
261
+ const sectionSize = Math.floor(totalWords / numberOfBlanks);
262
+
263
+ for (let i = 0; i < numberOfBlanks; i++) {
264
+ const start = i * sectionSize;
265
+ const end = i === numberOfBlanks - 1 ? totalWords : (i + 1) * sectionSize;
266
+ sections.push({ start, end });
267
+ }
268
+
269
+ return sections;
270
+ }
271
+
272
+ selectWordsManually(words, numberOfBlanks) {
273
+ // Fallback manual word selection - avoid function words completely
274
+ const functionWords = new Set([
275
+ // Articles
276
+ 'the', 'a', 'an',
277
+ // Prepositions
278
+ 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'over', 'after',
279
+ // Conjunctions
280
+ 'and', 'or', 'but', 'so', 'yet', 'nor', 'because', 'since', 'although', 'if', 'when', 'while',
281
+ // Pronouns
282
+ 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', 'my', 'your', 'his', 'her', 'its', 'our', 'their',
283
+ 'this', 'that', 'these', 'those', 'who', 'what', 'which', 'whom', 'whose',
284
+ // Auxiliary verbs
285
+ 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did',
286
+ 'will', 'would', 'could', 'should', 'may', 'might', 'must', 'can', 'shall'
287
+ ]);
288
+
289
+ // Get content words with their indices for better distribution
290
+ const contentWordIndices = [];
291
+ words.forEach((word, index) => {
292
+ const cleanWord = word.toLowerCase().replace(/[^\w]/g, '');
293
+ if (cleanWord.length > 3 && !functionWords.has(cleanWord)) {
294
+ contentWordIndices.push({ word: cleanWord, index });
295
+ }
296
+ });
297
+
298
+ // Distribute selection across sections
299
+ const passageSections = this.dividePassageIntoSections(words.length, numberOfBlanks);
300
+ const selectedWords = [];
301
+
302
+ for (let i = 0; i < numberOfBlanks && i < passageSections.length; i++) {
303
+ const section = passageSections[i];
304
+ const sectionWords = contentWordIndices.filter(item =>
305
+ item.index >= section.start && item.index < section.end
306
+ );
307
+
308
+ if (sectionWords.length > 0) {
309
+ const randomIndex = Math.floor(Math.random() * sectionWords.length);
310
+ selectedWords.push(sectionWords[randomIndex].word);
311
+ }
312
+ }
313
+
314
+ // Fill remaining slots if needed
315
+ while (selectedWords.length < numberOfBlanks && contentWordIndices.length > 0) {
316
+ const availableWords = contentWordIndices
317
+ .map(item => item.word)
318
+ .filter(word => !selectedWords.includes(word));
319
+
320
+ if (availableWords.length > 0) {
321
+ const randomIndex = Math.floor(Math.random() * availableWords.length);
322
+ selectedWords.push(availableWords[randomIndex]);
323
+ } else {
324
+ break;
325
+ }
326
+ }
327
+
328
+ return selectedWords;
329
+ }
330
+
331
+ async generateContextualization() {
332
+ // Always use AI for contextualization
333
+ try {
334
+ this.contextualization = await aiService.generateContextualization(
335
+ this.currentBook.title,
336
+ this.currentBook.author
337
+ );
338
+ return this.contextualization;
339
+ } catch (error) {
340
+ console.warn('AI contextualization failed, using fallback:', error);
341
+ this.contextualization = `"${this.currentBook.title}" by ${this.currentBook.author} - A classic work of literature.`;
342
+ return this.contextualization;
343
+ }
344
+ }
345
+
346
+ renderClozeText() {
347
+ let html = this.clozeText;
348
+
349
+ this.blanks.forEach((blank, index) => {
350
+ const inputHtml = `<input type="text"
351
+ class="cloze-input"
352
+ data-blank-index="${index}"
353
+ placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
354
+ style="width: ${Math.max(80, blank.originalWord.length * 12)}px;">`;
355
+
356
+ html = html.replace(`___BLANK_${index}___`, inputHtml);
357
+ });
358
+
359
+ return html;
360
+ }
361
+
362
+ submitAnswers(answers) {
363
+ this.userAnswers = answers;
364
+ let correctCount = 0;
365
+ const results = [];
366
+
367
+ this.blanks.forEach((blank, index) => {
368
+ const userAnswer = answers[index].trim().toLowerCase();
369
+ const correctAnswer = blank.originalWord.toLowerCase();
370
+ const isCorrect = userAnswer === correctAnswer;
371
+
372
+ if (isCorrect) correctCount++;
373
+
374
+ results.push({
375
+ blankIndex: index,
376
+ userAnswer: answers[index],
377
+ correctAnswer: blank.originalWord,
378
+ isCorrect
379
+ });
380
+ });
381
+
382
+ const scorePercentage = Math.round((correctCount / this.blanks.length) * 100);
383
+ this.score = scorePercentage;
384
+
385
+ // Calculate pass requirements based on number of blanks
386
+ const totalBlanks = this.blanks.length;
387
+ const requiredCorrect = this.calculateRequiredCorrect(totalBlanks);
388
+ const passed = correctCount >= requiredCorrect;
389
+
390
+ const resultsData = {
391
+ correct: correctCount,
392
+ total: this.blanks.length,
393
+ percentage: scorePercentage,
394
+ passed: passed,
395
+ results,
396
+ canAdvanceLevel: passed,
397
+ shouldRevealAnswers: !passed,
398
+ requiredCorrect: requiredCorrect,
399
+ currentLevel: this.currentLevel
400
+ };
401
+
402
+ // Store results for potential answer revelation
403
+ this.lastResults = resultsData;
404
+
405
+ return resultsData;
406
+ }
407
+
408
+ // Calculate required correct answers based on total blanks
409
+ calculateRequiredCorrect(totalBlanks) {
410
+ if (totalBlanks === 1) {
411
+ // Level 1: Must get the single word correct
412
+ return 1;
413
+ } else if (totalBlanks % 2 === 1) {
414
+ // Odd number of blanks (3, 5, etc.): require all but one
415
+ return totalBlanks - 1;
416
+ } else {
417
+ // Even number of blanks: require all correct
418
+ return totalBlanks;
419
+ }
420
+ }
421
+
422
+ showAnswers() {
423
+ return this.blanks.map(blank => ({
424
+ index: blank.index,
425
+ word: blank.originalWord
426
+ }));
427
+ }
428
+
429
+ nextRound() {
430
+ // Check if user passed the previous round
431
+ const passed = this.lastResults && this.lastResults.passed;
432
+
433
+ // Always increment round counter
434
+ this.currentRound++;
435
+
436
+ // Only advance level if user passed
437
+ if (passed) {
438
+ this.currentLevel++;
439
+ }
440
+ // If failed, stay at same level
441
+
442
+ // Clear chat conversations for new round
443
+ this.chatService.clearConversations();
444
+
445
+ // Clear last results since we're moving to new round
446
+ this.lastResults = null;
447
+
448
+ return this.startNewRound();
449
+ }
450
+
451
+ // Get answers for current round (for revelation when switching passages)
452
+ getCurrentAnswers() {
453
+ if (!this.lastResults) return null;
454
+
455
+ return {
456
+ hasResults: true,
457
+ passed: this.lastResults.passed,
458
+ shouldRevealAnswers: this.lastResults.shouldRevealAnswers,
459
+ currentLevel: this.lastResults.currentLevel,
460
+ requiredCorrect: this.lastResults.requiredCorrect,
461
+ answers: this.blanks.map(blank => ({
462
+ index: blank.index,
463
+ correctAnswer: blank.originalWord,
464
+ userAnswer: this.lastResults.results[blank.index]?.userAnswer || '',
465
+ isCorrect: this.lastResults.results[blank.index]?.isCorrect || false
466
+ }))
467
+ };
468
+ }
469
+
470
+ // Calculate difficulty of a word based on various factors
471
+ calculateWordDifficulty(word, position, allWords) {
472
+ let difficulty = 1;
473
+
474
+ // Length factor
475
+ if (word.length > 8) difficulty += 2;
476
+ else if (word.length > 5) difficulty += 1;
477
+
478
+ // Position factor (middle words might be harder)
479
+ const relativePosition = position / allWords.length;
480
+ if (relativePosition > 0.3 && relativePosition < 0.7) difficulty += 1;
481
+
482
+ // Complexity factors
483
+ if (word.includes('ing') || word.includes('ed')) difficulty += 0.5;
484
+ if (word.includes('tion') || word.includes('sion')) difficulty += 1;
485
+
486
+ // Current level factor
487
+ difficulty += (this.currentLevel - 1) * 0.5;
488
+
489
+ return Math.min(5, Math.max(1, Math.round(difficulty)));
490
+ }
491
+
492
+ // Simple, clean hint with just essential info based on level
493
+ generateContextualFallbackHint(word, wordIndex, allWords) {
494
+ if (this.currentLevel <= 2) {
495
+ return `${word.length} letters, starts with "${word[0]}", ends with "${word[word.length - 1]}"`;
496
+ } else {
497
+ return `${word.length} letters, starts with "${word[0]}"`;
498
+ }
499
+ }
500
+
501
+ // Chat functionality methods
502
+ async askQuestionAboutBlank(blankIndex, questionType, currentInput = '') {
503
+ const blankId = `blank_${blankIndex}`;
504
+ return await this.chatService.askQuestion(blankId, questionType, currentInput);
505
+ }
506
+
507
+ getSuggestedQuestionsForBlank(blankIndex) {
508
+ const blankId = `blank_${blankIndex}`;
509
+ return this.chatService.getSuggestedQuestions(blankId);
510
+ }
511
+
512
+ // Enhanced render method to include chat buttons
513
+ renderClozeTextWithChat() {
514
+ let html = this.clozeText;
515
+
516
+ this.blanks.forEach((blank, index) => {
517
+ const chatButtonId = `chat-btn-${index}`;
518
+ const inputHtml = `
519
+ <span class="inline-flex items-center gap-1">
520
+ <input type="text"
521
+ class="cloze-input"
522
+ data-blank-index="${index}"
523
+ placeholder="${'_'.repeat(Math.max(3, blank.originalWord.length))}"
524
+ style="width: ${Math.max(80, blank.originalWord.length * 12)}px;">
525
+ <button id="${chatButtonId}"
526
+ class="chat-button text-blue-500 hover:text-blue-700 text-sm"
527
+ data-blank-index="${index}"
528
+ title="Ask question about this word">
529
+ 💬
530
+ </button>
531
+ </span>`;
532
+
533
+ html = html.replace(`___BLANK_${index}___`, inputHtml);
534
+ });
535
+
536
+ return html;
537
+ }
538
+ }
539
+
540
+ export default ClozeGame;
src/conversationManager.js ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ wordPosition: wordData.wordPosition,
29
+ difficulty: wordData.difficulty,
30
+ previousAttempts: [],
31
+ userQuestions: [],
32
+ hintLevel: 0 // Progressive hint difficulty
33
+ };
34
+
35
+ this.wordContexts.set(blankId, context);
36
+ this.conversations.set(blankId, []);
37
+ return context;
38
+ }
39
+
40
+ // Per-blank question tracking with level awareness
41
+ async askQuestion(blankId, questionType, userInput = '') {
42
+ const context = this.wordContexts.get(blankId);
43
+
44
+ if (!context) {
45
+ return {
46
+ error: true,
47
+ message: "Context not found for this word."
48
+ };
49
+ }
50
+
51
+ // Mark question as used for this specific blank
52
+ if (!this.blankQuestions.has(blankId)) {
53
+ this.blankQuestions.set(blankId, new Set());
54
+ }
55
+ this.blankQuestions.get(blankId).add(questionType);
56
+
57
+ try {
58
+ const response = await this.generateSpecificResponse(context, questionType, userInput);
59
+ return {
60
+ success: true,
61
+ response: response,
62
+ questionType: questionType
63
+ };
64
+ } catch (error) {
65
+ console.error('Chat error:', error);
66
+ return this.getSimpleFallback(context, questionType);
67
+ }
68
+ }
69
+
70
+ // Generate specific response based on question type
71
+ async generateSpecificResponse(context, questionType, userInput) {
72
+ const word = context.targetWord;
73
+ const sentence = context.sentence;
74
+ const bookTitle = context.bookTitle;
75
+ const author = context.author;
76
+
77
+ try {
78
+ // Use the enhanced contextual hint generation
79
+ const aiResponse = await this.aiService.generateContextualHint(
80
+ questionType,
81
+ word,
82
+ sentence,
83
+ bookTitle,
84
+ author
85
+ );
86
+
87
+ if (aiResponse && typeof aiResponse === 'string' && aiResponse.length > 10) {
88
+ return aiResponse;
89
+ }
90
+ } catch (error) {
91
+ console.warn('AI response failed:', error);
92
+ }
93
+
94
+ // Fallback - return enhanced fallback response
95
+ return this.aiService.getEnhancedFallback(questionType, word, sentence, bookTitle);
96
+ }
97
+
98
+ // Build focused prompt for specific question types with level awareness
99
+ buildFocusedPrompt(context, questionType, userInput) {
100
+ const { sentence, bookTitle, author, targetWord } = context;
101
+ const level = this.currentLevel;
102
+
103
+ // Level 1 questions - Basic identification
104
+ if (level === 1) {
105
+ const prompts = {
106
+ 'basic_grammar': `What type of word (person/place/thing or action word) fits in the blank in: "${sentence}"? Keep it simple.`,
107
+ 'letter_hint': `The missing word is "${targetWord}". Tell me it starts with "${targetWord[0]}" without revealing the full word.`,
108
+ 'length_hint': `The missing word has ${targetWord.length} letters. Mention this fact helpfully.`,
109
+ 'category_hint': `Is the missing word in "${sentence}" a person, place, thing, or action? Give a simple answer.`,
110
+ 'basic_clue': `Give a very simple hint about the missing word in: "${sentence}". Make it easy to understand.`
111
+ };
112
+ return prompts[questionType] || prompts['basic_clue'];
113
+ }
114
+
115
+ // Level 2 questions - Contextual understanding
116
+ if (level === 2) {
117
+ const prompts = {
118
+ 'grammar_analysis': `In "${bookTitle}", what part of speech (noun, verb, adjective, etc.) is needed in: "${sentence}"? Explain simply.`,
119
+ 'contextual_meaning': `What does the missing word mean in this context from "${bookTitle}": "${sentence}"? Explain without revealing it.`,
120
+ 'synonym_clue': `Give a synonym or similar word to the one missing in: "${sentence}". Don't reveal the exact word.`,
121
+ 'narrative_connection': `How does the missing word connect to the story in "${bookTitle}"? Context: "${sentence}"`,
122
+ 'emotional_context': `What feeling or mood does the missing word express in: "${sentence}"?`
123
+ };
124
+ return prompts[questionType] || prompts['contextual_meaning'];
125
+ }
126
+
127
+ // Level 3+ questions - Literary analysis
128
+ const prompts = {
129
+ 'deep_grammar': `Analyze the grammatical function of the missing word in this passage from "${bookTitle}" by ${author}: "${sentence}"`,
130
+ 'literary_analysis': `What literary significance does the missing word have in "${bookTitle}"? Context: "${sentence}"`,
131
+ 'authorial_intent': `What would ${author} intend with the word choice here in "${bookTitle}": "${sentence}"?`,
132
+ 'comparative_analysis': `How does this word usage compare to similar passages in "${bookTitle}"? Context: "${sentence}"`,
133
+ 'style_analysis': `Explain the stylistic choice of the missing word in ${author}'s writing: "${sentence}"`
134
+ };
135
+
136
+ return prompts[questionType] || prompts['literary_analysis'];
137
+ }
138
+
139
+ // Simple fallback responses
140
+ getSimpleFallback(context, questionType) {
141
+ // Use the enhanced fallback from HuggingFace service
142
+ return this.aiService.getEnhancedFallback(
143
+ questionType,
144
+ context.targetWord,
145
+ context.sentence,
146
+ context.bookTitle
147
+ );
148
+ }
149
+
150
+ // Helper method to get words before the target word
151
+ getWordsBefore(sentence, targetWord, count = 3) {
152
+ const words = sentence.split(/\s+/);
153
+ const targetIndex = words.findIndex(word =>
154
+ word.toLowerCase().replace(/[^\w]/g, '') === targetWord.toLowerCase()
155
+ );
156
+
157
+ if (targetIndex === -1) return "";
158
+
159
+ const startIndex = Math.max(0, targetIndex - count);
160
+ return words.slice(startIndex, targetIndex).join(' ');
161
+ }
162
+
163
+ // Helper method to get words after the target word
164
+ getWordsAfter(sentence, targetWord, count = 3) {
165
+ const words = sentence.split(/\s+/);
166
+ const targetIndex = words.findIndex(word =>
167
+ word.toLowerCase().replace(/[^\w]/g, '') === targetWord.toLowerCase()
168
+ );
169
+
170
+ if (targetIndex === -1) return "";
171
+
172
+ const endIndex = Math.min(words.length, targetIndex + count + 1);
173
+ return words.slice(targetIndex + 1, endIndex).join(' ');
174
+ }
175
+
176
+ // Process AI response to ensure quality and safety
177
+ processAIResponse(rawResponse, targetWord) {
178
+ let processed = rawResponse.trim();
179
+
180
+ // Remove any accidental word reveals
181
+ const variations = this.generateWordVariations(targetWord);
182
+ variations.forEach(variation => {
183
+ const regex = new RegExp(`\\b${variation}\\b`, 'gi');
184
+ processed = processed.replace(regex, '[the word]');
185
+ });
186
+
187
+ return processed;
188
+ }
189
+
190
+ // Generate word variations to avoid accidental reveals
191
+ generateWordVariations(word) {
192
+ const variations = [word.toLowerCase()];
193
+
194
+ // Add common variations
195
+ if (word.endsWith('ing')) {
196
+ variations.push(word.slice(0, -3));
197
+ }
198
+ if (word.endsWith('ed')) {
199
+ variations.push(word.slice(0, -2));
200
+ }
201
+ if (word.endsWith('s')) {
202
+ variations.push(word.slice(0, -1));
203
+ }
204
+
205
+ return variations;
206
+ }
207
+
208
+ // Clear conversations and reset tracking
209
+ clearConversations() {
210
+ this.conversations.clear();
211
+ this.wordContexts.clear();
212
+ this.blankQuestions.clear();
213
+ }
214
+
215
+ // Set current level for question selection
216
+ setLevel(level) {
217
+ this.currentLevel = level;
218
+ }
219
+
220
+ // Get suggested questions for a specific blank
221
+ getSuggestedQuestions(blankId) {
222
+ const usedQuestions = this.blankQuestions.get(blankId) || new Set();
223
+
224
+ return this.questions.map(q => ({
225
+ ...q,
226
+ used: usedQuestions.has(q.type)
227
+ }));
228
+ }
229
+
230
+ // Reset for new game (clears everything including across-game state)
231
+ resetForNewGame() {
232
+ this.clearConversations();
233
+ this.currentLevel = 1;
234
+ }
235
+ }
236
+
237
+ export default ChatService;
src/styles.css ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: 2px 4px;
27
+ }
28
+
29
+ .chat-button:hover {
30
+ background-color: rgba(59, 130, 246, 0.1);
31
+ transform: scale(1.1);
32
+ }
33
+
34
+ .suggestion-btn {
35
+ transition: all 0.2s ease;
36
+ }
37
+
38
+ .suggestion-btn:hover {
39
+ transform: translateY(-1px);
40
+ }
41
+
42
+ /* Answer revelation styles */
43
+ .revealed-answer {
44
+ background-color: #fef3c7 !important;
45
+ font-weight: bold !important;
46
+ color: #92400e !important;
47
+ border: 2px solid #f59e0b !important;
48
+ }
49
+
50
+ /* Correct and incorrect answer styles */
51
+ .cloze-input.correct {
52
+ background-color: #dcfce7 !important;
53
+ border-color: #16a34a !important;
54
+ color: #15803d !important;
55
+ font-weight: bold !important;
56
+ }
57
+
58
+ .cloze-input.incorrect {
59
+ background-color: #fef2f2 !important;
60
+ border-color: #dc2626 !important;
61
+ color: #dc2626 !important;
62
+ font-weight: bold !important;
63
+ }
64
+
65
+ .cloze-input:disabled {
66
+ opacity: 0.8;
67
+ cursor: not-allowed;
68
+ }
69
+
70
+ /* Custom typewriter color scheme */
71
+ :root {
72
+ --aged-paper: #faf7f0;
73
+ --aged-paper-dark: #f5f1e8;
74
+ --aged-paper-light: #fefcf7;
75
+ --typewriter-ink: #2c2c2c;
76
+ --typewriter-ribbon: #8b5cf6;
77
+ --paper-shadow: rgba(0, 0, 0, 0.08);
78
+ }
79
+
80
+ @layer base {
81
+ body {
82
+ font-family: 'Special Elite', 'Courier New', monospace;
83
+ background-color: var(--aged-paper);
84
+ color: var(--typewriter-ink);
85
+ letter-spacing: 0.05em;
86
+ background-image:
87
+ radial-gradient(circle at 25% 25%, rgba(139, 92, 246, 0.02) 0%, transparent 50%),
88
+ radial-gradient(circle at 75% 75%, rgba(139, 92, 246, 0.02) 0%, transparent 50%);
89
+ }
90
+ }
91
+
92
+ @layer components {
93
+ .typewriter-text {
94
+ font-family: 'Special Elite', 'Courier New', monospace;
95
+ color: var(--typewriter-ink);
96
+ letter-spacing: 0.1em;
97
+ word-spacing: 0.1em;
98
+ line-height: 1.8;
99
+ }
100
+
101
+ .typewriter-subtitle {
102
+ font-family: 'Special Elite', 'Courier New', monospace;
103
+ color: #666;
104
+ letter-spacing: 0.05em;
105
+ font-size: 0.95rem;
106
+ }
107
+
108
+ .paper-sheet {
109
+ background-color: var(--aged-paper-light);
110
+ border: 1px solid rgba(139, 92, 246, 0.1);
111
+ box-shadow:
112
+ 0 2px 8px var(--paper-shadow),
113
+ inset 0 1px 0 rgba(255, 255, 255, 0.5);
114
+ position: relative;
115
+ }
116
+
117
+ .paper-sheet::before {
118
+ content: '';
119
+ position: absolute;
120
+ top: 0;
121
+ left: 0;
122
+ right: 0;
123
+ bottom: 0;
124
+ background: repeating-linear-gradient(
125
+ to bottom,
126
+ transparent,
127
+ transparent 23px,
128
+ rgba(139, 92, 246, 0.03) 23px,
129
+ rgba(139, 92, 246, 0.03) 24px
130
+ );
131
+ pointer-events: none;
132
+ z-index: 1;
133
+ }
134
+
135
+ .paper-content {
136
+ position: relative;
137
+ z-index: 2;
138
+ }
139
+
140
+ .cloze-input {
141
+ font-family: 'Special Elite', 'Courier New', monospace;
142
+ background-color: transparent;
143
+ border: none;
144
+ border-bottom: 2px solid black;
145
+ color: var(--typewriter-ink);
146
+ text-align: center;
147
+ outline: none;
148
+ padding: 2px 4px;
149
+ margin: 0 2px;
150
+ min-width: 3ch;
151
+ width: auto;
152
+ letter-spacing: 0.05em;
153
+ font-size: inherit;
154
+ line-height: inherit;
155
+ transition: all 0.2s ease;
156
+ }
157
+
158
+ .cloze-input:focus {
159
+ border-bottom-color: black;
160
+ box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2);
161
+ background-color: rgba(0, 0, 0, 0.05);
162
+ }
163
+
164
+ .cloze-input.correct {
165
+ border-bottom-color: #10b981;
166
+ background-color: rgba(16, 185, 129, 0.1);
167
+ }
168
+
169
+ .cloze-input.incorrect {
170
+ border-bottom-color: #ef4444;
171
+ background-color: rgba(239, 68, 68, 0.1);
172
+ }
173
+
174
+ .cloze-input::placeholder {
175
+ color: rgba(0, 0, 0, 0.4);
176
+ font-style: italic;
177
+ }
178
+
179
+ .typewriter-button {
180
+ font-family: 'Special Elite', 'Courier New', monospace;
181
+ min-width: 120px;
182
+ min-height: 42px;
183
+ padding: 8px 16px;
184
+ background-color: var(--aged-paper-dark);
185
+ color: var(--typewriter-ink);
186
+ border: 2px solid black;
187
+ border-radius: 4px;
188
+ font-weight: 600;
189
+ letter-spacing: 0.05em;
190
+ cursor: pointer;
191
+ transition: all 0.15s ease;
192
+ box-shadow:
193
+ 0 4px 0 rgba(0, 0, 0, 0.3),
194
+ 0 6px 12px rgba(0, 0, 0, 0.1);
195
+ position: relative;
196
+ overflow: hidden;
197
+ }
198
+
199
+ .typewriter-button:hover:not(:disabled) {
200
+ background-color: rgba(0, 0, 0, 0.05);
201
+ transform: translateY(-1px);
202
+ box-shadow:
203
+ 0 5px 0 rgba(0, 0, 0, 0.3),
204
+ 0 8px 16px rgba(0, 0, 0, 0.15);
205
+ }
206
+
207
+ .typewriter-button:active:not(:disabled) {
208
+ transform: translateY(2px);
209
+ box-shadow:
210
+ 0 2px 0 rgba(0, 0, 0, 0.3),
211
+ 0 3px 6px rgba(0, 0, 0, 0.1);
212
+ }
213
+
214
+ .typewriter-button:disabled {
215
+ opacity: 0.6;
216
+ cursor: not-allowed;
217
+ transform: translateY(2px);
218
+ box-shadow:
219
+ 0 2px 0 rgba(0, 0, 0, 0.2),
220
+ 0 3px 6px rgba(0, 0, 0, 0.05);
221
+ }
222
+
223
+ .typewriter-button:focus {
224
+ outline: none;
225
+ box-shadow:
226
+ 0 4px 0 rgba(0, 0, 0, 0.3),
227
+ 0 6px 12px rgba(0, 0, 0, 0.1),
228
+ 0 0 0 3px rgba(0, 0, 0, 0.2);
229
+ }
230
+
231
+ .prose {
232
+ font-family: 'Special Elite', 'Courier New', monospace;
233
+ font-size: 1.1rem;
234
+ line-height: 1.8;
235
+ letter-spacing: 0.05em;
236
+ word-spacing: 0.1em;
237
+ color: var(--typewriter-ink);
238
+ overflow-wrap: break-word;
239
+ }
240
+
241
+ .prose p {
242
+ margin-bottom: 1.5rem;
243
+ text-align: justify;
244
+ }
245
+
246
+ .biblio-info {
247
+ font-family: 'Special Elite', 'Courier New', monospace;
248
+ font-size: 0.85rem;
249
+ color: #666;
250
+ letter-spacing: 0.03em;
251
+ font-style: italic;
252
+ }
253
+
254
+ .context-box {
255
+ background-color: rgba(245, 158, 11, 0.08);
256
+ border-left: 4px solid #f59e0b;
257
+ font-family: 'Special Elite', 'Courier New', monospace;
258
+ font-size: 0.9rem;
259
+ letter-spacing: 0.03em;
260
+ line-height: 1.6;
261
+ }
262
+
263
+ .hints-box {
264
+ background-color: rgba(245, 158, 11, 0.08);
265
+ border-left: 4px solid #f59e0b;
266
+ font-family: 'Special Elite', 'Courier New', monospace;
267
+ font-size: 0.9rem;
268
+ letter-spacing: 0.03em;
269
+ line-height: 1.6;
270
+ }
271
+
272
+ .round-badge {
273
+ background-color: rgba(245, 158, 11, 0.1);
274
+ color: #666;
275
+ border: 1px solid rgba(245, 158, 11, 0.2);
276
+ font-family: 'Special Elite', 'Courier New', monospace;
277
+ font-weight: 600;
278
+ letter-spacing: 0.05em;
279
+ }
280
+
281
+ .loading-text {
282
+ font-family: 'Special Elite', 'Courier New', monospace;
283
+ color: #666;
284
+ letter-spacing: 0.05em;
285
+ }
286
+
287
+ .result-text {
288
+ font-family: 'Special Elite', 'Courier New', monospace;
289
+ font-weight: 600;
290
+ letter-spacing: 0.05em;
291
+ }
292
+
293
+ /* Responsive adjustments */
294
+ @media (max-width: 640px) {
295
+ .prose {
296
+ font-size: 1rem;
297
+ }
298
+
299
+ .typewriter-text {
300
+ font-size: 1.75rem;
301
+ }
302
+
303
+ .typewriter-subtitle {
304
+ font-size: 0.85rem;
305
+ }
306
+
307
+ .typewriter-button {
308
+ min-width: 100px;
309
+ min-height: 38px;
310
+ font-size: 0.9rem;
311
+ }
312
+ }
313
+
314
+ @media (max-width: 480px) {
315
+ .prose {
316
+ font-size: 0.95rem;
317
+ }
318
+
319
+ .typewriter-text {
320
+ font-size: 1.5rem;
321
+ }
322
+
323
+ .cloze-input {
324
+ min-width: 2.5ch;
325
+ }
326
+ }
327
+
328
+ /* Print styles */
329
+ @media print {
330
+ body {
331
+ background: white;
332
+ color: black;
333
+ }
334
+
335
+ .paper-sheet::before {
336
+ display: none;
337
+ }
338
+
339
+ .typewriter-button {
340
+ display: none;
341
+ }
342
+ }
343
+ }
344
+
345
+ /* Custom utility classes */
346
+ @layer utilities {
347
+ .ink-ribbon-lines {
348
+ background: repeating-linear-gradient(
349
+ to bottom,
350
+ transparent,
351
+ transparent 23px,
352
+ rgba(139, 92, 246, 0.03) 23px,
353
+ rgba(139, 92, 246, 0.03) 24px
354
+ );
355
+ }
356
+ }