milwright commited on
Commit
e4e854a
Β·
1 Parent(s): af86b7d

fix leaderboard initials entry readability with improved contrast and light background

Browse files
Files changed (8) hide show
  1. .gitattributes +40 -0
  2. HF_LEADERBOARD_SETUP.md +0 -222
  3. app.py +28 -5
  4. hf_leaderboard.py +30 -44
  5. src/aiService.js +86 -13
  6. src/app.js +13 -19
  7. src/leaderboardUI.js +2 -3
  8. src/styles.css +22 -17
.gitattributes ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces configuration
2
+ # Prevent leaderboard.json updates from triggering Space rebuilds
3
+ leaderboard.json linguist-generated=true
4
+
5
+ # Standard HF Spaces attributes
6
+ *.7z filter=lfs diff=lfs merge=lfs -text
7
+ *.arrow filter=lfs diff=lfs merge=lfs -text
8
+ *.bin filter=lfs diff=lfs merge=lfs -text
9
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
10
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
11
+ *.ftz filter=lfs diff=lfs merge=lfs -text
12
+ *.gz filter=lfs diff=lfs merge=lfs -text
13
+ *.h5 filter=lfs diff=lfs merge=lfs -text
14
+ *.joblib filter=lfs diff=lfs merge=lfs -text
15
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
16
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
17
+ *.model filter=lfs diff=lfs merge=lfs -text
18
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
19
+ *.npy filter=lfs diff=lfs merge=lfs -text
20
+ *.npz filter=lfs diff=lfs merge=lfs -text
21
+ *.onnx filter=lfs diff=lfs merge=lfs -text
22
+ *.ot filter=lfs diff=lfs merge=lfs -text
23
+ *.parquet filter=lfs diff=lfs merge=lfs -text
24
+ *.pb filter=lfs diff=lfs merge=lfs -text
25
+ *.pickle filter=lfs diff=lfs merge=lfs -text
26
+ *.pkl filter=lfs diff=lfs merge=lfs -text
27
+ *.pt filter=lfs diff=lfs merge=lfs -text
28
+ *.pth filter=lfs diff=lfs merge=lfs -text
29
+ *.rar filter=lfs diff=lfs merge=lfs -text
30
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
31
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
32
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
33
+ *.tar filter=lfs diff=lfs merge=lfs -text
34
+ *.tflite filter=lfs diff=lfs merge=lfs -text
35
+ *.tgz filter=lfs diff=lfs merge=lfs -text
36
+ *.wasm filter=lfs diff=lfs merge=lfs -text
37
+ *.xz filter=lfs diff=lfs merge=lfs -text
38
+ *.zip filter=lfs diff=lfs merge=lfs -text
39
+ *.zst filter=lfs diff=lfs merge=lfs -text
40
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
HF_LEADERBOARD_SETUP.md DELETED
@@ -1,222 +0,0 @@
1
- # Hugging Face Leaderboard Setup
2
-
3
- This document explains how the HF Hub leaderboard integration works and how to set it up.
4
-
5
- ## Overview
6
-
7
- The leaderboard system now supports two modes:
8
- 1. **HF Hub Mode** (persistent, shared across all users)
9
- 2. **localStorage Mode** (fallback, local to browser)
10
-
11
- When HF Hub is available, all leaderboard entries are:
12
- - Saved to a HF Hub dataset repository
13
- - Automatically synced across all users
14
- - Version controlled with commit history
15
- - Persistent across sessions and devices
16
-
17
- ## Setup Instructions
18
-
19
- ### 1. Get Hugging Face Token
20
-
21
- 1. Go to https://huggingface.co/settings/tokens
22
- 2. Create a new token with **write** access
23
- 3. Copy the token value
24
-
25
- ### 2. Configure Environment Variables
26
-
27
- Create a `.env` file in the project root (copy from `.env.example`):
28
-
29
- ```bash
30
- # Hugging Face API Token
31
- HF_TOKEN=hf_your_token_here
32
-
33
- # Optional: Custom repository name (defaults to zmuhls/cloze-reader-leaderboard)
34
- HF_LEADERBOARD_REPO=your_username/your-repo-name
35
- ```
36
-
37
- ### 3. Install Dependencies
38
-
39
- ```bash
40
- pip install -r requirements.txt
41
- ```
42
-
43
- This will install `huggingface-hub` along with other dependencies.
44
-
45
- ### 4. Run the Server
46
-
47
- ```bash
48
- # Production mode (with HF backend)
49
- make dev-python
50
- # OR
51
- python app.py
52
-
53
- # The server will:
54
- # 1. Check for HF_TOKEN in environment
55
- # 2. Create the HF dataset repository if it doesn't exist
56
- # 3. Initialize with empty leaderboard
57
- # 4. Handle all read/write operations via API endpoints
58
- ```
59
-
60
- ### 5. Test the Integration
61
-
62
- Visit `http://localhost:7860` and:
63
- 1. Play the game to reach Level 2
64
- 2. Enter your initials
65
- 3. Check browser console for HF sync messages:
66
- - `πŸ”§ LEADERBOARD: Using HF Hub backend`
67
- - `πŸ“€ LEADERBOARD: Saved to HF Hub`
68
- - `πŸ“₯ LEADERBOARD: Synced from HF Hub`
69
-
70
- ## Architecture
71
-
72
- ### Backend (Python)
73
-
74
- **`hf_leaderboard.py`** - HF Hub integration service
75
- - Creates/manages HF dataset repository
76
- - Handles leaderboard read/write/update operations
77
- - Auto-commits changes to HF Hub
78
- - Uses `huggingface_hub` Python library
79
-
80
- **`app.py`** - FastAPI endpoints
81
- - `GET /api/leaderboard` - Fetch current leaderboard
82
- - `POST /api/leaderboard/add` - Add new entry
83
- - `POST /api/leaderboard/update` - Replace entire leaderboard
84
- - `DELETE /api/leaderboard/clear` - Clear all entries (admin)
85
-
86
- ### Frontend (JavaScript)
87
-
88
- **`src/hfLeaderboardAPI.js`** - API client
89
- - Communicates with FastAPI backend
90
- - Handles network errors gracefully
91
- - Provides async methods for all operations
92
-
93
- **`src/leaderboardService.js`** - Service layer (updated)
94
- - Checks HF availability on init
95
- - Syncs from HF Hub on page load
96
- - Saves to both HF and localStorage
97
- - Falls back to localStorage if HF unavailable
98
-
99
- ## Data Format
100
-
101
- Leaderboard entries are stored as JSON on HF Hub:
102
-
103
- ```json
104
- {
105
- "leaderboard": [
106
- {
107
- "initials": "ABC",
108
- "level": 5,
109
- "round": 2,
110
- "passagesPassed": 18,
111
- "date": "2025-01-31T12:00:00.000Z"
112
- }
113
- ],
114
- "last_updated": "2025-01-31T12:00:00.000Z",
115
- "version": "1.0"
116
- }
117
- ```
118
-
119
- ## How It Works
120
-
121
- ### On Page Load
122
- 1. Frontend checks if backend is available (`/api/leaderboard`)
123
- 2. If available, sets `useHF = true`
124
- 3. Syncs leaderboard from HF Hub to localStorage
125
- 4. User sees most recent global leaderboard
126
-
127
- ### When Player Achieves High Score
128
- 1. Entry saved to localStorage immediately (fast UI update)
129
- 2. Entry sent to backend API (`POST /api/leaderboard/add`)
130
- 3. Backend adds entry to HF Hub with auto-commit
131
- 4. All other users will see updated leaderboard on next load
132
-
133
- ### Fallback Behavior
134
- If HF backend is unavailable (no token, network error, etc.):
135
- - System automatically uses localStorage only
136
- - Leaderboard still works, just not shared
137
- - Console shows: `⚠️ LEADERBOARD: HF backend unavailable, using localStorage`
138
-
139
- ## Viewing Your HF Dataset
140
-
141
- After first save, visit:
142
- ```
143
- https://huggingface.co/datasets/YOUR_USERNAME/cloze-reader-leaderboard
144
- ```
145
-
146
- You'll see:
147
- - `leaderboard.json` file
148
- - Commit history of all updates
149
- - Public leaderboard accessible to all
150
-
151
- ## Production Deployment
152
-
153
- ### Hugging Face Spaces
154
- If deploying on HF Spaces, the token is automatically available:
155
- ```python
156
- # HF Spaces provides HF_TOKEN automatically
157
- # No .env file needed!
158
- ```
159
-
160
- ### Other Platforms (Vercel, Railway, etc.)
161
- Add `HF_TOKEN` to your platform's environment variables:
162
- - Vercel: Project Settings β†’ Environment Variables
163
- - Railway: Project β†’ Variables
164
- - Render: Environment β†’ Environment Variables
165
-
166
- ## Security Notes
167
-
168
- - βœ… HF Token stored securely in backend environment
169
- - βœ… Token never exposed to frontend/browser
170
- - βœ… All HF operations go through backend API
171
- - βœ… CORS configured for security
172
- - ⚠️ Leaderboard dataset should be **public** (read access for all users)
173
- - ⚠️ Write access controlled via backend (users can't directly modify HF dataset)
174
-
175
- ## Troubleshooting
176
-
177
- ### "No HF token available for writing"
178
- - Check `.env` file has `HF_TOKEN` or `HF_API_KEY`
179
- - Restart server after adding token
180
- - Verify token has write permissions
181
-
182
- ### "Failed to create HF repo"
183
- - Check token permissions (need write access)
184
- - Verify repository name format (username/repo-name)
185
- - Check if repo already exists
186
-
187
- ### Leaderboard not syncing
188
- - Check browser console for error messages
189
- - Verify backend is running (`http://localhost:7860/api/leaderboard`)
190
- - Check network tab for failed API calls
191
- - Ensure HF Hub is accessible (not blocked by firewall)
192
-
193
- ### Backend logs show errors
194
- ```bash
195
- # Check logs for detailed error messages
196
- python app.py
197
-
198
- # Look for:
199
- # βœ… "Using existing HF dataset: ..."
200
- # βœ… "Leaderboard saved to HF Hub: ..."
201
- # ❌ "Failed to upload to HF Hub: ..."
202
- ```
203
-
204
- ## Development vs Production
205
-
206
- **Local Development** (`make dev` - static server):
207
- - Uses localStorage only
208
- - No backend, no HF sync
209
- - Fast iteration, no setup needed
210
-
211
- **Production** (`make dev-python` - FastAPI server):
212
- - Uses HF Hub backend
213
- - Requires token and backend running
214
- - Shared global leaderboard
215
-
216
- ## API Documentation
217
-
218
- Once server is running, visit:
219
- - FastAPI docs: `http://localhost:7860/docs`
220
- - Alternative docs: `http://localhost:7860/redoc`
221
-
222
- Interactive API testing available through Swagger UI!
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -30,11 +30,18 @@ app.add_middleware(
30
  )
31
 
32
  # Initialize HF Leaderboard Service
33
- hf_token = os.getenv("HF_TOKEN") or os.getenv("HF_API_KEY")
34
- hf_leaderboard = HFLeaderboardService(
35
- repo_id=os.getenv("HF_LEADERBOARD_REPO", "zmuhls/cloze-reader-leaderboard"),
36
- token=hf_token
37
- )
 
 
 
 
 
 
 
38
 
39
  # Pydantic models for API
40
  class LeaderboardEntry(BaseModel):
@@ -87,6 +94,13 @@ async def get_leaderboard():
87
  """
88
  Get current leaderboard data from HF Hub
89
  """
 
 
 
 
 
 
 
90
  try:
91
  leaderboard = hf_leaderboard.get_leaderboard()
92
  return {
@@ -104,6 +118,9 @@ async def add_leaderboard_entry(entry: LeaderboardEntry):
104
  """
105
  Add new entry to leaderboard
106
  """
 
 
 
107
  try:
108
  success = hf_leaderboard.add_entry(entry.dict())
109
  if success:
@@ -125,6 +142,9 @@ async def update_leaderboard(entries: List[LeaderboardEntry]):
125
  """
126
  Update entire leaderboard (replace all data)
127
  """
 
 
 
128
  try:
129
  success = hf_leaderboard.update_leaderboard([e.dict() for e in entries])
130
  if success:
@@ -146,6 +166,9 @@ async def clear_leaderboard():
146
  """
147
  Clear all leaderboard data (admin only)
148
  """
 
 
 
149
  try:
150
  success = hf_leaderboard.clear_leaderboard()
151
  if success:
 
30
  )
31
 
32
  # Initialize HF Leaderboard Service
33
+ # repo_id will auto-detect from SPACE_ID env var (set automatically in HF Spaces)
34
+ # For local testing, you can set SPACE_ID manually or pass repo_id explicitly
35
+ hf_token = os.getenv("HF_TOKEN")
36
+ try:
37
+ hf_leaderboard = HFLeaderboardService(
38
+ repo_id=os.getenv("SPACE_ID"), # Auto-detect Space ID
39
+ token=hf_token
40
+ )
41
+ except ValueError as e:
42
+ logger.warning(f"Could not initialize HF Leaderboard Service: {e}")
43
+ logger.warning("Leaderboard will use localStorage fallback only")
44
+ hf_leaderboard = None
45
 
46
  # Pydantic models for API
47
  class LeaderboardEntry(BaseModel):
 
94
  """
95
  Get current leaderboard data from HF Hub
96
  """
97
+ if not hf_leaderboard:
98
+ return {
99
+ "success": True,
100
+ "leaderboard": [],
101
+ "message": "HF leaderboard not available (using localStorage only)"
102
+ }
103
+
104
  try:
105
  leaderboard = hf_leaderboard.get_leaderboard()
106
  return {
 
118
  """
119
  Add new entry to leaderboard
120
  """
121
+ if not hf_leaderboard:
122
+ raise HTTPException(status_code=503, detail="HF leaderboard not available")
123
+
124
  try:
125
  success = hf_leaderboard.add_entry(entry.dict())
126
  if success:
 
142
  """
143
  Update entire leaderboard (replace all data)
144
  """
145
+ if not hf_leaderboard:
146
+ raise HTTPException(status_code=503, detail="HF leaderboard not available")
147
+
148
  try:
149
  success = hf_leaderboard.update_leaderboard([e.dict() for e in entries])
150
  if success:
 
166
  """
167
  Clear all leaderboard data (admin only)
168
  """
169
+ if not hf_leaderboard:
170
+ raise HTTPException(status_code=503, detail="HF leaderboard not available")
171
+
172
  try:
173
  success = hf_leaderboard.clear_leaderboard()
174
  if success:
hf_leaderboard.py CHANGED
@@ -7,7 +7,7 @@ import json
7
  import os
8
  from datetime import datetime
9
  from typing import List, Dict, Optional
10
- from huggingface_hub import HfApi, hf_hub_download, HfFolder
11
  import logging
12
 
13
  logger = logging.getLogger(__name__)
@@ -16,57 +16,36 @@ logger = logging.getLogger(__name__)
16
  class HFLeaderboardService:
17
  """
18
  Service for managing leaderboard data on Hugging Face Hub
19
- Stores leaderboard as a JSON file in a HF dataset
20
  """
21
 
22
- def __init__(self, repo_id: str = "zmuhls/cloze-reader-leaderboard", token: Optional[str] = None):
23
  """
24
  Initialize HF Leaderboard Service
25
 
26
  Args:
27
  repo_id: HF Hub repository ID (format: username/repo-name)
 
28
  token: HF API token (if not provided, uses HF_TOKEN env var)
29
  """
30
- self.repo_id = repo_id
31
- self.token = token or os.getenv("HF_TOKEN") or os.getenv("HF_API_KEY")
 
 
 
 
32
  self.api = HfApi()
33
  self.leaderboard_file = "leaderboard.json"
 
34
 
35
  if not self.token:
36
  logger.warning("No HF token provided. Read-only mode (if repo is public)")
37
 
38
- # Ensure repo exists
39
- self._ensure_repo_exists()
40
-
41
- def _ensure_repo_exists(self):
42
- """Create the HF dataset repository if it doesn't exist"""
43
- if not self.token:
44
- return
45
-
46
- try:
47
- # Try to get repo info
48
- self.api.repo_info(repo_id=self.repo_id, repo_type="dataset", token=self.token)
49
- logger.info(f"Using existing HF dataset: {self.repo_id}")
50
- except Exception as e:
51
- # Repo doesn't exist, create it
52
- try:
53
- self.api.create_repo(
54
- repo_id=self.repo_id,
55
- repo_type="dataset",
56
- token=self.token,
57
- private=False,
58
- exist_ok=True
59
- )
60
- logger.info(f"Created HF dataset: {self.repo_id}")
61
-
62
- # Initialize with empty leaderboard
63
- self._save_to_hub([])
64
- except Exception as create_error:
65
- logger.error(f"Failed to create HF repo: {create_error}")
66
 
67
  def _save_to_hub(self, data: List[Dict]):
68
  """
69
- Save leaderboard data to HF Hub
70
 
71
  Args:
72
  data: List of leaderboard entries
@@ -74,7 +53,7 @@ class HFLeaderboardService:
74
  if not self.token:
75
  raise ValueError("No HF token available for writing")
76
 
77
- # Create temporary file
78
  temp_file = f"/tmp/{self.leaderboard_file}"
79
  with open(temp_file, "w") as f:
80
  json.dump({
@@ -83,19 +62,26 @@ class HFLeaderboardService:
83
  "version": "1.0"
84
  }, f, indent=2)
85
 
86
- # Upload to HF Hub
87
  try:
88
- self.api.upload_file(
89
- path_or_fileobj=temp_file,
90
- path_in_repo=self.leaderboard_file,
 
 
 
 
 
91
  repo_id=self.repo_id,
92
- repo_type="dataset",
93
- token=self.token,
94
- commit_message=f"Update leaderboard - {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC"
 
95
  )
 
96
  logger.info(f"Leaderboard saved to HF Hub: {self.repo_id}")
97
  except Exception as e:
98
- logger.error(f"Failed to upload to HF Hub: {e}")
99
  raise
100
  finally:
101
  # Clean up temp file
@@ -114,7 +100,7 @@ class HFLeaderboardService:
114
  file_path = hf_hub_download(
115
  repo_id=self.repo_id,
116
  filename=self.leaderboard_file,
117
- repo_type="dataset",
118
  token=self.token
119
  )
120
 
 
7
  import os
8
  from datetime import datetime
9
  from typing import List, Dict, Optional
10
+ from huggingface_hub import HfApi, hf_hub_download, CommitOperationAdd
11
  import logging
12
 
13
  logger = logging.getLogger(__name__)
 
16
  class HFLeaderboardService:
17
  """
18
  Service for managing leaderboard data on Hugging Face Hub
19
+ Stores leaderboard as a JSON file in the current HF Space repository
20
  """
21
 
22
+ def __init__(self, repo_id: Optional[str] = None, token: Optional[str] = None):
23
  """
24
  Initialize HF Leaderboard Service
25
 
26
  Args:
27
  repo_id: HF Hub repository ID (format: username/repo-name)
28
+ If not provided, uses SPACE_ID env var (auto-set in HF Spaces)
29
  token: HF API token (if not provided, uses HF_TOKEN env var)
30
  """
31
+ # Use SPACE_ID if available (automatically set in HF Spaces)
32
+ self.repo_id = repo_id or os.getenv("SPACE_ID")
33
+ if not self.repo_id:
34
+ raise ValueError("No repo_id provided and SPACE_ID env var not set. Cannot determine target repository.")
35
+
36
+ self.token = token or os.getenv("HF_TOKEN")
37
  self.api = HfApi()
38
  self.leaderboard_file = "leaderboard.json"
39
+ self.repo_type = "space" # Store in Space repo, not separate dataset
40
 
41
  if not self.token:
42
  logger.warning("No HF token provided. Read-only mode (if repo is public)")
43
 
44
+ logger.info(f"HF Leaderboard Service initialized for Space: {self.repo_id}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
 
46
  def _save_to_hub(self, data: List[Dict]):
47
  """
48
+ Save leaderboard data to HF Hub using best practice commit pattern
49
 
50
  Args:
51
  data: List of leaderboard entries
 
53
  if not self.token:
54
  raise ValueError("No HF token available for writing")
55
 
56
+ # Create temporary file with leaderboard data
57
  temp_file = f"/tmp/{self.leaderboard_file}"
58
  with open(temp_file, "w") as f:
59
  json.dump({
 
62
  "version": "1.0"
63
  }, f, indent=2)
64
 
65
+ # Commit to HF Hub using best practice pattern
66
  try:
67
+ operations = [
68
+ CommitOperationAdd(
69
+ path_or_fileobj=temp_file,
70
+ path_in_repo=self.leaderboard_file
71
+ )
72
+ ]
73
+
74
+ self.api.create_commit(
75
  repo_id=self.repo_id,
76
+ operations=operations,
77
+ commit_message=f"update leaderboard - {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} utc",
78
+ repo_type=self.repo_type,
79
+ token=self.token
80
  )
81
+
82
  logger.info(f"Leaderboard saved to HF Hub: {self.repo_id}")
83
  except Exception as e:
84
+ logger.error(f"Failed to commit to HF Hub: {e}")
85
  raise
86
  finally:
87
  # Clean up temp file
 
100
  file_path = hf_hub_download(
101
  repo_id=self.repo_id,
102
  filename=self.leaderboard_file,
103
+ repo_type=self.repo_type,
104
  token=self.token
105
  )
106
 
src/aiService.js CHANGED
@@ -352,22 +352,46 @@ Passage: "${passage}"`
352
  }
353
 
354
  if (Array.isArray(words)) {
355
- // Validate word lengths based on level
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  const validWords = words.filter(word => {
357
  // First check if the word contains at least one letter
358
  if (!/[a-zA-Z]/.test(word)) {
359
  console.log(`❌ Rejecting non-alphabetic word: "${word}"`);
360
  return false;
361
  }
362
-
363
  const cleanWord = word.replace(/[^a-zA-Z]/g, '');
364
-
365
  // If cleanWord is empty after removing non-letters, reject
366
  if (cleanWord.length === 0) {
367
  console.log(`❌ Rejecting word with no letters: "${word}"`);
368
  return false;
369
  }
370
-
 
 
 
 
 
 
371
  // Check length constraints
372
  if (level <= 2) {
373
  return cleanWord.length >= 4 && cleanWord.length <= 7;
@@ -391,22 +415,47 @@ Passage: "${passage}"`
391
  const matches = content.match(/"([^"]+)"/g);
392
  if (matches) {
393
  const words = matches.map(m => m.replace(/"/g, ''));
394
- // Validate word lengths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  const validWords = words.filter(word => {
396
  // First check if the word contains at least one letter
397
  if (!/[a-zA-Z]/.test(word)) {
398
  console.log(`❌ Rejecting non-alphabetic word: "${word}"`);
399
  return false;
400
  }
401
-
402
  const cleanWord = word.replace(/[^a-zA-Z]/g, '');
403
-
404
  // If cleanWord is empty after removing non-letters, reject
405
  if (cleanWord.length === 0) {
406
  console.log(`❌ Rejecting word with no letters: "${word}"`);
407
  return false;
408
  }
409
-
 
 
 
 
 
 
410
  // Check length constraints
411
  if (level <= 2) {
412
  return cleanWord.length >= 4 && cleanWord.length <= 7;
@@ -602,29 +651,53 @@ Return JSON: {"passage1": {"words": [${blanksPerPassage} words], "context": "one
602
  parsed.passage1.words = parsed.passage1.words.filter(word => word && word.trim() !== '');
603
  parsed.passage2.words = parsed.passage2.words.filter(word => word && word.trim() !== '');
604
 
605
- // Validate word lengths based on level
606
  const validateWords = (words, passageText) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
607
  return words.filter(word => {
608
  // First check if the word contains at least one letter
609
  if (!/[a-zA-Z]/.test(word)) {
610
  console.log(`❌ Rejecting non-alphabetic word: "${word}"`);
611
  return false;
612
  }
613
-
614
  const cleanWord = word.replace(/[^a-zA-Z]/g, '');
615
-
616
  // If cleanWord is empty after removing non-letters, reject
617
  if (cleanWord.length === 0) {
618
  console.log(`❌ Rejecting word with no letters: "${word}"`);
619
  return false;
620
  }
621
-
 
 
 
 
 
 
622
  // Check if word appears in all caps in the passage (like "VOLUME")
623
  if (passageText.includes(word.toUpperCase()) && word === word.toUpperCase()) {
624
  console.log(`Skipping all-caps word: ${word}`);
625
  return false;
626
  }
627
-
628
  // Check length constraints
629
  if (level <= 2) {
630
  return cleanWord.length >= 4 && cleanWord.length <= 7;
 
352
  }
353
 
354
  if (Array.isArray(words)) {
355
+ // Create passage word array with position and capitalization info (matches clozeGameEngine logic)
356
+ const passageWords = passage.split(/\s+/);
357
+ const passageWordMap = new Map();
358
+
359
+ passageWords.forEach((word, idx) => {
360
+ const cleanOriginal = word.replace(/[^\w]/g, '');
361
+ const cleanLower = cleanOriginal.toLowerCase();
362
+ const isCapitalized = cleanOriginal.length > 0 && cleanOriginal[0] === cleanOriginal[0].toUpperCase();
363
+
364
+ // Only track non-capitalized words after position 10 (matches game engine constraints)
365
+ if (!isCapitalized && idx >= 10) {
366
+ if (!passageWordMap.has(cleanLower)) {
367
+ passageWordMap.set(cleanLower, []);
368
+ }
369
+ passageWordMap.get(cleanLower).push(idx);
370
+ }
371
+ });
372
+
373
+ // Validate word lengths based on level and passage presence
374
  const validWords = words.filter(word => {
375
  // First check if the word contains at least one letter
376
  if (!/[a-zA-Z]/.test(word)) {
377
  console.log(`❌ Rejecting non-alphabetic word: "${word}"`);
378
  return false;
379
  }
380
+
381
  const cleanWord = word.replace(/[^a-zA-Z]/g, '');
382
+
383
  // If cleanWord is empty after removing non-letters, reject
384
  if (cleanWord.length === 0) {
385
  console.log(`❌ Rejecting word with no letters: "${word}"`);
386
  return false;
387
  }
388
+
389
+ // Check if word exists as non-capitalized word after position 10 (matches game engine)
390
+ if (!passageWordMap.has(cleanWord.toLowerCase())) {
391
+ console.log(`❌ Rejecting word not matchable in passage: "${word}" (capitalized or in first 10 words)`);
392
+ return false;
393
+ }
394
+
395
  // Check length constraints
396
  if (level <= 2) {
397
  return cleanWord.length >= 4 && cleanWord.length <= 7;
 
415
  const matches = content.match(/"([^"]+)"/g);
416
  if (matches) {
417
  const words = matches.map(m => m.replace(/"/g, ''));
418
+
419
+ // Create passage word array with position and capitalization info (matches clozeGameEngine logic)
420
+ const passageWords = passage.split(/\s+/);
421
+ const passageWordMap = new Map();
422
+
423
+ passageWords.forEach((word, idx) => {
424
+ const cleanOriginal = word.replace(/[^\w]/g, '');
425
+ const cleanLower = cleanOriginal.toLowerCase();
426
+ const isCapitalized = cleanOriginal.length > 0 && cleanOriginal[0] === cleanOriginal[0].toUpperCase();
427
+
428
+ // Only track non-capitalized words after position 10 (matches game engine constraints)
429
+ if (!isCapitalized && idx >= 10) {
430
+ if (!passageWordMap.has(cleanLower)) {
431
+ passageWordMap.set(cleanLower, []);
432
+ }
433
+ passageWordMap.get(cleanLower).push(idx);
434
+ }
435
+ });
436
+
437
+ // Validate word lengths and passage presence
438
  const validWords = words.filter(word => {
439
  // First check if the word contains at least one letter
440
  if (!/[a-zA-Z]/.test(word)) {
441
  console.log(`❌ Rejecting non-alphabetic word: "${word}"`);
442
  return false;
443
  }
444
+
445
  const cleanWord = word.replace(/[^a-zA-Z]/g, '');
446
+
447
  // If cleanWord is empty after removing non-letters, reject
448
  if (cleanWord.length === 0) {
449
  console.log(`❌ Rejecting word with no letters: "${word}"`);
450
  return false;
451
  }
452
+
453
+ // Check if word exists as non-capitalized word after position 10 (matches game engine)
454
+ if (!passageWordMap.has(cleanWord.toLowerCase())) {
455
+ console.log(`❌ Rejecting word not matchable in passage: "${word}" (capitalized or in first 10 words)`);
456
+ return false;
457
+ }
458
+
459
  // Check length constraints
460
  if (level <= 2) {
461
  return cleanWord.length >= 4 && cleanWord.length <= 7;
 
651
  parsed.passage1.words = parsed.passage1.words.filter(word => word && word.trim() !== '');
652
  parsed.passage2.words = parsed.passage2.words.filter(word => word && word.trim() !== '');
653
 
654
+ // Validate word lengths based on level and passage presence
655
  const validateWords = (words, passageText) => {
656
+ // Create passage word array with position and capitalization info (matches clozeGameEngine logic)
657
+ const passageWords = passageText.split(/\s+/);
658
+ const passageWordMap = new Map();
659
+
660
+ passageWords.forEach((word, idx) => {
661
+ const cleanOriginal = word.replace(/[^\w]/g, '');
662
+ const cleanLower = cleanOriginal.toLowerCase();
663
+ const isCapitalized = cleanOriginal.length > 0 && cleanOriginal[0] === cleanOriginal[0].toUpperCase();
664
+
665
+ // Only track non-capitalized words after position 10 (matches game engine constraints)
666
+ if (!isCapitalized && idx >= 10) {
667
+ if (!passageWordMap.has(cleanLower)) {
668
+ passageWordMap.set(cleanLower, []);
669
+ }
670
+ passageWordMap.get(cleanLower).push(idx);
671
+ }
672
+ });
673
+
674
  return words.filter(word => {
675
  // First check if the word contains at least one letter
676
  if (!/[a-zA-Z]/.test(word)) {
677
  console.log(`❌ Rejecting non-alphabetic word: "${word}"`);
678
  return false;
679
  }
680
+
681
  const cleanWord = word.replace(/[^a-zA-Z]/g, '');
682
+
683
  // If cleanWord is empty after removing non-letters, reject
684
  if (cleanWord.length === 0) {
685
  console.log(`❌ Rejecting word with no letters: "${word}"`);
686
  return false;
687
  }
688
+
689
+ // Check if word exists as non-capitalized word after position 10 (matches game engine)
690
+ if (!passageWordMap.has(cleanWord.toLowerCase())) {
691
+ console.log(`❌ Rejecting word not matchable in passage: "${word}" (capitalized or in first 10 words)`);
692
+ return false;
693
+ }
694
+
695
  // Check if word appears in all caps in the passage (like "VOLUME")
696
  if (passageText.includes(word.toUpperCase()) && word === word.toUpperCase()) {
697
  console.log(`Skipping all-caps word: ${word}`);
698
  return false;
699
  }
700
+
701
  // Check length constraints
702
  if (level <= 2) {
703
  return cleanWord.length >= 4 && cleanWord.length <= 7;
src/app.js CHANGED
@@ -339,25 +339,19 @@ class App {
339
  if (this.game.checkForHighScore()) {
340
  const rank = this.game.getHighScoreRank();
341
  const stats = this.game.leaderboardService.getStats();
342
- const profile = this.game.leaderboardService.getPlayerProfile();
343
-
344
- // If player hasn't entered initials yet, show initials entry
345
- if (!profile.hasEnteredInitials) {
346
- this.leaderboardUI.showInitialsEntry(
347
- stats.highestLevel,
348
- stats.roundAtHighestLevel,
349
- rank,
350
- (initials) => {
351
- // Save to leaderboard
352
- const finalRank = this.game.addToLeaderboard(initials);
353
- console.log(`Added to leaderboard at rank ${finalRank}`);
354
- }
355
- );
356
- } else {
357
- // Update existing entry
358
- const finalRank = this.game.addToLeaderboard(profile.initials);
359
- console.log(`Updated leaderboard entry at rank ${finalRank}`);
360
- }
361
  }
362
  }
363
  }
 
339
  if (this.game.checkForHighScore()) {
340
  const rank = this.game.getHighScoreRank();
341
  const stats = this.game.leaderboardService.getStats();
342
+
343
+ // Always show initials entry when achieving a high score
344
+ // Pre-fills with previous initials if available, allowing changes
345
+ this.leaderboardUI.showInitialsEntry(
346
+ stats.highestLevel,
347
+ stats.roundAtHighestLevel,
348
+ rank,
349
+ (initials) => {
350
+ // Save to leaderboard
351
+ const finalRank = this.game.addToLeaderboard(initials);
352
+ console.log(`Added to leaderboard at rank ${finalRank}`);
353
+ }
354
+ );
 
 
 
 
 
 
355
  }
356
  }
357
  }
src/leaderboardUI.js CHANGED
@@ -170,7 +170,6 @@ export class LeaderboardUI {
170
  <div class="initials-modal">
171
  <div class="initials-header">
172
  <h2 class="initials-title">New High Score</h2>
173
- <button class="leaderboard-close" aria-label="Close without saving">Γ—</button>
174
  <div class="initials-achievement">
175
  You reached <span class="highlight">Level ${level}</span>
176
  <br>
@@ -179,7 +178,7 @@ export class LeaderboardUI {
179
  </div>
180
 
181
  <div class="initials-content">
182
- <p class="initials-prompt">Enter your initials:</p>
183
 
184
  <div class="initials-slots">
185
  ${this.initials.map((letter, index) => `
@@ -196,7 +195,7 @@ export class LeaderboardUI {
196
  <div class="initials-instructions">
197
  <p>Use arrow keys ↑↓ to change letters</p>
198
  <p>Press Tab or ←→ to move between slots</p>
199
- <p>Press Enter to confirm, ESC to cancel</p>
200
  </div>
201
 
202
  <button class="initials-submit typewriter-button">
 
170
  <div class="initials-modal">
171
  <div class="initials-header">
172
  <h2 class="initials-title">New High Score</h2>
 
173
  <div class="initials-achievement">
174
  You reached <span class="highlight">Level ${level}</span>
175
  <br>
 
178
  </div>
179
 
180
  <div class="initials-content">
181
+ <p class="initials-prompt">Enter or update your initials:</p>
182
 
183
  <div class="initials-slots">
184
  ${this.initials.map((letter, index) => `
 
195
  <div class="initials-instructions">
196
  <p>Use arrow keys ↑↓ to change letters</p>
197
  <p>Press Tab or ←→ to move between slots</p>
198
+ <p>Press Enter to submit</p>
199
  </div>
200
 
201
  <button class="initials-submit typewriter-button">
src/styles.css CHANGED
@@ -866,7 +866,7 @@
866
  }
867
 
868
  .initials-header {
869
- background: linear-gradient(180deg, var(--aged-paper-dark) 0%, #d4c9b3 100%);
870
  padding: 24px;
871
  border-bottom: 3px solid rgba(0, 0, 0, 0.3);
872
  text-align: center;
@@ -874,41 +874,43 @@
874
 
875
  .initials-title {
876
  font-family: 'Special Elite', 'Courier New', monospace;
877
- font-size: 24px;
878
  font-weight: 700;
879
- color: var(--typewriter-ink);
880
  margin: 0 0 12px 0;
881
  letter-spacing: 1px;
882
  }
883
 
884
  .initials-achievement {
885
- font-size: 16px;
886
- color: var(--typewriter-ink);
887
  line-height: 1.6;
 
888
  }
889
 
890
  .initials-achievement .highlight {
891
- color: #8b4513;
892
  font-weight: 700;
893
  text-decoration: underline;
894
  }
895
 
896
  .rank-text {
897
- font-size: 14px;
898
- color: #666;
899
  font-weight: 600;
900
- font-style: italic;
901
  }
902
 
903
  .initials-content {
904
  padding: 32px 24px;
905
  text-align: center;
 
906
  }
907
 
908
  .initials-prompt {
909
- font-size: 18px;
910
- font-weight: 600;
911
- color: var(--typewriter-ink);
912
  margin-bottom: 24px;
913
  }
914
 
@@ -982,20 +984,23 @@
982
  /* Initials Instructions */
983
  .initials-instructions {
984
  margin-bottom: 24px;
985
- color: #666;
986
- font-size: 13px;
987
- line-height: 1.6;
 
988
  }
989
 
990
  .initials-instructions p {
991
- margin: 4px 0;
992
  }
993
 
994
  .initials-submit {
995
  width: 100%;
996
  max-width: 200px;
997
  min-height: 48px;
998
- font-size: 16px;
 
 
999
  }
1000
 
1001
  /* Success Message */
 
866
  }
867
 
868
  .initials-header {
869
+ background: linear-gradient(180deg, #f5f1e8 0%, #e8dcc8 100%);
870
  padding: 24px;
871
  border-bottom: 3px solid rgba(0, 0, 0, 0.3);
872
  text-align: center;
 
874
 
875
  .initials-title {
876
  font-family: 'Special Elite', 'Courier New', monospace;
877
+ font-size: 26px;
878
  font-weight: 700;
879
+ color: #000000;
880
  margin: 0 0 12px 0;
881
  letter-spacing: 1px;
882
  }
883
 
884
  .initials-achievement {
885
+ font-size: 17px;
886
+ color: #1a1a1a;
887
  line-height: 1.6;
888
+ font-weight: 500;
889
  }
890
 
891
  .initials-achievement .highlight {
892
+ color: #7d3c00;
893
  font-weight: 700;
894
  text-decoration: underline;
895
  }
896
 
897
  .rank-text {
898
+ font-size: 16px;
899
+ color: #2c2c2c;
900
  font-weight: 600;
901
+ font-style: normal;
902
  }
903
 
904
  .initials-content {
905
  padding: 32px 24px;
906
  text-align: center;
907
+ background: var(--aged-paper-light);
908
  }
909
 
910
  .initials-prompt {
911
+ font-size: 20px;
912
+ font-weight: 700;
913
+ color: #000000;
914
  margin-bottom: 24px;
915
  }
916
 
 
984
  /* Initials Instructions */
985
  .initials-instructions {
986
  margin-bottom: 24px;
987
+ color: #000000;
988
+ font-size: 15px;
989
+ line-height: 1.7;
990
+ font-weight: 600;
991
  }
992
 
993
  .initials-instructions p {
994
+ margin: 6px 0;
995
  }
996
 
997
  .initials-submit {
998
  width: 100%;
999
  max-width: 200px;
1000
  min-height: 48px;
1001
+ font-size: 17px;
1002
+ font-weight: 700;
1003
+ color: #000000 !important;
1004
  }
1005
 
1006
  /* Success Message */