v0.2.10 Refactor spinner handling with CustomSpinner
Browse filesReplaced `_set_spinner` with a `CustomSpinner` context manager to improve readability, ensure proper cleanup, and enhance user experience during loading states. Updated `_init_session`, `_game_over_content`, and `run_app` to use `CustomSpinner` for better integration with Streamlit's UI components.
Introduced a base64-encoded `wrdler.gif` in
`show_spinner` for improved portability and added fallback visuals for robustness. Refactored spinner overlay styles for better appearance and transitions.
Modularized code by adding `_render_game_tab` and consolidating spinner logic into `CustomSpinner`. Removed redundant code, improved error handling, and refined CSS for a polished UI.
v0.2.10 Failed Spinner
move some functions to ui_helper
- .gitattributes +2 -1
- CLAUDE.md +19 -77
- README.md +9 -2
- specs/leaderboard_spec.md +15 -38
- specs/requirements.md +3 -2
- specs/specs.md +7 -16
- static/wrdler.gif +3 -0
- wrdler/__init__.py +1 -1
- wrdler/assets/wrdler.gif +3 -0
- wrdler/settings/settings.json +1 -1
- wrdler/ui.py +279 -852
- wrdler/ui_helpers.py +678 -0
.gitattributes
CHANGED
|
@@ -35,4 +35,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
*.wav filter=lfs diff=lfs merge=lfs -text
|
| 38 |
-
*.ico filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 37 |
*.wav filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
*.gif filter=lfs diff=lfs merge=lfs -text
|
CLAUDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# CLAUDE
|
| 2 |
|
| 3 |
-
Wrdler v0.2.
|
| 4 |
|
| 5 |
# Wrdler - Project Context
|
| 6 |
|
|
@@ -12,11 +12,12 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 12 |
- **2 free letter guesses at game start** (all instances revealed)
|
| 13 |
- **Word composition:** 2 four-letter, 2 five-letter, 2 six-letter words per puzzle
|
| 14 |
|
| 15 |
-
**Current Version:** 0.2.
|
|
|
|
| 16 |
**Repository:** https://github.com/Oncorporation/Wrdler.git
|
| 17 |
**Branch:** AI (working branch)
|
| 18 |
|
| 19 |
-
## Current Features (v0.2.
|
| 20 |
|
| 21 |
### Core Gameplay
|
| 22 |
- 8x6 grid with 6 hidden words (one per row, horizontal only)
|
|
@@ -26,6 +27,7 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 26 |
- Game ends when all words guessed or all word letters are revealed
|
| 27 |
- Incorrect guess history display (toggleable, default enabled)
|
| 28 |
- 10 incorrect guess limit per game
|
|
|
|
| 29 |
|
| 30 |
### Game Modes
|
| 31 |
1. **Classic Mode:** Allows consecutive guessing after correct answers
|
|
@@ -49,11 +51,9 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 49 |
- Default sound effects enabled in settings
|
| 50 |
- New default configuration: `classic-classic-full_sound_free_letters.json`
|
| 51 |
- Deprecated configuration removed: `classic-classic-2.json`
|
| 52 |
-
- Sidebar now focused on lightweight controls (if any) and navigation
|
| 53 |
|
| 54 |
### Word List Management
|
| 55 |
-
-
|
| 56 |
-
- Filter capability using `assets/filter.txt` blocklist to remove unwanted words
|
| 57 |
- Dialog display of removed words after filtering
|
| 58 |
|
| 59 |
### Challenge Mode & Remote Storage
|
|
@@ -68,6 +68,7 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 68 |
- Challenge scores also contribute to daily/weekly leaderboards
|
| 69 |
- Source tracking via `source_challenge_id` field
|
| 70 |
- Unified JSON format with `entry_type` field (daily/weekly/challenge)
|
|
|
|
| 71 |
|
| 72 |
**Access:** 'Leaderboard' link in the footer navigation at the bottom of the page
|
| 73 |
|
|
@@ -77,7 +78,7 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 77 |
- **Auto Score Submission:** Checks qualification for top 25 after game completion
|
| 78 |
- **Storage:** Folder-based discovery at `games/leaderboards/{daily|weekly}/{period}/{file_id}/settings.json`
|
| 79 |
- **File ID Format:** `{wordlist_source}-{game_mode}-{sequence}` (e.g., `classic-classic-0`)
|
| 80 |
-
- **Leaderboard Page:** Four tabs (Today, Daily, Weekly, History) accessible via `?page=today|daily|weekly|history`
|
| 81 |
- Leaderboard files use UTC for all period boundaries.
|
| 82 |
- When displaying daily leaderboards, show the UTC period as a PST date range.
|
| 83 |
- Example: For UTC file date 2025-12-08, display:
|
|
@@ -86,6 +87,10 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 86 |
2025-12-07 16:00:00 PST to 2025-12-08 15:59:59 PST
|
| 87 |
The leaderboard expander label should show: `Mon, Dec 08, 2025 4:00 PM PST – Tue, Dec 09, 2025 3:59:59 PM PST [settings badge]`
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
### AI Word Generation
|
| 90 |
- Topic-based word list generation via HuggingFace Spaces or local transformers
|
| 91 |
- Automatic word saving (max 1000 words per file)
|
|
@@ -103,7 +108,8 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 103 |
- Works offline for basic functionality
|
| 104 |
|
| 105 |
### Footer Navigation
|
| 106 |
-
-
|
|
|
|
| 107 |
|
| 108 |
## Technical Architecture
|
| 109 |
|
|
@@ -120,7 +126,7 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 120 |
wrdler/
|
| 121 |
├── app.py # Streamlit entry point
|
| 122 |
├── wrdler/ # Main package
|
| 123 |
-
│ ├── __init__.py # Version: 0.2.
|
| 124 |
│ ├── models.py # Data models (Coord, Word, Puzzle, GameState)
|
| 125 |
│ ├── generator.py # Puzzle generation with deterministic seeding
|
| 126 |
│ ├── logic.py # Game mechanics (reveal, guess, scoring)
|
|
@@ -245,80 +251,16 @@ streamlit run app.py
|
|
| 245 |
pytest tests/
|
| 246 |
```
|
| 247 |
|
| 248 |
-
##
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
Move all game settings from sidebar to a dedicated settings page at `?page=settings`, protected by HuggingFace OAuth (admin-only access).
|
| 252 |
-
|
| 253 |
-
### Implementation Approach
|
| 254 |
-
1. **Use existing query parameter routing** (like leaderboard pages)
|
| 255 |
-
2. **HuggingFace OAuth integration:**
|
| 256 |
-
- Add `hf_oauth: true` to README.md YAML header ✅ DONE
|
| 257 |
-
- Create `wrdler/oauth.py` with utility functions ✅ DONE
|
| 258 |
-
- OAuth user info available at `st.session_state["oauth_user"]`
|
| 259 |
-
- Check admin access via `ADMIN_USERS` environment variable
|
| 260 |
-
3. **Create `wrdler/settings_page.py`:**
|
| 261 |
-
- Check authentication with `require_admin()` from oauth.py
|
| 262 |
-
- Move settings UI from sidebar (word list, game mode, audio, etc.)
|
| 263 |
-
- Persist settings to session state
|
| 264 |
-
- Accessible via `?page=settings`
|
| 265 |
-
4. **Modify `wrdler/ui.py`:**
|
| 266 |
-
- Add settings page route handler (similar to leaderboard routing)
|
| 267 |
-
- Remove settings from sidebar (keep minimal controls only)
|
| 268 |
-
- Add "⚙️ Settings" link in footer navigation
|
| 269 |
-
5. **Keep sidebar minimal:**
|
| 270 |
-
- Version info
|
| 271 |
-
- User info (if logged in)
|
| 272 |
-
- Link to settings page
|
| 273 |
-
|
| 274 |
-
### HuggingFace OAuth Flow
|
| 275 |
-
1. User clicks login button (HF Spaces provides this automatically)
|
| 276 |
-
2. User authorizes with HF account
|
| 277 |
-
3. HF redirects back with OAuth token
|
| 278 |
-
4. User info stored in `st.session_state["oauth_user"]`
|
| 279 |
-
5. Check `username` against `ADMIN_USERS` env var
|
| 280 |
-
6. Grant/deny access to settings page
|
| 281 |
-
|
| 282 |
-
### Files to Modify
|
| 283 |
-
- `wrdler/ui.py` - Add settings page routing, remove sidebar settings
|
| 284 |
-
- `wrdler/settings_page.py` - Settings UI with OAuth protection (enhanced)
|
| 285 |
-
- `wrdler/oauth.py` - Already created
|
| 286 |
-
|
| 287 |
-
### Key OAuth Functions (wrdler/oauth.py)
|
| 288 |
-
```python
|
| 289 |
-
get_user_info() → Dict | None # Get authenticated user info
|
| 290 |
-
get_username() → str | None # Get username (preferred_username)
|
| 291 |
-
is_authenticated() → bool # Check if user is logged in
|
| 292 |
-
is_admin(allowed_users) → bool # Check if user is admin
|
| 293 |
-
require_admin(page_name) → bool # Validate admin access or show error
|
| 294 |
-
```
|
| 295 |
|
| 296 |
## Technical Notes
|
| 297 |
-
|
| 298 |
-
### Important Implementation Details
|
| 299 |
-
- **Python syntax only** - Use colons `:` not braces `{}`
|
| 300 |
- **8×6 grid:** `grid_rows=6`, `grid_cols=8`
|
| 301 |
- **Horizontal-only placement:** One word per row
|
| 302 |
- **Query param routing:** All pages use `?page=<name>` system
|
| 303 |
- **Session state management:** Heavy use of `st.session_state`
|
| 304 |
-
- **
|
| 305 |
-
|
| 306 |
-
### Current Routing Pattern (ui.py)
|
| 307 |
-
```python
|
| 308 |
-
params = st.query_params
|
| 309 |
-
page = params.get("page", "")
|
| 310 |
-
|
| 311 |
-
if page in {"today", "daily", "weekly", "history"}:
|
| 312 |
-
render_leaderboard_page(default_tab=page)
|
| 313 |
-
return
|
| 314 |
-
|
| 315 |
-
if page == "settings":
|
| 316 |
-
render_settings_page()
|
| 317 |
-
return
|
| 318 |
-
|
| 319 |
-
# Default: main game page
|
| 320 |
-
run_app()
|
| 321 |
-
```
|
| 322 |
|
| 323 |
## Deployment Platforms
|
| 324 |
1. **HuggingFace Spaces** (Primary) - Dockerfile deployment with OAuth support
|
|
|
|
| 1 |
# CLAUDE
|
| 2 |
|
| 3 |
+
Wrdler v0.2.10
|
| 4 |
|
| 5 |
# Wrdler - Project Context
|
| 6 |
|
|
|
|
| 12 |
- **2 free letter guesses at game start** (all instances revealed)
|
| 13 |
- **Word composition:** 2 four-letter, 2 five-letter, 2 six-letter words per puzzle
|
| 14 |
|
| 15 |
+
**Current Version:** 0.2.10
|
| 16 |
+
**Last Updated:** 2025-12-18
|
| 17 |
**Repository:** https://github.com/Oncorporation/Wrdler.git
|
| 18 |
**Branch:** AI (working branch)
|
| 19 |
|
| 20 |
+
## Current Features (v0.2.10)
|
| 21 |
|
| 22 |
### Core Gameplay
|
| 23 |
- 8x6 grid with 6 hidden words (one per row, horizontal only)
|
|
|
|
| 27 |
- Game ends when all words guessed or all word letters are revealed
|
| 28 |
- Incorrect guess history display (toggleable, default enabled)
|
| 29 |
- 10 incorrect guess limit per game
|
| 30 |
+
- **All leaderboard and challenge submissions use the latest st.session_state, including challenge overrides.**
|
| 31 |
|
| 32 |
### Game Modes
|
| 33 |
1. **Classic Mode:** Allows consecutive guessing after correct answers
|
|
|
|
| 51 |
- Default sound effects enabled in settings
|
| 52 |
- New default configuration: `classic-classic-full_sound_free_letters.json`
|
| 53 |
- Deprecated configuration removed: `classic-classic-2.json`
|
|
|
|
| 54 |
|
| 55 |
### Word List Management
|
| 56 |
+
- Sort and filter word lists (filter using `assets/filter.txt` blocklist)
|
|
|
|
| 57 |
- Dialog display of removed words after filtering
|
| 58 |
|
| 59 |
### Challenge Mode & Remote Storage
|
|
|
|
| 68 |
- Challenge scores also contribute to daily/weekly leaderboards
|
| 69 |
- Source tracking via `source_challenge_id` field
|
| 70 |
- Unified JSON format with `entry_type` field (daily/weekly/challenge)
|
| 71 |
+
- **Challenge settings override defaults on load, and all submissions use the current session state.**
|
| 72 |
|
| 73 |
**Access:** 'Leaderboard' link in the footer navigation at the bottom of the page
|
| 74 |
|
|
|
|
| 78 |
- **Auto Score Submission:** Checks qualification for top 25 after game completion
|
| 79 |
- **Storage:** Folder-based discovery at `games/leaderboards/{daily|weekly}/{period}/{file_id}/settings.json`
|
| 80 |
- **File ID Format:** `{wordlist_source}-{game_mode}-{sequence}` (e.g., `classic-classic-0`)
|
| 81 |
+
- **Leaderboard Page:** Four tabs (Today, Daily, Weekly, History) accessible via `?page=today|daily|weekly|history` using query parameter routing and custom navigation links (not Streamlit native tabs)
|
| 82 |
- Leaderboard files use UTC for all period boundaries.
|
| 83 |
- When displaying daily leaderboards, show the UTC period as a PST date range.
|
| 84 |
- Example: For UTC file date 2025-12-08, display:
|
|
|
|
| 87 |
2025-12-07 16:00:00 PST to 2025-12-08 15:59:59 PST
|
| 88 |
The leaderboard expander label should show: `Mon, Dec 08, 2025 4:00 PM PST – Tue, Dec 09, 2025 3:59:59 PM PST [settings badge]`
|
| 89 |
|
| 90 |
+
### Game Over Dialog & Leaderboard Integration
|
| 91 |
+
- Game over dialog now integrates leaderboard submission and displays qualification results (rankings)
|
| 92 |
+
- After submitting your score, the dialog will show if you qualified for the daily or weekly leaderboard and your rank
|
| 93 |
+
|
| 94 |
### AI Word Generation
|
| 95 |
- Topic-based word list generation via HuggingFace Spaces or local transformers
|
| 96 |
- Automatic word saving (max 1000 words per file)
|
|
|
|
| 108 |
- Works offline for basic functionality
|
| 109 |
|
| 110 |
### Footer Navigation
|
| 111 |
+
- Navigation links to Leaderboard, Play, and Settings pages are in the footer (not the sidebar)
|
| 112 |
+
- Footer navigation prevents reloading active pages
|
| 113 |
|
| 114 |
## Technical Architecture
|
| 115 |
|
|
|
|
| 126 |
wrdler/
|
| 127 |
├── app.py # Streamlit entry point
|
| 128 |
├── wrdler/ # Main package
|
| 129 |
+
│ ├── __init__.py # Version: 0.2.10
|
| 130 |
│ ├── models.py # Data models (Coord, Word, Puzzle, GameState)
|
| 131 |
│ ├── generator.py # Puzzle generation with deterministic seeding
|
| 132 |
│ ├── logic.py # Game mechanics (reveal, guess, scoring)
|
|
|
|
| 251 |
pytest tests/
|
| 252 |
```
|
| 253 |
|
| 254 |
+
## OAuth-Protected Settings Page
|
| 255 |
+
- Settings page at `?page=settings`, protected by HuggingFace OAuth (admin-only access)
|
| 256 |
+
- Uses query parameter routing and checks admin access via `ADMIN_USERS` env var
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
## Technical Notes
|
|
|
|
|
|
|
|
|
|
| 259 |
- **8×6 grid:** `grid_rows=6`, `grid_cols=8`
|
| 260 |
- **Horizontal-only placement:** One word per row
|
| 261 |
- **Query param routing:** All pages use `?page=<name>` system
|
| 262 |
- **Session state management:** Heavy use of `st.session_state`
|
| 263 |
+
- **All leaderboard and challenge submissions use the latest st.session_state, including challenge overrides.**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
## Deployment Platforms
|
| 266 |
1. **HuggingFace Spaces** (Primary) - Dockerfile deployment with OAuth support
|
README.md
CHANGED
|
@@ -21,7 +21,7 @@ thumbnail: >-
|
|
| 21 |
|
| 22 |
# Wrdler
|
| 23 |
|
| 24 |
-
Version 0.2.
|
| 25 |
|
| 26 |
Wrdler is a vocabulary puzzle game with daily and weekly leaderboards, 8x6 grid, and two free letter guesses at the start. See CHANGELOG or specs for details on the latest updates.
|
| 27 |
|
|
@@ -29,7 +29,8 @@ Wrdler is a vocabulary puzzle game with daily and weekly leaderboards, 8x6 grid,
|
|
| 29 |
|
| 30 |
Wrdler is a vocabulary learning game with a simplified grid and strategic letter guessing. The objective is to discover hidden words on a grid by making smart guesses before all letters are revealed.
|
| 31 |
|
| 32 |
-
**Current Version:** v0.2.
|
|
|
|
| 33 |
|
| 34 |
## Key Differences from BattleWords
|
| 35 |
|
|
@@ -50,6 +51,7 @@ Wrdler is a vocabulary learning game with a simplified grid and strategic letter
|
|
| 50 |
- Incorrect guess history with tooltip and optional display (enabled by default)
|
| 51 |
- 10 incorrect guess limit per game
|
| 52 |
- Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
|
|
|
|
| 53 |
|
| 54 |
### Audio & Visuals
|
| 55 |
- Ocean-themed gradient background with wave animations
|
|
@@ -91,6 +93,7 @@ Wrdler is a vocabulary learning game with a simplified grid and strategic letter
|
|
| 91 |
- **Top 5 leaderboard** display in Challenge Mode banner
|
| 92 |
- **"Show Challenge Share Links" toggle** (default OFF) to control URL visibility
|
| 93 |
- Each player gets different random words from the same wordlist
|
|
|
|
| 94 |
|
| 95 |
### 🏆 Daily & Weekly Leaderboards (v0.2.1) ✅
|
| 96 |
**Comprehensive Leaderboard System:**
|
|
@@ -117,12 +120,16 @@ Wrdler is a vocabulary learning game with a simplified grid and strategic letter
|
|
| 117 |
- **Daily Tab:** Last 7 days of daily leaderboards with expandable date groups
|
| 118 |
- **Weekly Tab:** Last 5 weeks, each rendered as its own expander (current or `week=YYYY-Www` query selection opens by default)
|
| 119 |
- **History Tab:** Historical leaderboard browser with dropdown selectors
|
|
|
|
|
|
|
| 120 |
|
| 121 |
**Integration:**
|
| 122 |
- Automatic submission after game completion (opt-in via game over popup)
|
|
|
|
| 123 |
- Challenge scores also contribute to daily/weekly leaderboards
|
| 124 |
- Source tracking via `source_challenge_id` field
|
| 125 |
- Unified JSON format with `entry_type` field (daily/weekly/challenge)
|
|
|
|
| 126 |
|
| 127 |
**Access:** 'Leaderboard' link in the footer navigation at the bottom of the page
|
| 128 |
|
|
|
|
| 21 |
|
| 22 |
# Wrdler
|
| 23 |
|
| 24 |
+
Version 0.2.10
|
| 25 |
|
| 26 |
Wrdler is a vocabulary puzzle game with daily and weekly leaderboards, 8x6 grid, and two free letter guesses at the start. See CHANGELOG or specs for details on the latest updates.
|
| 27 |
|
|
|
|
| 29 |
|
| 30 |
Wrdler is a vocabulary learning game with a simplified grid and strategic letter guessing. The objective is to discover hidden words on a grid by making smart guesses before all letters are revealed.
|
| 31 |
|
| 32 |
+
**Current Version:** v0.2.10
|
| 33 |
+
**Last Updated:** 2025-12-18
|
| 34 |
|
| 35 |
## Key Differences from BattleWords
|
| 36 |
|
|
|
|
| 51 |
- Incorrect guess history with tooltip and optional display (enabled by default)
|
| 52 |
- 10 incorrect guess limit per game
|
| 53 |
- Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
|
| 54 |
+
- **All leaderboard and challenge submissions use the latest st.session_state, including challenge overrides.**
|
| 55 |
|
| 56 |
### Audio & Visuals
|
| 57 |
- Ocean-themed gradient background with wave animations
|
|
|
|
| 93 |
- **Top 5 leaderboard** display in Challenge Mode banner
|
| 94 |
- **"Show Challenge Share Links" toggle** (default OFF) to control URL visibility
|
| 95 |
- Each player gets different random words from the same wordlist
|
| 96 |
+
- **Challenge settings override defaults on load, and all submissions use the current session state.**
|
| 97 |
|
| 98 |
### 🏆 Daily & Weekly Leaderboards (v0.2.1) ✅
|
| 99 |
**Comprehensive Leaderboard System:**
|
|
|
|
| 120 |
- **Daily Tab:** Last 7 days of daily leaderboards with expandable date groups
|
| 121 |
- **Weekly Tab:** Last 5 weeks, each rendered as its own expander (current or `week=YYYY-Www` query selection opens by default)
|
| 122 |
- **History Tab:** Historical leaderboard browser with dropdown selectors
|
| 123 |
+
- **Navigation:** Access leaderboards via the footer navigation at the bottom of the page (not the sidebar)
|
| 124 |
+
- **Routing:** Leaderboard page uses query parameters and custom navigation links for tab selection
|
| 125 |
|
| 126 |
**Integration:**
|
| 127 |
- Automatic submission after game completion (opt-in via game over popup)
|
| 128 |
+
- Game over dialog now integrates leaderboard submission and displays qualification results (rankings)
|
| 129 |
- Challenge scores also contribute to daily/weekly leaderboards
|
| 130 |
- Source tracking via `source_challenge_id` field
|
| 131 |
- Unified JSON format with `entry_type` field (daily/weekly/challenge)
|
| 132 |
+
- **All leaderboard and challenge submissions use the latest st.session_state, including challenge overrides.**
|
| 133 |
|
| 134 |
**Access:** 'Leaderboard' link in the footer navigation at the bottom of the page
|
| 135 |
|
specs/leaderboard_spec.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
# Wrdler Leaderboard System Specification
|
| 2 |
|
| 3 |
-
**Document Version:** 1.4.
|
| 4 |
-
**Project Version:** 0.2.
|
| 5 |
**Author:** GitHub Copilot
|
| 6 |
-
**Last Updated:** 2025-12-
|
| 7 |
**Status:** ✅ Implemented and Documented
|
| 8 |
|
| 9 |
---
|
|
@@ -38,6 +38,7 @@ This specification documents the implemented **Daily and Weekly Leaderboard Syst
|
|
| 38 |
- ✅ Stores leaderboard data in HuggingFace repository using existing storage infrastructure
|
| 39 |
- ✅ Uses folder-based discovery (no index.json) with descriptive folder names
|
| 40 |
- ✅ Uses a unified JSON format consistent with existing challenge settings.json files
|
|
|
|
| 41 |
|
| 42 |
**Implementation Status:** All features complete and deployed as of version 0.2.0
|
| 43 |
|
|
@@ -155,6 +156,8 @@ Instead of maintaining an `index.json` file, leaderboards are discovered by:
|
|
| 155 |
|
| 156 |
### 3.4 Data Flow
|
| 157 |
|
|
|
|
|
|
|
| 158 |
```
|
| 159 |
┌────────────────────┐
|
| 160 |
│ Game Completion │
|
|
@@ -521,46 +524,17 @@ def get_current_weekly_id() -> str:
|
|
| 521 |
|
| 522 |
## 10. UI Components
|
| 523 |
|
| 524 |
-
### 10.1
|
| 525 |
-
|
| 526 |
-
Add to `_render_sidebar()` in `ui.py`:
|
| 527 |
|
| 528 |
-
|
| 529 |
-
st.header("Navigation")
|
| 530 |
-
if st.button("🏆 Leaderboards", width="stretch"):
|
| 531 |
-
st.session_state["show_leaderboard_page"] = True
|
| 532 |
-
st.rerun()
|
| 533 |
-
```
|
| 534 |
|
| 535 |
-
### 10.2 Game Over Integration
|
| 536 |
|
| 537 |
-
|
| 538 |
|
| 539 |
-
|
| 540 |
-
2. Display qualification results:
|
| 541 |
|
| 542 |
-
|
| 543 |
-
# After score submission
|
| 544 |
-
if results["daily"]["qualified"]:
|
| 545 |
-
st.success(f"🏆 You ranked #{results['daily']['rank']} on today's leaderboard!")
|
| 546 |
-
if results["weekly"]["qualified"]:
|
| 547 |
-
st.success(f"🏆 You ranked #{results['weekly']['rank']} on this week's leaderboard!")
|
| 548 |
-
```
|
| 549 |
-
|
| 550 |
-
### 10.3 Leaderboard Page Routing
|
| 551 |
-
|
| 552 |
-
In `run_app()`:
|
| 553 |
-
|
| 554 |
-
```python
|
| 555 |
-
# Check if leaderboard page should be shown
|
| 556 |
-
if st.session_state.get("show_leaderboard_page", False):
|
| 557 |
-
from wrdler.leaderboard_page import render_leaderboard_page
|
| 558 |
-
render_leaderboard_page()
|
| 559 |
-
if st.button("← Back to Game"):
|
| 560 |
-
st.session_state["show_leaderboard_page"] = False
|
| 561 |
-
st.rerun()
|
| 562 |
-
return # Don't render game UI
|
| 563 |
-
```
|
| 564 |
|
| 565 |
---
|
| 566 |
|
|
@@ -863,6 +837,9 @@ HF_REPO_ID/games/
|
|
| 863 |
- Always store full `users` list; apply `max_display_entries` at render time only.
|
| 864 |
- Rank reporting:
|
| 865 |
- Return rank based on full sorted list even if not displayed; if outside display limit, mark `qualified=False`.
|
|
|
|
|
|
|
|
|
|
| 866 |
|
| 867 |
### 14.9 Commit and Retry Strategy (HF)
|
| 868 |
|
|
|
|
| 1 |
# Wrdler Leaderboard System Specification
|
| 2 |
|
| 3 |
+
**Document Version:** 1.4.3
|
| 4 |
+
**Project Version:** 0.2.10
|
| 5 |
**Author:** GitHub Copilot
|
| 6 |
+
**Last Updated:** 2025-12-18
|
| 7 |
**Status:** ✅ Implemented and Documented
|
| 8 |
|
| 9 |
---
|
|
|
|
| 38 |
- ✅ Stores leaderboard data in HuggingFace repository using existing storage infrastructure
|
| 39 |
- ✅ Uses folder-based discovery (no index.json) with descriptive folder names
|
| 40 |
- ✅ Uses a unified JSON format consistent with existing challenge settings.json files
|
| 41 |
+
- **All leaderboard and challenge submissions use the latest st.session_state, including challenge overrides.**
|
| 42 |
|
| 43 |
**Implementation Status:** All features complete and deployed as of version 0.2.0
|
| 44 |
|
|
|
|
| 156 |
|
| 157 |
### 3.4 Data Flow
|
| 158 |
|
| 159 |
+
- All leaderboard and challenge submissions use the latest st.session_state, including challenge overrides.
|
| 160 |
+
|
| 161 |
```
|
| 162 |
┌────────────────────┐
|
| 163 |
│ Game Completion │
|
|
|
|
| 524 |
|
| 525 |
## 10. UI Components
|
| 526 |
|
| 527 |
+
### 10.1 Footer Navigation (Updated)
|
|
|
|
|
|
|
| 528 |
|
| 529 |
+
Leaderboard navigation is now accessed via the footer menu at the bottom of the page, not the sidebar. The footer contains links to Leaderboard, Play, and Settings pages. This replaces the previous sidebar navigation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
|
| 531 |
+
### 10.2 Game Over Integration (Updated)
|
| 532 |
|
| 533 |
+
The game over dialog now integrates leaderboard submission and displays qualification results (rankings). After submitting your score, the dialog will show if you qualified for the daily or weekly leaderboard and your rank.
|
| 534 |
|
| 535 |
+
### 10.3 Leaderboard Page Routing (Updated)
|
|
|
|
| 536 |
|
| 537 |
+
Leaderboard page routing uses query parameters and custom navigation links for tab selection (e.g., `?page=today`, `?page=weekly`). Tabs are not implemented with Streamlit's native tabs but with custom links for better URL support.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
|
| 539 |
---
|
| 540 |
|
|
|
|
| 837 |
- Always store full `users` list; apply `max_display_entries` at render time only.
|
| 838 |
- Rank reporting:
|
| 839 |
- Return rank based on full sorted list even if not displayed; if outside display limit, mark `qualified=False`.
|
| 840 |
+
- **Duplicate removal:**
|
| 841 |
+
- If multiple entries exist with identical `username`, `word_list`, `score`, `time`, `timestamp`, and `word_list_difficulty`, only keep one entry.
|
| 842 |
+
- Prefer the entry with a non-null `source_challenge_id` if duplicates are found.
|
| 843 |
|
| 844 |
### 14.9 Commit and Retry Strategy (HF)
|
| 845 |
|
specs/requirements.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
# Wrdler Requirements
|
| 2 |
|
| 3 |
-
**Version:** 0.2.
|
| 4 |
**Status:** Production Ready - Leaderboards Implemented
|
| 5 |
-
**Last Updated:** 2025-12-
|
| 6 |
|
| 7 |
This document breaks down the implementation tasks for Wrdler using the game rules described in `specs.md`. Wrdler is a Python/Streamlit project based on BattleWords but with a simplified 8x6 grid, horizontal-only words, and free letter guesses at the start.
|
| 8 |
|
|
@@ -15,6 +15,7 @@ This document breaks down the implementation tasks for Wrdler using the game rul
|
|
| 15 |
- Horizontal words only (no vertical)
|
| 16 |
- No radar/scope visualization
|
| 17 |
- 2 free letter guesses at game start
|
|
|
|
| 18 |
|
| 19 |
## Implementation Details (v0.2.1)
|
| 20 |
- **Tech Stack:** Python 3.12.8, Streamlit 1.51.0, numpy, matplotlib, transformers, gradio_client
|
|
|
|
| 1 |
# Wrdler Requirements
|
| 2 |
|
| 3 |
+
**Version:** 0.2.10
|
| 4 |
**Status:** Production Ready - Leaderboards Implemented
|
| 5 |
+
**Last Updated:** 2025-12-18
|
| 6 |
|
| 7 |
This document breaks down the implementation tasks for Wrdler using the game rules described in `specs.md`. Wrdler is a Python/Streamlit project based on BattleWords but with a simplified 8x6 grid, horizontal-only words, and free letter guesses at the start.
|
| 8 |
|
|
|
|
| 15 |
- Horizontal words only (no vertical)
|
| 16 |
- No radar/scope visualization
|
| 17 |
- 2 free letter guesses at game start
|
| 18 |
+
- **All leaderboard and challenge submissions use the latest st.session_state, including challenge overrides.**
|
| 19 |
|
| 20 |
## Implementation Details (v0.2.1)
|
| 21 |
- **Tech Stack:** Python 3.12.8, Streamlit 1.51.0, numpy, matplotlib, transformers, gradio_client
|
specs/specs.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
# Wrdler Specifications
|
| 2 |
|
| 3 |
-
**Version:** 0.2.
|
|
|
|
| 4 |
|
| 5 |
**Status:** Production Ready - Leaderboards & Enhanced Settings Page Implemented
|
| 6 |
-
**Last Updated:** 2025-12-09
|
| 7 |
|
| 8 |
## Overview
|
| 9 |
Wrdler is a Python/Streamlit vocabulary puzzle game based on BattleWords, but with key differences. The objective is to discover hidden words on a grid by making strategic guesses and using free letter reveals at the game start.
|
|
@@ -17,6 +17,7 @@ Wrdler is a Python/Streamlit vocabulary puzzle game based on BattleWords, but wi
|
|
| 17 |
- **Horizontal words only** (no vertical placement)
|
| 18 |
- **No scope/radar visualization**
|
| 19 |
- **2 free letter guesses at game start** (all instances of chosen letters are revealed)
|
|
|
|
| 20 |
|
| 21 |
## Game Board
|
| 22 |
- 8 x 6 grid
|
|
@@ -142,26 +143,16 @@ HF_REPO_ID/games/
|
|
| 142 |
- **History Tab:** Historical leaderboard browser
|
| 143 |
- Dropdown selectors for period and settings
|
| 144 |
- Separate daily and weekly columns
|
|
|
|
|
|
|
| 145 |
|
| 146 |
**Integration:**
|
| 147 |
- Automatic submission after game completion (opt-in)
|
|
|
|
| 148 |
- Challenge scores also contribute to daily/weekly leaderboards
|
| 149 |
- Source tracking via `source_challenge_id` field
|
| 150 |
- Unified JSON format with `entry_type` field (daily/weekly/challenge)
|
| 151 |
-
|
| 152 |
-
**Discovery:** Folder-based (no index.json)
|
| 153 |
-
- Scans period folders for date/week IDs
|
| 154 |
-
- Filters by file_id prefix for matching settings
|
| 155 |
-
- Loads and verifies full settings match
|
| 156 |
-
|
| 157 |
-
**Date Display Updates:**
|
| 158 |
-
- All leaderboard files use UTC for period boundaries.
|
| 159 |
-
- When displaying daily leaderboards, show the UTC period as a PST date range.
|
| 160 |
-
- Example: For UTC file date 2025-12-08, display:
|
| 161 |
-
2025-12-08 00:00:00 UTC to 2025-12-08 23:59:59 UTC
|
| 162 |
-
and
|
| 163 |
-
2025-12-07 16:00:00 PST to 2025-12-08 15:59:59 PST
|
| 164 |
-
The leaderboard expander label should show: `Mon, Dec 08, 2025 4:00 PM PST – Tue, Dec 09, 2025 3:59:59 PM PST [settings badge]`
|
| 165 |
|
| 166 |
**Access:** 'Leaderboard' link in the footer navigation at the bottom of the page
|
| 167 |
|
|
|
|
| 1 |
# Wrdler Specifications
|
| 2 |
|
| 3 |
+
**Version:** 0.2.10
|
| 4 |
+
**Last Updated:** 2025-12-18
|
| 5 |
|
| 6 |
**Status:** Production Ready - Leaderboards & Enhanced Settings Page Implemented
|
|
|
|
| 7 |
|
| 8 |
## Overview
|
| 9 |
Wrdler is a Python/Streamlit vocabulary puzzle game based on BattleWords, but with key differences. The objective is to discover hidden words on a grid by making strategic guesses and using free letter reveals at the game start.
|
|
|
|
| 17 |
- **Horizontal words only** (no vertical placement)
|
| 18 |
- **No scope/radar visualization**
|
| 19 |
- **2 free letter guesses at game start** (all instances of chosen letters are revealed)
|
| 20 |
+
- **All leaderboard and challenge submissions use the latest st.session_state, including challenge overrides.**
|
| 21 |
|
| 22 |
## Game Board
|
| 23 |
- 8 x 6 grid
|
|
|
|
| 143 |
- **History Tab:** Historical leaderboard browser
|
| 144 |
- Dropdown selectors for period and settings
|
| 145 |
- Separate daily and weekly columns
|
| 146 |
+
- **Navigation:** Access leaderboards via the footer navigation at the bottom of the page (not the sidebar)
|
| 147 |
+
- **Routing:** Leaderboard page uses query parameters and custom navigation links for tab selection
|
| 148 |
|
| 149 |
**Integration:**
|
| 150 |
- Automatic submission after game completion (opt-in)
|
| 151 |
+
- Game over dialog now integrates leaderboard submission and displays qualification results (rankings)
|
| 152 |
- Challenge scores also contribute to daily/weekly leaderboards
|
| 153 |
- Source tracking via `source_challenge_id` field
|
| 154 |
- Unified JSON format with `entry_type` field (daily/weekly/challenge)
|
| 155 |
+
- **All leaderboard and challenge submissions use the latest st.session_state, including challenge overrides.**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
**Access:** 'Leaderboard' link in the footer navigation at the bottom of the page
|
| 158 |
|
static/wrdler.gif
ADDED
|
Git LFS Details
|
wrdler/__init__.py
CHANGED
|
@@ -9,5 +9,5 @@ Key differences from BattleWords:
|
|
| 9 |
- Daily and weekly leaderboards
|
| 10 |
"""
|
| 11 |
|
| 12 |
-
__version__ = "0.2.
|
| 13 |
__all__ = ["models", "generator", "logic", "ui", "word_loader", "leaderboard", "leaderboard_page"]
|
|
|
|
| 9 |
- Daily and weekly leaderboards
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
__version__ = "0.2.10"
|
| 13 |
__all__ = ["models", "generator", "logic", "ui", "word_loader", "leaderboard", "leaderboard_page"]
|
wrdler/assets/wrdler.gif
ADDED
|
Git LFS Details
|
wrdler/settings/settings.json
CHANGED
|
@@ -11,7 +11,7 @@
|
|
| 11 |
"music_enabled": false,
|
| 12 |
"music_volume": 15,
|
| 13 |
"effects_volume": 25,
|
| 14 |
-
"enable_sound_effects":
|
| 15 |
"music_track_path": "background.mp3",
|
| 16 |
"wordlist_source": "classic.txt",
|
| 17 |
"puzzle_options": {
|
|
|
|
| 11 |
"music_enabled": false,
|
| 12 |
"music_volume": 15,
|
| 13 |
"effects_volume": 25,
|
| 14 |
+
"enable_sound_effects": false,
|
| 15 |
"music_track_path": "background.mp3",
|
| 16 |
"wordlist_source": "classic.txt",
|
| 17 |
"puzzle_options": {
|
wrdler/ui.py
CHANGED
|
@@ -33,156 +33,38 @@ from .leaderboard import submit_score_to_all_leaderboards
|
|
| 33 |
from .settings_page import render_settings_page
|
| 34 |
from .local_storage import load_latest_settings
|
| 35 |
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
#
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
// Register service worker for offline functionality
|
| 45 |
-
// Note: Using inline Blob URL to bypass Streamlit's text/plain content-type for .js files
|
| 46 |
-
if ('serviceWorker' in navigator) {
|
| 47 |
-
window.addEventListener('load', () => {
|
| 48 |
-
// Service worker code as string (inline to avoid MIME type issues)
|
| 49 |
-
const swCode = `
|
| 50 |
-
const CACHE_NAME = 'wrdler-v0.0.1';
|
| 51 |
-
const RUNTIME_CACHE = 'wrdler-runtime';
|
| 52 |
-
|
| 53 |
-
const PRECACHE_URLS = [
|
| 54 |
-
'/',
|
| 55 |
-
'/app/static/manifest.json',
|
| 56 |
-
'/app/static/icon-192.png',
|
| 57 |
-
'/app/static/icon-512.png'
|
| 58 |
-
];
|
| 59 |
-
|
| 60 |
-
self.addEventListener('install', event => {
|
| 61 |
-
console.log('[ServiceWorker] Installing...');
|
| 62 |
-
event.waitUntil(
|
| 63 |
-
caches.open(CACHE_NAME)
|
| 64 |
-
.then(cache => {
|
| 65 |
-
console.log('[ServiceWorker] Precaching app shell');
|
| 66 |
-
return cache.addAll(PRECACHE_URLS);
|
| 67 |
-
})
|
| 68 |
-
.then(() => self.skipWaiting())
|
| 69 |
-
);
|
| 70 |
-
});
|
| 71 |
-
|
| 72 |
-
self.addEventListener('activate', event => {
|
| 73 |
-
console.log('[ServiceWorker] Activating...');
|
| 74 |
-
event.waitUntil(
|
| 75 |
-
caches.keys().then(cacheNames => {
|
| 76 |
-
return Promise.all(
|
| 77 |
-
cacheNames.map(cacheName => {
|
| 78 |
-
if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
|
| 79 |
-
console.log('[ServiceWorker] Deleting old cache:', cacheName);
|
| 80 |
-
return caches.delete(cacheName);
|
| 81 |
-
}
|
| 82 |
-
})
|
| 83 |
-
);
|
| 84 |
-
}).then(() => self.clients.claim())
|
| 85 |
-
);
|
| 86 |
-
});
|
| 87 |
-
|
| 88 |
-
self.addEventListener('fetch', event => {
|
| 89 |
-
if (event.request.method !== 'GET') return;
|
| 90 |
-
if (!event.request.url.startsWith('http')) return;
|
| 91 |
-
|
| 92 |
-
event.respondWith(
|
| 93 |
-
caches.open(RUNTIME_CACHE).then(cache => {
|
| 94 |
-
return fetch(event.request)
|
| 95 |
-
.then(response => {
|
| 96 |
-
if (response.status === 200) {
|
| 97 |
-
cache.put(event.request, response.clone());
|
| 98 |
-
}
|
| 99 |
-
return response;
|
| 100 |
-
})
|
| 101 |
-
.catch(() => {
|
| 102 |
-
return caches.match(event.request).then(cachedResponse => {
|
| 103 |
-
if (cachedResponse) {
|
| 104 |
-
console.log('[ServiceWorker] Serving from cache:', event.request.url);
|
| 105 |
-
return cachedResponse;
|
| 106 |
-
}
|
| 107 |
-
return new Response('Offline - Please check your connection', {
|
| 108 |
-
status: 503,
|
| 109 |
-
statusText: 'Service Unavailable',
|
| 110 |
-
headers: new Headers({'Content-Type': 'text/plain'})
|
| 111 |
-
});
|
| 112 |
-
});
|
| 113 |
-
});
|
| 114 |
-
})
|
| 115 |
-
);
|
| 116 |
-
});
|
| 117 |
-
|
| 118 |
-
self.addEventListener('message', event => {
|
| 119 |
-
if (event.data.action === 'skipWaiting') {
|
| 120 |
-
self.skipWaiting();
|
| 121 |
-
}
|
| 122 |
-
});
|
| 123 |
-
`;
|
| 124 |
-
|
| 125 |
-
// Create Blob URL for service worker
|
| 126 |
-
const blob = new Blob([swCode], { type: 'application/javascript' });
|
| 127 |
-
const swUrl = URL.createObjectURL(blob);
|
| 128 |
-
|
| 129 |
-
navigator.serviceWorker.register(swUrl)
|
| 130 |
-
.then(registration => {
|
| 131 |
-
console.log('[PWA] Service Worker registered successfully:', registration.scope);
|
| 132 |
-
|
| 133 |
-
registration.addEventListener('updatefound', () => {
|
| 134 |
-
const newWorker = registration.installing;
|
| 135 |
-
newWorker.addEventListener('statechange', () => {
|
| 136 |
-
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
| 137 |
-
console.log('[PWA] New version available! Refresh to update.');
|
| 138 |
-
}
|
| 139 |
-
});
|
| 140 |
-
});
|
| 141 |
-
})
|
| 142 |
-
.catch(error => {
|
| 143 |
-
console.log('[PWA] Service Worker registration failed:', error);
|
| 144 |
-
});
|
| 145 |
-
});
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
// Prompt user to install PWA (for browsers that support it)
|
| 149 |
-
let deferredPrompt;
|
| 150 |
-
window.addEventListener('beforeinstallprompt', (e) => {
|
| 151 |
-
console.log('[PWA] Install prompt available');
|
| 152 |
-
e.preventDefault();
|
| 153 |
-
deferredPrompt = e;
|
| 154 |
-
// Could show custom install button here if desired
|
| 155 |
-
});
|
| 156 |
-
|
| 157 |
-
// Track when user installs the app
|
| 158 |
-
window.addEventListener('appinstalled', () => {
|
| 159 |
-
console.log('[PWA] Wrdler installed successfully!');
|
| 160 |
-
deferredPrompt = null;
|
| 161 |
-
});
|
| 162 |
-
</script>
|
| 163 |
-
"""
|
| 164 |
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
# First check shared game settings (challenge mode)
|
| 176 |
-
shared_settings = st.session_state.get("shared_game_settings")
|
| 177 |
-
if shared_settings and shared_settings.get("game_title"):
|
| 178 |
-
return shared_settings["game_title"]
|
| 179 |
-
|
| 180 |
-
# Then check session state
|
| 181 |
-
if st.session_state.get("game_title"):
|
| 182 |
-
return st.session_state["game_title"]
|
| 183 |
-
|
| 184 |
-
# Fall back to APP_SETTINGS
|
| 185 |
-
return APP_SETTINGS.get("game_title", "Wrdler")
|
| 186 |
|
| 187 |
|
| 188 |
def _apply_challenge_settings(settings: dict) -> None:
|
|
@@ -207,13 +89,6 @@ def _apply_challenge_settings(settings: dict) -> None:
|
|
| 207 |
st.session_state.game_title = settings["game_title"]
|
| 208 |
|
| 209 |
|
| 210 |
-
def fig_to_pil_rgba(fig):
|
| 211 |
-
canvas = FigureCanvas(fig)
|
| 212 |
-
canvas.draw()
|
| 213 |
-
w, h = fig.canvas.get_width_height()
|
| 214 |
-
img = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
|
| 215 |
-
return Image.fromarray(img, mode="RGBA")
|
| 216 |
-
|
| 217 |
def _coord_to_xy(c) -> CoordLike:
|
| 218 |
# Supports dataclass Coord(x, y) or a 2-tuple/list.
|
| 219 |
if hasattr(c, "x") and hasattr(c, "y"):
|
|
@@ -238,420 +113,15 @@ def _build_letter_map(puzzle) -> dict[CoordLike, str]:
|
|
| 238 |
letters[xy] = text[i]
|
| 239 |
return letters
|
| 240 |
|
| 241 |
-
ocean_background_css = """
|
| 242 |
-
<style>
|
| 243 |
-
:root {
|
| 244 |
-
--water-deep: #0b2a4a;
|
| 245 |
-
--water-mid: #0f3968;
|
| 246 |
-
--water-lite: #165ba8;
|
| 247 |
-
--water-sky: #1d64c8;
|
| 248 |
-
--foam: rgba(255,255,255,0.18);
|
| 249 |
-
}
|
| 250 |
-
|
| 251 |
-
.stAppHeader{
|
| 252 |
-
opacity:0.6;
|
| 253 |
-
}
|
| 254 |
-
|
| 255 |
-
.stApp {
|
| 256 |
-
margin: 0;
|
| 257 |
-
min-height: 100vh;
|
| 258 |
-
overflow: hidden; /* prevent scrollbars from animated layers */
|
| 259 |
-
background-attachment: scroll;
|
| 260 |
-
position: relative;
|
| 261 |
-
|
| 262 |
-
/* Static base gradient */
|
| 263 |
-
background-image:
|
| 264 |
-
linear-gradient(180deg, var(--water-sky) 0%, var(--water-lite) 35%, var(--water-mid) 70%, var(--water-deep) 100%);
|
| 265 |
-
background-size: 100% 100%;
|
| 266 |
-
background-position: 50% 50%;
|
| 267 |
-
|
| 268 |
-
animation: none !important;
|
| 269 |
-
will-change: background-position;
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
/* Animated overlay waves (under bg layers) */
|
| 273 |
-
.stApp::before {
|
| 274 |
-
content: "";
|
| 275 |
-
position: absolute;
|
| 276 |
-
inset: -10% -10%;
|
| 277 |
-
z-index: 0;
|
| 278 |
-
pointer-events: none;
|
| 279 |
-
background:
|
| 280 |
-
repeating-linear-gradient(0deg, rgba(255,255,255,0.10) 0 2px, transparent 2px 22px),
|
| 281 |
-
repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0 1px, transparent 1px 18px);
|
| 282 |
-
mix-blend-mode: screen;
|
| 283 |
-
opacity: 0.10;
|
| 284 |
-
/* animation: waveOverlayScroll 16s linear infinite; */
|
| 285 |
-
}
|
| 286 |
-
.stIFrame {
|
| 287 |
-
margin-bottom:25px;
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
-
@keyframes waveOverlayScroll {
|
| 291 |
-
0% { background-position: 0px 0px, 0px 0px; }
|
| 292 |
-
100% { background-position: -800px 0px, 0px -600px; }
|
| 293 |
-
}
|
| 294 |
-
|
| 295 |
-
/* Keep Streamlit content above background/overlay */
|
| 296 |
-
.stApp > div { position: relative; z-index: 5; }
|
| 297 |
-
|
| 298 |
-
/* Slower, more subtle animations */
|
| 299 |
-
@keyframes oceanHighlight {
|
| 300 |
-
0% { background-position: 50% 0%; }
|
| 301 |
-
50% { background-position: 60% 8%; }
|
| 302 |
-
100% { background-position: 50% 0%; }
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
@keyframes oceanLong {
|
| 306 |
-
0% { background-position: 0% 50%; }
|
| 307 |
-
100% { background-position: -100% 50%; }
|
| 308 |
-
}
|
| 309 |
-
|
| 310 |
-
@keyframes oceanMid {
|
| 311 |
-
0% { background-position: 100% 50%; }
|
| 312 |
-
100% { background-position: 200% 50%; }
|
| 313 |
-
}
|
| 314 |
-
|
| 315 |
-
@keyframes oceanFine {
|
| 316 |
-
0% { background-position: 0% 50%; }
|
| 317 |
-
100% { background-position: 100% 50%; }
|
| 318 |
-
}
|
| 319 |
-
|
| 320 |
-
/* Reduced motion */
|
| 321 |
-
@media (prefers-reduced-motion: reduce) {
|
| 322 |
-
.stApp, .stApp::before { animation: none; }
|
| 323 |
-
}
|
| 324 |
-
</style>
|
| 325 |
-
"""
|
| 326 |
-
|
| 327 |
-
def inject_ocean_layers() -> None:
|
| 328 |
-
st.markdown(
|
| 329 |
-
"""
|
| 330 |
-
<style>
|
| 331 |
-
.bw-bg-container {
|
| 332 |
-
position: fixed; /* fixed to viewport, not stApp */
|
| 333 |
-
inset: 0;
|
| 334 |
-
z-index: 1; /* below content (z=5) but above ::before (z=0) */
|
| 335 |
-
pointer-events: none;
|
| 336 |
-
overflow: hidden; /* clip children */
|
| 337 |
-
}
|
| 338 |
-
.bw-bg-layer {
|
| 339 |
-
position: absolute;
|
| 340 |
-
inset: 0;
|
| 341 |
-
width: 100vw;
|
| 342 |
-
height: 100vh;
|
| 343 |
-
pointer-events: none;
|
| 344 |
-
}
|
| 345 |
-
/* Explicit stacking order with slower animations */
|
| 346 |
-
.bw-bg-highlight {
|
| 347 |
-
z-index: 11;
|
| 348 |
-
background: radial-gradient(150% 100% at 50% -20%, rgba(255,255,255,0.10) 0%, transparent 60%);
|
| 349 |
-
background-size: 150% 150%; /* reduced from 300% */
|
| 350 |
-
/* animation: oceanHighlight 12s ease-in-out infinite; */ /* doubled from 6s */
|
| 351 |
-
}
|
| 352 |
-
.bw-bg-long {
|
| 353 |
-
z-index: 12;
|
| 354 |
-
background: repeating-linear-gradient(-6deg, rgba(255,255,255,0.08) 0px, rgba(255,255,255,0.08) 18px, rgba(0,0,0,0.04) 18px, rgba(0,0,0,0.04) 48px);
|
| 355 |
-
background-size: 150% 150%; /* reduced from 320% */
|
| 356 |
-
/* animation: oceanLong 36s linear infinite; */ /* doubled from 18s */
|
| 357 |
-
opacity: 0.2;
|
| 358 |
-
}
|
| 359 |
-
.bw-bg-mid {
|
| 360 |
-
z-index: 13;
|
| 361 |
-
background: repeating-linear-gradient(-12deg, rgba(255,255,255,0.10) 0px, rgba(255,255,255,0.10) 10px, rgba(0,0,0,0.05) 10px, rgba(0,0,0,0.05) 26px);
|
| 362 |
-
background-size: 150% 150%; /* reduced from 260% */
|
| 363 |
-
/* animation: oceanMid 24s linear infinite; */ /* doubled from 12s */
|
| 364 |
-
opacity: 0.2;
|
| 365 |
-
}
|
| 366 |
-
.bw-bg-fine {
|
| 367 |
-
z-index: 14;
|
| 368 |
-
background: repeating-linear-gradient(-18deg, var(--foam) 0px, var(--foam) 4px, transparent 4px, transparent 12px);
|
| 369 |
-
background-size: 120% 120%; /* reduced from 200% */
|
| 370 |
-
/* animation: oceanFine 14s linear infinite; */ /* doubled from 7s */
|
| 371 |
-
opacity: 0.15;
|
| 372 |
-
}
|
| 373 |
-
</style>
|
| 374 |
-
<div class="bw-bg-container">
|
| 375 |
-
<div class="bw-bg-layer bw-bg-highlight"></div>
|
| 376 |
-
<div class="bw-bg-layer bw-bg-long"></div>
|
| 377 |
-
<div class="bw-bg-layer bw-bg-mid"></div>
|
| 378 |
-
<div class="bw-bg-layer bw-bg-fine"></div>
|
| 379 |
-
</div>
|
| 380 |
-
""",
|
| 381 |
-
unsafe_allow_html=True,
|
| 382 |
-
)
|
| 383 |
-
|
| 384 |
-
def inject_styles() -> None:
|
| 385 |
-
st.markdown(
|
| 386 |
-
"""
|
| 387 |
-
<style>
|
| 388 |
-
/* Center main content and limit width */
|
| 389 |
-
# .stApp, body {
|
| 390 |
-
# background: rgba(29, 100, 200, 0.5);
|
| 391 |
-
# }
|
| 392 |
-
.stMainBlockContainer {
|
| 393 |
-
max-width: 1100px;
|
| 394 |
-
}
|
| 395 |
-
.stHeading {
|
| 396 |
-
margin-bottom: -1.5rem !important;
|
| 397 |
-
margin-top: -1.5rem !important;
|
| 398 |
-
# font-size: 1.75rem !important; /* Title */
|
| 399 |
-
line-height: 1.1 !important;
|
| 400 |
-
}
|
| 401 |
-
.stHeading h3 {font-size: 1.35rem;}
|
| 402 |
-
/* Base grid cell visuals */
|
| 403 |
-
.bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; min-height: 48px;}
|
| 404 |
-
.bw-cell {
|
| 405 |
-
width: 100%;
|
| 406 |
-
gap: 0.1rem;
|
| 407 |
-
aspect-ratio: 16 / 11;
|
| 408 |
-
line-height: 1.6;
|
| 409 |
-
display: flex;
|
| 410 |
-
align-items: center;
|
| 411 |
-
justify-content: center;
|
| 412 |
-
border: 1px solid #1d64c8;
|
| 413 |
-
border-radius: 0;
|
| 414 |
-
font-weight: 700;
|
| 415 |
-
user-select: none;
|
| 416 |
-
padding: 0.5rem 0.75rem;
|
| 417 |
-
font-size: 1.4rem;
|
| 418 |
-
min-height: 1.75rem;
|
| 419 |
-
min-width: 1.25em;
|
| 420 |
-
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
| 421 |
-
background: #1d64c8; /* Base cell color */
|
| 422 |
-
color: #FFFFFF; /* Base text color for contrast */
|
| 423 |
-
}
|
| 424 |
-
/* Found letter cells */
|
| 425 |
-
.bw-cell.letter { background: #d7faff; color: #050057; }
|
| 426 |
-
/* Optional empty state if ever used */
|
| 427 |
-
.bw-cell.empty { background: #3a3a3a; color: #ffffff;}
|
| 428 |
-
/* Completed word cells */
|
| 429 |
-
.bw-cell.bw-cell-complete { background: #050057 !important; color: #d7faff !important; }
|
| 430 |
-
|
| 431 |
-
/* Free letter buttons */
|
| 432 |
-
.bw-free-letter-btn {
|
| 433 |
-
min-width: 60px !important;
|
| 434 |
-
height: 60px !important;
|
| 435 |
-
font-size: 1.5rem !important;
|
| 436 |
-
font-weight: 700 !important;
|
| 437 |
-
border-radius: 50% !important;
|
| 438 |
-
background: linear-gradient(135deg, #20d46c 0%, #1ca41c 100%) !important;
|
| 439 |
-
color: #ffffff !important;
|
| 440 |
-
border: 3px solid #1ca41c !important;
|
| 441 |
-
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2) !important;
|
| 442 |
-
transition: all 0.2s ease !important;
|
| 443 |
-
}
|
| 444 |
-
.bw-free-letter-btn:hover {
|
| 445 |
-
background: linear-gradient(135deg, #1ca41c 0%, #20d46c 100%) !important;
|
| 446 |
-
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.3) !important;
|
| 447 |
-
transform: translateY(-2px) !important;
|
| 448 |
-
}
|
| 449 |
-
.bw-free-letter-btn:disabled {
|
| 450 |
-
background: #888 !important;
|
| 451 |
-
border-color: #666 !important;
|
| 452 |
-
opacity: 0.5 !important;
|
| 453 |
-
cursor: not-allowed !important;
|
| 454 |
-
}
|
| 455 |
-
.bw-free-letter-container {
|
| 456 |
-
display: flex;
|
| 457 |
-
flex-direction: column;
|
| 458 |
-
align-items: center;
|
| 459 |
-
gap: 1rem;
|
| 460 |
-
padding: 1rem;
|
| 461 |
-
background: linear-gradient(135deg, rgba(29, 100, 200, 0.15), rgba(11, 42, 74, 0.15));
|
| 462 |
-
border-radius: 1rem;
|
| 463 |
-
border: 2px solid rgba(29, 100, 200, 0.3);
|
| 464 |
-
margin-bottom: 1rem;
|
| 465 |
-
}
|
| 466 |
-
|
| 467 |
-
.bw-free-letter-grid {
|
| 468 |
-
display: grid;
|
| 469 |
-
aspect-ratio: auto;
|
| 470 |
-
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
|
| 471 |
-
gap: 0.5rem;
|
| 472 |
-
width: 100%;
|
| 473 |
-
max-width: 400px;
|
| 474 |
-
z-index: 1000;
|
| 475 |
-
position: relative;
|
| 476 |
-
}
|
| 477 |
-
/* Apply bw-free-letter-btn styles to buttons inside bw-free-letter-grid */
|
| 478 |
-
.bw-free-letter-grid button,
|
| 479 |
-
.bw-free-letter-grid div[data-testid="stButton"] button {
|
| 480 |
-
min-width: 60px !important;
|
| 481 |
-
height: 60px !important;
|
| 482 |
-
font-size: 1.5rem !important;
|
| 483 |
-
font-weight: 700 !important;
|
| 484 |
-
border-radius: 50% !important;
|
| 485 |
-
background: linear-gradient(135deg, #20d46c 0%, #1ca41c 100%) !important;
|
| 486 |
-
color: #ffffff !important;
|
| 487 |
-
border: 3px solid #1ca41c !important;
|
| 488 |
-
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2) !important;
|
| 489 |
-
transition: all 0.2s ease !important;
|
| 490 |
-
aspect-ratio: unset !important;
|
| 491 |
-
}
|
| 492 |
-
.bw-free-letter-grid button:hover,
|
| 493 |
-
.bw-free-letter-grid div[data-testid="stButton"] button:hover {
|
| 494 |
-
background: linear-gradient(135deg, #1ca41c 0%, #20d46c 100%) !important;
|
| 495 |
-
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.3) !important;
|
| 496 |
-
transform: translateY(-2px) !important;
|
| 497 |
-
}
|
| 498 |
-
.bw-free-letter-grid button:disabled,
|
| 499 |
-
.bw-free-letter-grid div[data-testid="stButton"] button:disabled {
|
| 500 |
-
background: #888 !important;
|
| 501 |
-
border-color: #666 !important;
|
| 502 |
-
opacity: 0.5 !important;
|
| 503 |
-
cursor: not-allowed !important;
|
| 504 |
-
}
|
| 505 |
-
|
| 506 |
-
.bw-free-letter-title {
|
| 507 |
-
font-size: 1.1rem;
|
| 508 |
-
font-weight: 700;
|
| 509 |
-
color: #ffffff;
|
| 510 |
-
text-align: center;
|
| 511 |
-
margin: 0;
|
| 512 |
-
}
|
| 513 |
-
.bw-free-letter-status {
|
| 514 |
-
font-size: 0.9rem;
|
| 515 |
-
color: #d7faff;
|
| 516 |
-
text-align: center;
|
| 517 |
-
}
|
| 518 |
-
|
| 519 |
-
/* Final score style */
|
| 520 |
-
.bw-final-score { color: #1ca41c !important; font-weight: 800; }
|
| 521 |
-
.stExpander {z-index: 10;width: 50%;}
|
| 522 |
-
div[data-testid="stToastContainer"], div[data-testid="stToast"] {
|
| 523 |
-
margin: 0 auto;
|
| 524 |
-
}
|
| 525 |
-
|
| 526 |
-
/* Make grid buttons square and fill their column */
|
| 527 |
-
div[data-testid="stButton"]{
|
| 528 |
-
margin: 0 auto;
|
| 529 |
-
text-align: center;
|
| 530 |
-
}
|
| 531 |
-
div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; border-radius: 0; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 1.75rem;}
|
| 532 |
-
.st-key-new_game_btn, .st-key-sort_wordlist_btn, .st-key-filter_wordlist_btn { margin: 0 auto; aspect-ratio: unset; z-index:9999;}
|
| 533 |
-
.st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto;}
|
| 534 |
-
|
| 535 |
-
div[data-testid="column"], .st-emotion-cache-zh2fnc { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; }
|
| 536 |
-
.st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.25rem !important; min-height: 2.5rem; min-width: 2.5rem;}
|
| 537 |
-
|
| 538 |
-
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 539 |
-
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
|
| 540 |
-
.bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
|
| 541 |
-
.st-emotion-cache-1n6tfoc { background: linear-gradient(-45deg, #a1a1a1, #c0c0c0, #a1a1a1, #666666) !important; gap: 0.1rem !important; color: white; border-radius:15px; padding: 10px 10px 10px 10px; }
|
| 542 |
-
.st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
|
| 543 |
-
.st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
|
| 544 |
-
.st-key-guess_input [id^="text_input"] {max-width: 80px;}
|
| 545 |
-
.st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
|
| 546 |
-
aspect-ratio: auto !important;
|
| 547 |
-
position:relative;
|
| 548 |
-
z-index: 1200;
|
| 549 |
-
}
|
| 550 |
-
.username_input [id^="text_input"], .st-key-username_input [id^="text_input"] { color: #666;}
|
| 551 |
-
.st-emotion-cache-18kf3ut, .stColumn.st-emotion-cache-116javk {padding-bottom:3px;}
|
| 552 |
-
|
| 553 |
-
/* grid adjustments */
|
| 554 |
-
# @media (max-width: 705px){
|
| 555 |
-
# .bw-cell {
|
| 556 |
-
# min-height: 2.5rem;
|
| 557 |
-
# min-width: 1.75rem;
|
| 558 |
-
# }
|
| 559 |
-
# }
|
| 560 |
-
@media (min-width: 560px){
|
| 561 |
-
div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; min-height: 1.75rem; display: flex;}
|
| 562 |
-
.st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
|
| 563 |
-
.st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 16 / 11; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
|
| 564 |
-
/*.st-emotion-cache-1n6tfoc { aspect-ratio: 16 / 11; min-height: calc(100% + 20px) !important;}*/
|
| 565 |
-
.st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
|
| 566 |
-
.st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
|
| 567 |
-
aspect-ratio: auto !important;
|
| 568 |
-
position:relative;
|
| 569 |
-
z-index: 1200;
|
| 570 |
-
}
|
| 571 |
-
}
|
| 572 |
-
|
| 573 |
-
div[data-testid="stElementToolbarButtonContainer"], button[data-testid="stBaseButton-elementToolbar"], button[data-testid="stBaseButton-elementToolbar"]:hover {
|
| 574 |
-
display: none;
|
| 575 |
-
}
|
| 576 |
-
@media (max-width: 991px) and (min-width: 640px){
|
| 577 |
-
.st-emotion-cache-18kf3ut, .stColumn.st-emotion-cache-116javk {padding-bottom:0;}
|
| 578 |
-
}
|
| 579 |
-
/* Mobile styles */
|
| 580 |
-
@media (max-width: 640px) {
|
| 581 |
-
.bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:60px;}
|
| 582 |
-
#bw-main-anchor + div[data-testid="stHorizontalBlock"] { flex-direction: column-reverse !important; width: 100% !important; max-width: 100vw !important; }
|
| 583 |
-
#bw-main-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { width: 100% !important; min-width: 100% !important; max-width: 100% !important; flex: 1 1 100% !important; }
|
| 584 |
-
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 585 |
-
.st-emotion-cache-1tj828o { min-width: calc(8.33333% - 1rem); }
|
| 586 |
-
# .bw-free-letter-grid {
|
| 587 |
-
# grid-template-columns: repeat(auto-fit, minmax(50px, 1fr));
|
| 588 |
-
# }
|
| 589 |
-
.st-emotion-cache-1hxuzh3 {min-width: unset;}
|
| 590 |
-
}
|
| 591 |
-
|
| 592 |
-
.bold-text { font-weight: 700; }
|
| 593 |
-
.blue-background { background:#1d64c8; opacity:0.9; }
|
| 594 |
-
.metal-border { position: relative; padding: 20px; background: #333; color: white; border: 4px solid; border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1c1, #666666) 1; border-radius: 8px; }
|
| 595 |
-
.shiny-border { position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
|
| 596 |
-
.shiny-border::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transition: left 0.5s; }
|
| 597 |
-
.bw-score-panel-container { height: 100%; overflow: hidden; text-align:center;}
|
| 598 |
-
.bw-score-panel-container table tbody tr h3 {display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}
|
| 599 |
-
.shiny-border:hover::before { left: 100%; }
|
| 600 |
-
|
| 601 |
-
.bw-radio-group { display:flex; align-items:flex-start; gap: 10px; flex-flow: row; }
|
| 602 |
-
.bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;}
|
| 603 |
-
.bw-radio-circle { width: 45px; height: 45px; border-radius: 50%; border: 4px solid; background: rgba(255,255,255,0.06); display: grid; place-items: center; color:#fff; font-weight:700; }
|
| 604 |
-
.bw-radio-circle .dot { width: 14px; height: 14px; border-radius: 50%; background:#777; box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25); }
|
| 605 |
-
.bw-radio-circle.active.hit { background: linear-gradient(135deg, rgba(0,255,127,0.18), rgba(0,128,64,0.38)); }
|
| 606 |
-
.bw-radio-circle.active.hit .dot { background:#20d46c; box-shadow: 0 0 10px rgba(32,212,108,0.85); }
|
| 607 |
-
.bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
|
| 608 |
-
.bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
|
| 609 |
-
.bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
|
| 610 |
-
@media (max-width:1000px) and (min-width: 641px) {
|
| 611 |
-
.bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
|
| 612 |
-
.bw-radio-item {margin: 0 auto;}
|
| 613 |
-
}
|
| 614 |
-
@media (max-width:640px) {
|
| 615 |
-
.bw-radio-item { margin:unset;}
|
| 616 |
-
}
|
| 617 |
-
|
| 618 |
-
/* Make the sidebar scrollable */
|
| 619 |
-
section[data-testid="stSidebar"] {
|
| 620 |
-
display: none;
|
| 621 |
-
}
|
| 622 |
-
|
| 623 |
-
.st-emotion-cache-wp60of {
|
| 624 |
-
width: 720px;
|
| 625 |
-
position: absolute;
|
| 626 |
-
max-width:100%;
|
| 627 |
-
}
|
| 628 |
-
.stImage {max-width:300px;}
|
| 629 |
-
[id^="text_input"], .st-bb [id^="text_input"] {
|
| 630 |
-
background-color:#fff;
|
| 631 |
-
color:#000;
|
| 632 |
-
caret-color:#333;}
|
| 633 |
-
|
| 634 |
-
@media (min-width:720px) {
|
| 635 |
-
.st-emotion-cache-wp60of {
|
| 636 |
-
left: calc(calc(100% - 720px) / 2);
|
| 637 |
-
}
|
| 638 |
-
}
|
| 639 |
-
|
| 640 |
-
/* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
|
| 641 |
-
.bw-component-abs { position: fixed !important; inset: 0 !important; z-index: 99999 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; padding: 0 !important; }
|
| 642 |
-
/* Generic hide utility */
|
| 643 |
-
.hide { display: none !important; pointer-events: none !important; }
|
| 644 |
-
</style>
|
| 645 |
-
""",
|
| 646 |
-
unsafe_allow_html=True,
|
| 647 |
-
)
|
| 648 |
|
| 649 |
def _init_session() -> None:
|
| 650 |
if "initialized" in st.session_state and st.session_state.initialized:
|
| 651 |
return
|
| 652 |
|
| 653 |
# --- Load most recent settings from settings/settings.json ---
|
|
|
|
| 654 |
latest_settings = load_latest_settings()
|
|
|
|
| 655 |
if latest_settings:
|
| 656 |
# Apply all keys from settings file into session_state before any defaults
|
| 657 |
for key, value in latest_settings.items():
|
|
@@ -754,6 +224,7 @@ def _init_session() -> None:
|
|
| 754 |
# --- Add enable sound effects ---
|
| 755 |
if "enable_sound_effects" not in st.session_state:
|
| 756 |
st.session_state.enable_sound_effects = False
|
|
|
|
| 757 |
|
| 758 |
def _new_game() -> None:
|
| 759 |
"""
|
|
@@ -1127,7 +598,7 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
|
|
| 1127 |
"""
|
| 1128 |
<style>
|
| 1129 |
div[data-testid="column"] {
|
| 1130 |
-
padding: 0 !important;
|
| 1131 |
}
|
| 1132 |
button[data-testid="stButton"] {
|
| 1133 |
width: 60px !important;
|
|
@@ -1628,6 +1099,26 @@ def _render_score_panel(state: GameState):
|
|
| 1628 |
components.html(html_doc, height=height, scrolling=False)
|
| 1629 |
|
| 1630 |
def _game_over_content(state: GameState) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1631 |
# Play congratulations music (not sound effect) as background if enabled
|
| 1632 |
music_dir = _get_music_dir()
|
| 1633 |
congrats_music_path = os.path.join(music_dir, "congratulations.mp3")
|
|
@@ -1703,7 +1194,7 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1703 |
box-shadow: 0 0 32px #1d64c8;
|
| 1704 |
background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);
|
| 1705 |
color: #fff;
|
| 1706 |
-
padding:
|
| 1707 |
}
|
| 1708 |
.bw-dialog-header { display:flex; justify-content: space-between; align-items: center; }
|
| 1709 |
.bw-dialog-title, .st-emotion-cache-11elpad p { margin: 0; font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003);}
|
|
@@ -1728,7 +1219,10 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1728 |
}
|
| 1729 |
.st-bb {background-color: rgba(29, 100, 200, 0.5);}
|
| 1730 |
.st-key-generate_share_link div[data-testid="stButton"] button, .st-key-submit_leaderboard_only div[data-testid="stButton"] button { aspect-ratio: auto;}
|
| 1731 |
-
.st-key-generate_share_link div[data-testid="stButton"] button:hover {
|
|
|
|
|
|
|
|
|
|
| 1732 |
</style>
|
| 1733 |
""",
|
| 1734 |
unsafe_allow_html=True,
|
|
@@ -1787,7 +1281,7 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1787 |
box-shadow: 0 0 32px #1d64c8;
|
| 1788 |
background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);
|
| 1789 |
color: #fff;
|
| 1790 |
-
padding:
|
| 1791 |
}
|
| 1792 |
/* Improve inner text contrast inside the styled block */
|
| 1793 |
div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) h3,
|
|
@@ -1811,7 +1305,7 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1811 |
unsafe_allow_html=True,
|
| 1812 |
)
|
| 1813 |
|
| 1814 |
-
st.markdown("
|
| 1815 |
|
| 1816 |
# Check if this is a shared game being completed
|
| 1817 |
is_shared_game = st.session_state.get("loaded_game_sid") is not None
|
|
@@ -1822,16 +1316,19 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1822 |
st.session_state["player_username"] = ""
|
| 1823 |
|
| 1824 |
username = st.text_input(
|
| 1825 |
-
"Enter
|
| 1826 |
value=st.session_state.get("player_username", ""),
|
| 1827 |
key="username_input",
|
| 1828 |
-
placeholder="
|
| 1829 |
)
|
| 1830 |
if username:
|
| 1831 |
st.session_state["player_username"] = username
|
| 1832 |
|
| 1833 |
can_submit = bool(username and username.strip())
|
| 1834 |
|
|
|
|
|
|
|
|
|
|
| 1835 |
# Helper function to submit to leaderboards
|
| 1836 |
def _submit_to_leaderboards(username: str, score: int, time_secs: int, word_list: list, challenge_id: str = None):
|
| 1837 |
"""Submit score to daily and weekly leaderboards (with fallback verification).
|
|
@@ -1850,158 +1347,159 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1850 |
"may_overlap": getattr(state.puzzle, "may_overlap", False)
|
| 1851 |
}
|
| 1852 |
)
|
| 1853 |
-
|
| 1854 |
# Primary submission (attempt both daily and weekly)
|
| 1855 |
-
|
| 1856 |
-
|
| 1857 |
-
|
| 1858 |
-
|
| 1859 |
-
|
| 1860 |
-
|
| 1861 |
-
|
| 1862 |
-
|
| 1863 |
-
|
| 1864 |
-
|
| 1865 |
-
|
| 1866 |
-
|
| 1867 |
-
|
| 1868 |
-
|
| 1869 |
-
|
| 1870 |
-
|
| 1871 |
-
|
| 1872 |
-
|
| 1873 |
-
|
| 1874 |
-
|
| 1875 |
-
|
| 1876 |
-
|
| 1877 |
-
|
| 1878 |
-
|
| 1879 |
-
|
| 1880 |
-
|
| 1881 |
-
|
| 1882 |
-
|
| 1883 |
-
|
| 1884 |
-
|
| 1885 |
return results
|
| 1886 |
|
| 1887 |
|
| 1888 |
# Check if share URL already generated
|
| 1889 |
if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
|
| 1890 |
-
|
| 1891 |
|
| 1892 |
# Show separate "Submit to Leaderboard" button when NOT in a challenge
|
| 1893 |
if not is_shared_game and not st.session_state.get("leaderboard_submitted", False):
|
| 1894 |
-
|
| 1895 |
-
|
| 1896 |
-
|
| 1897 |
-
|
| 1898 |
-
|
| 1899 |
-
|
| 1900 |
-
|
| 1901 |
-
|
| 1902 |
-
|
| 1903 |
-
|
| 1904 |
-
|
| 1905 |
-
|
| 1906 |
-
|
| 1907 |
-
|
| 1908 |
-
|
| 1909 |
-
|
| 1910 |
-
|
| 1911 |
-
|
| 1912 |
-
|
| 1913 |
-
|
| 1914 |
-
|
| 1915 |
-
|
| 1916 |
-
|
| 1917 |
-
|
| 1918 |
-
|
| 1919 |
-
|
| 1920 |
-
|
| 1921 |
-
|
| 1922 |
|
| 1923 |
button_text = "📊 Submit Your Result" if is_shared_game else "🔗 Generate Share Link"
|
| 1924 |
|
| 1925 |
-
if
|
| 1926 |
-
st.
|
| 1927 |
-
|
| 1928 |
-
|
| 1929 |
-
|
| 1930 |
-
|
| 1931 |
-
|
| 1932 |
-
|
| 1933 |
-
|
| 1934 |
-
|
| 1935 |
-
|
| 1936 |
-
|
| 1937 |
-
|
| 1938 |
-
|
| 1939 |
-
|
| 1940 |
-
|
| 1941 |
-
|
| 1942 |
-
|
| 1943 |
-
|
| 1944 |
-
|
| 1945 |
-
)
|
| 1946 |
-
|
| 1947 |
-
if success:
|
| 1948 |
-
share_url = get_shareable_url(existing_sid)
|
| 1949 |
-
st.session_state["share_url"] = share_url
|
| 1950 |
-
st.session_state["share_sid"] = existing_sid
|
| 1951 |
-
st.session_state["show_challenge_share_links"] = True
|
| 1952 |
-
|
| 1953 |
-
# Also submit to daily/weekly leaderboards
|
| 1954 |
-
try:
|
| 1955 |
-
lb_results = _submit_to_leaderboards(
|
| 1956 |
-
username, state.score, elapsed_seconds, word_list, existing_sid
|
| 1957 |
)
|
| 1958 |
-
st.session_state["leaderboard_submitted"] = True
|
| 1959 |
-
st.session_state["leaderboard_results"] = lb_results
|
| 1960 |
-
except Exception as lb_err:
|
| 1961 |
-
st.warning(f"Challenge submitted but leaderboard update failed: {lb_err}")
|
| 1962 |
-
|
| 1963 |
-
st.success(f"✅ Result submitted for {username}!")
|
| 1964 |
-
st.rerun()
|
| 1965 |
-
else:
|
| 1966 |
-
st.error("Failed to submit result")
|
| 1967 |
-
else:
|
| 1968 |
-
# Create new game
|
| 1969 |
-
# Note: save_game_to_hf still uses grid_size for backward compatibility
|
| 1970 |
-
# For Wrdler 8x6 grid, we pass 6 (number of rows/words)
|
| 1971 |
-
challenge_id, full_url, sid = save_game_to_hf(
|
| 1972 |
-
word_list=word_list,
|
| 1973 |
-
username=username,
|
| 1974 |
-
score=state.score,
|
| 1975 |
-
time_seconds=elapsed_seconds,
|
| 1976 |
-
game_mode=state.game_mode,
|
| 1977 |
-
grid_size=6, # Wrdler: 6 rows (8 columns)
|
| 1978 |
-
spacer=spacer,
|
| 1979 |
-
may_overlap=may_overlap,
|
| 1980 |
-
wordlist_source=wordlist_source
|
| 1981 |
-
)
|
| 1982 |
|
| 1983 |
-
|
| 1984 |
-
|
| 1985 |
-
|
| 1986 |
-
|
| 1987 |
-
|
| 1988 |
-
|
| 1989 |
-
|
| 1990 |
-
|
| 1991 |
-
|
| 1992 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1993 |
)
|
| 1994 |
-
st.session_state["leaderboard_submitted"] = True
|
| 1995 |
-
st.session_state["leaderboard_results"] = lb_results
|
| 1996 |
-
except Exception as lb_err:
|
| 1997 |
-
st.warning(f"Challenge created but leaderboard update failed: {lb_err}")
|
| 1998 |
-
|
| 1999 |
-
st.rerun()
|
| 2000 |
-
else:
|
| 2001 |
-
st.error("Failed to generate short URL")
|
| 2002 |
|
| 2003 |
-
|
| 2004 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2005 |
else:
|
| 2006 |
# Conditionally display the generated share URL
|
| 2007 |
if st.session_state.get("show_challenge_share_links", False):
|
|
@@ -2187,112 +1685,20 @@ def _on_game_option_change() -> None:
|
|
| 2187 |
# Start a fresh game with updated options
|
| 2188 |
_new_game()
|
| 2189 |
|
| 2190 |
-
def _render_footer(current_page: str = "play"):
|
| 2191 |
-
"""Render footer with navigation links to leaderboards and main game.
|
| 2192 |
-
|
| 2193 |
-
Args:
|
| 2194 |
-
current_page: Which page is currently active ("play", "today", "daily", "weekly", "history", "settings")
|
| 2195 |
-
"""
|
| 2196 |
-
# Determine which link should be highlighted as active
|
| 2197 |
-
play_active = "active" if current_page == "play" else ""
|
| 2198 |
-
leaderboard_active = "active" if current_page in {"today", "daily", "weekly", "history"} else ""
|
| 2199 |
-
settings_active = "active" if current_page == "settings" else ""
|
| 2200 |
-
|
| 2201 |
-
# Check if we're in challenge mode and need to preserve game_id
|
| 2202 |
-
game_id = None
|
| 2203 |
-
try:
|
| 2204 |
-
params = st.query_params
|
| 2205 |
-
if "game_id" in params:
|
| 2206 |
-
game_id = params.get("game_id")
|
| 2207 |
-
except Exception:
|
| 2208 |
-
pass
|
| 2209 |
-
|
| 2210 |
-
# Also check session state for loaded challenge
|
| 2211 |
-
if not game_id and st.session_state.get("loaded_game_sid"):
|
| 2212 |
-
game_id = st.session_state.get("loaded_game_sid")
|
| 2213 |
-
|
| 2214 |
-
# Build URLs with game_id if in challenge mode
|
| 2215 |
-
if game_id:
|
| 2216 |
-
today_url = f"?page=today&game_id={game_id}"
|
| 2217 |
-
leaderboard_url = f"?page=today&game_id={game_id}"
|
| 2218 |
-
play_url = f"?game_id={game_id}"
|
| 2219 |
-
settings_url = f"?page=settings&game_id={game_id}"
|
| 2220 |
-
else:
|
| 2221 |
-
today_url = "?page=today"
|
| 2222 |
-
leaderboard_url = "?page=today"
|
| 2223 |
-
play_url = "/"
|
| 2224 |
-
settings_url = "?page=settings"
|
| 2225 |
-
|
| 2226 |
-
st.markdown(
|
| 2227 |
-
f"""
|
| 2228 |
-
<style>
|
| 2229 |
-
.bw-footer {{
|
| 2230 |
-
position: fixed;
|
| 2231 |
-
bottom: 0;
|
| 2232 |
-
left: 0;
|
| 2233 |
-
right: 0;
|
| 2234 |
-
background: linear-gradient(180deg, transparent 0%, rgba(11, 42, 74, 0.95) 30%, rgba(11, 42, 74, 0.98) 100%);
|
| 2235 |
-
padding: 0.75rem 1rem 0.5rem;
|
| 2236 |
-
z-index: 9998;
|
| 2237 |
-
text-align: center;
|
| 2238 |
-
}}
|
| 2239 |
-
.bw-footer-nav {{
|
| 2240 |
-
display: flex;
|
| 2241 |
-
justify-content: center;
|
| 2242 |
-
align-items: center;
|
| 2243 |
-
gap: 1rem;
|
| 2244 |
-
flex-wrap: wrap;
|
| 2245 |
-
}}
|
| 2246 |
-
.bw-footer-nav a {{
|
| 2247 |
-
color: #d7faff;
|
| 2248 |
-
text-decoration: none;
|
| 2249 |
-
font-weight: 600;
|
| 2250 |
-
font-size: 0.85rem;
|
| 2251 |
-
padding: 0.4rem 0.8rem;
|
| 2252 |
-
border-radius: 0.5rem;
|
| 2253 |
-
background: rgba(29, 100, 200, 0.3);
|
| 2254 |
-
border: 1px solid rgba(215, 250, 255, 0.3);
|
| 2255 |
-
transition: all 0.2s ease;
|
| 2256 |
-
}}
|
| 2257 |
-
.bw-footer-nav a:hover {{
|
| 2258 |
-
background: rgba(29, 100, 200, 0.6);
|
| 2259 |
-
border-color: rgba(215, 250, 255, 0.6);
|
| 2260 |
-
color: #ffffff;
|
| 2261 |
-
text-decoration: none;
|
| 2262 |
-
}}
|
| 2263 |
-
.bw-footer-nav a.active {{
|
| 2264 |
-
background: rgba(32, 212, 108, 0.3);
|
| 2265 |
-
border-color: rgba(32, 212, 108, 0.5);
|
| 2266 |
-
}}
|
| 2267 |
-
/* Add padding to main content to prevent footer overlap */
|
| 2268 |
-
.stMainBlockContainer {{
|
| 2269 |
-
padding-bottom: 70px !important;
|
| 2270 |
-
}}
|
| 2271 |
-
@media (max-width: 640px) {{
|
| 2272 |
-
.bw-footer-nav {{
|
| 2273 |
-
gap: 0.5rem;
|
| 2274 |
-
}}
|
| 2275 |
-
.bw-footer-nav a {{
|
| 2276 |
-
font-size: 0.75rem;
|
| 2277 |
-
padding: 0.35rem 0.6rem;
|
| 2278 |
-
}}
|
| 2279 |
-
}}
|
| 2280 |
-
</style>
|
| 2281 |
-
<div class="bw-footer">
|
| 2282 |
-
<nav class="bw-footer-nav">
|
| 2283 |
-
<a href="{leaderboard_url if not leaderboard_active else '#'}" title="View Leaderboards" target="_self" class="{leaderboard_active}">🏆 Leaderboard</a>
|
| 2284 |
-
<a href="{play_url if not play_active else '#'}" title="Play Wrdler" target="_self" class="{play_active}">🎮 Play</a>
|
| 2285 |
-
<a href="{settings_url if not settings_active else '#'}" title="Settings" target="_self" class="{settings_active}">⚙️ Settings</a>
|
| 2286 |
-
</nav>
|
| 2287 |
-
</div>
|
| 2288 |
-
""",
|
| 2289 |
-
unsafe_allow_html=True,
|
| 2290 |
-
)
|
| 2291 |
-
|
| 2292 |
def run_app():
|
| 2293 |
# Render PWA service worker registration (meta tags in <head> via Docker)
|
| 2294 |
st.markdown(pwa_service_worker, unsafe_allow_html=True)
|
| 2295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2296 |
# Handle query params using new API
|
| 2297 |
try:
|
| 2298 |
params = st.query_params
|
|
@@ -2316,56 +1722,83 @@ def run_app():
|
|
| 2316 |
page = params.get("page", "")
|
| 2317 |
|
| 2318 |
if page == "settings":
|
| 2319 |
-
st.
|
| 2320 |
-
|
| 2321 |
-
|
| 2322 |
-
|
|
|
|
|
|
|
| 2323 |
return
|
| 2324 |
|
| 2325 |
if page in {"today", "daily", "weekly", "history"}:
|
| 2326 |
from .leaderboard_page import render_leaderboard_page
|
| 2327 |
-
st.
|
| 2328 |
-
|
| 2329 |
-
|
| 2330 |
-
|
| 2331 |
-
|
|
|
|
|
|
|
| 2332 |
return
|
| 2333 |
|
| 2334 |
# Handle game_id for loading shared games
|
| 2335 |
if "game_id" in params and "shared_game_loaded" not in st.session_state:
|
| 2336 |
sid = params.get("game_id")
|
| 2337 |
-
|
| 2338 |
-
|
| 2339 |
-
|
| 2340 |
-
|
| 2341 |
-
|
| 2342 |
-
|
| 2343 |
-
|
| 2344 |
-
|
| 2345 |
-
|
| 2346 |
-
|
| 2347 |
-
|
| 2348 |
-
|
| 2349 |
-
|
| 2350 |
-
|
| 2351 |
-
|
| 2352 |
-
|
| 2353 |
-
|
| 2354 |
-
|
| 2355 |
-
|
| 2356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2357 |
else:
|
| 2358 |
-
st.
|
| 2359 |
-
|
| 2360 |
-
|
| 2361 |
-
st.
|
| 2362 |
-
|
| 2363 |
-
|
| 2364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2365 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2366 |
_init_session()
|
| 2367 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 2368 |
-
inject_ocean_layers
|
| 2369 |
_render_header()
|
| 2370 |
|
| 2371 |
state = _to_state()
|
|
@@ -2387,15 +1820,9 @@ def run_app():
|
|
| 2387 |
# New Game button moved above grid to prevent unnecessary rerenders on grid clicks
|
| 2388 |
# Using on_click callback ensures _new_game runs before widgets are instantiated
|
| 2389 |
st.button("New Game", width=125, on_click=_new_game, key="new_game_btn")
|
| 2390 |
-
|
| 2391 |
# Show free letter selection if enabled and not complete
|
| 2392 |
if st.session_state.get("enable_free_letters", False) and state.free_letters_used < 2:
|
| 2393 |
_render_free_letters(state)
|
| 2394 |
-
|
| 2395 |
-
_render_grid(state, st.session_state.letter_map, show_grid_ticks=st.session_state.get("show_grid_ticks", True))
|
| 2396 |
-
|
| 2397 |
-
# End condition (only show overlay if dismissed)
|
| 2398 |
-
state = _to_state()
|
| 2399 |
-
if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
|
| 2400 |
-
_render_game_over(state)
|
| 2401 |
-
_render_footer(current_page="play")
|
|
|
|
| 33 |
from .settings_page import render_settings_page
|
| 34 |
from .local_storage import load_latest_settings
|
| 35 |
|
| 36 |
+
from .ui_helpers import (
|
| 37 |
+
fig_to_pil_rgba,
|
| 38 |
+
pwa_service_worker,
|
| 39 |
+
ocean_background_css,
|
| 40 |
+
inject_ocean_layers,
|
| 41 |
+
inject_styles,
|
| 42 |
+
_render_footer,
|
| 43 |
+
show_spinner,
|
| 44 |
+
_get_effective_game_title
|
| 45 |
+
)
|
| 46 |
|
| 47 |
+
# --- Spinner context manager for custom spinner ---
|
| 48 |
+
class CustomSpinner:
|
| 49 |
+
"""Context manager for showing custom spinner with wrdler.gif"""
|
| 50 |
+
def __init__(self, placeholder, message: str = "Loading..."):
|
| 51 |
+
self.placeholder = placeholder
|
| 52 |
+
self.message = message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
def __enter__(self):
|
| 55 |
+
with self.placeholder.container():
|
| 56 |
+
show_spinner(self.message)
|
| 57 |
+
#wait 1 sec to ensure spinner is visible
|
| 58 |
+
time.sleep(0.2)
|
| 59 |
|
| 60 |
+
return self
|
| 61 |
+
|
| 62 |
+
def __exit__(self, *args):
|
| 63 |
+
self.placeholder.empty()
|
| 64 |
+
|
| 65 |
+
st.set_page_config(initial_sidebar_state="collapsed")
|
| 66 |
+
|
| 67 |
+
CoordLike = Tuple[int, int]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
|
| 70 |
def _apply_challenge_settings(settings: dict) -> None:
|
|
|
|
| 89 |
st.session_state.game_title = settings["game_title"]
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def _coord_to_xy(c) -> CoordLike:
|
| 93 |
# Supports dataclass Coord(x, y) or a 2-tuple/list.
|
| 94 |
if hasattr(c, "x") and hasattr(c, "y"):
|
|
|
|
| 113 |
letters[xy] = text[i]
|
| 114 |
return letters
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
def _init_session() -> None:
|
| 118 |
if "initialized" in st.session_state and st.session_state.initialized:
|
| 119 |
return
|
| 120 |
|
| 121 |
# --- Load most recent settings from settings/settings.json ---
|
| 122 |
+
|
| 123 |
latest_settings = load_latest_settings()
|
| 124 |
+
|
| 125 |
if latest_settings:
|
| 126 |
# Apply all keys from settings file into session_state before any defaults
|
| 127 |
for key, value in latest_settings.items():
|
|
|
|
| 224 |
# --- Add enable sound effects ---
|
| 225 |
if "enable_sound_effects" not in st.session_state:
|
| 226 |
st.session_state.enable_sound_effects = False
|
| 227 |
+
|
| 228 |
|
| 229 |
def _new_game() -> None:
|
| 230 |
"""
|
|
|
|
| 598 |
"""
|
| 599 |
<style>
|
| 600 |
div[data-testid="column"] {
|
| 601 |
+
padding: 0 !important;
|
| 602 |
}
|
| 603 |
button[data-testid="stButton"] {
|
| 604 |
width: 60px !important;
|
|
|
|
| 1099 |
components.html(html_doc, height=height, scrolling=False)
|
| 1100 |
|
| 1101 |
def _game_over_content(state: GameState) -> None:
|
| 1102 |
+
"""
|
| 1103 |
+
Handle game over UI and logic. Collect all current game settings at the top.
|
| 1104 |
+
"""
|
| 1105 |
+
# --- Collect all current game settings (must match settings/settings.json) ---
|
| 1106 |
+
current_settings = {
|
| 1107 |
+
"game_mode": st.session_state.get("game_mode", "classic"),
|
| 1108 |
+
"selected_wordlist": st.session_state.get("selected_wordlist", "classic.txt"),
|
| 1109 |
+
"spacer": st.session_state.get("spacer", 1),
|
| 1110 |
+
"may_overlap": st.session_state.get("may_overlap", False),
|
| 1111 |
+
"show_incorrect_guesses": st.session_state.get("show_incorrect_guesses", True),
|
| 1112 |
+
"enable_free_letters": st.session_state.get("enable_free_letters", False),
|
| 1113 |
+
"show_grid_ticks": st.session_state.get("show_grid_ticks", False),
|
| 1114 |
+
"show_challenge_share_links": st.session_state.get("show_challenge_share_links", True),
|
| 1115 |
+
"enable_sound_effects": st.session_state.get("enable_sound_effects", False),
|
| 1116 |
+
"music_enabled": st.session_state.get("music_enabled", False),
|
| 1117 |
+
"music_volume": st.session_state.get("music_volume", 15),
|
| 1118 |
+
"effects_volume": st.session_state.get("effects_volume", 25),
|
| 1119 |
+
"game_title": st.session_state.get("game_title", None),
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
# Play congratulations music (not sound effect) as background if enabled
|
| 1123 |
music_dir = _get_music_dir()
|
| 1124 |
congrats_music_path = os.path.join(music_dir, "congratulations.mp3")
|
|
|
|
| 1194 |
box-shadow: 0 0 32px #1d64c8;
|
| 1195 |
background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);
|
| 1196 |
color: #fff;
|
| 1197 |
+
padding: 6px;
|
| 1198 |
}
|
| 1199 |
.bw-dialog-header { display:flex; justify-content: space-between; align-items: center; }
|
| 1200 |
.bw-dialog-title, .st-emotion-cache-11elpad p { margin: 0; font-weight: bold;font-size: 1.5rem;text-align: center;flex: auto;filter:drop-shadow(1px 1px 2px #003);}
|
|
|
|
| 1219 |
}
|
| 1220 |
.st-bb {background-color: rgba(29, 100, 200, 0.5);}
|
| 1221 |
.st-key-generate_share_link div[data-testid="stButton"] button, .st-key-submit_leaderboard_only div[data-testid="stButton"] button { aspect-ratio: auto;}
|
| 1222 |
+
.st-key-generate_share_link div[data-testid="stButton"] button:hover, .st-key-submit_leaderboard_only div[data-testid="stButton"] button:hover {background-color: rgb(61, 157, 243);}
|
| 1223 |
+
.st-key-generate_share_link div[data-testid="stButton"] button:active, .st-key-submit_leaderboard_only div[data-testid="stButton"] button:active {background-color: rgb(61, 157, 243);}
|
| 1224 |
+
.st-key-generate_share_link div[data-testid="stButton"] button:focus-visible, .st-key-submit_leaderboard_only div[data-testid="stButton"] button:focus-visible {background-color: rgb(61, 157, 243);}
|
| 1225 |
+
.st-key-generate_share_link div[data-testid="stButton"] button:hover { color: rgb(61, 157, 243);}
|
| 1226 |
</style>
|
| 1227 |
""",
|
| 1228 |
unsafe_allow_html=True,
|
|
|
|
| 1281 |
box-shadow: 0 0 32px #1d64c8;
|
| 1282 |
background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);
|
| 1283 |
color: #fff;
|
| 1284 |
+
padding: 6px;
|
| 1285 |
}
|
| 1286 |
/* Improve inner text contrast inside the styled block */
|
| 1287 |
div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) h3,
|
|
|
|
| 1305 |
unsafe_allow_html=True,
|
| 1306 |
)
|
| 1307 |
|
| 1308 |
+
st.markdown("## 🎮 Join Leaderboard?")
|
| 1309 |
|
| 1310 |
# Check if this is a shared game being completed
|
| 1311 |
is_shared_game = st.session_state.get("loaded_game_sid") is not None
|
|
|
|
| 1316 |
st.session_state["player_username"] = ""
|
| 1317 |
|
| 1318 |
username = st.text_input(
|
| 1319 |
+
"Enter Username (required)",
|
| 1320 |
value=st.session_state.get("player_username", ""),
|
| 1321 |
key="username_input",
|
| 1322 |
+
placeholder="Username (Enter/Return/Tab to continue)"
|
| 1323 |
)
|
| 1324 |
if username:
|
| 1325 |
st.session_state["player_username"] = username
|
| 1326 |
|
| 1327 |
can_submit = bool(username and username.strip())
|
| 1328 |
|
| 1329 |
+
# Track which button was pressed to hide the other
|
| 1330 |
+
btn_flag = st.session_state.get("gameover_button_pressed", None)
|
| 1331 |
+
|
| 1332 |
# Helper function to submit to leaderboards
|
| 1333 |
def _submit_to_leaderboards(username: str, score: int, time_secs: int, word_list: list, challenge_id: str = None):
|
| 1334 |
"""Submit score to daily and weekly leaderboards (with fallback verification).
|
|
|
|
| 1347 |
"may_overlap": getattr(state.puzzle, "may_overlap", False)
|
| 1348 |
}
|
| 1349 |
)
|
|
|
|
| 1350 |
# Primary submission (attempt both daily and weekly)
|
| 1351 |
+
with st.spinner("Submitting to leaderboards..."):
|
| 1352 |
+
try:
|
| 1353 |
+
results = submit_score_to_all_leaderboards(
|
| 1354 |
+
username=username,
|
| 1355 |
+
score=score,
|
| 1356 |
+
time_seconds=time_secs,
|
| 1357 |
+
word_list=word_list,
|
| 1358 |
+
settings=settings,
|
| 1359 |
+
word_list_difficulty=difficulty_value,
|
| 1360 |
+
source_challenge_id=challenge_id,
|
| 1361 |
+
)
|
| 1362 |
+
except Exception:
|
| 1363 |
+
# If the unified submit fails, try a best-effort weekly submit below
|
| 1364 |
+
results = {"daily": {"qualified": False, "rank": None, "id": None}, "weekly": {"qualified": False, "rank": None, "id": None}}
|
| 1365 |
+
|
| 1366 |
+
# Check weekly result and attempt a fallback single-weekly submission if missing
|
| 1367 |
+
weekly_info = results.get("weekly") or {}
|
| 1368 |
+
if weekly_info.get("id") is None or (weekly_info.get("qualified") is False and weekly_info.get("rank") is None):
|
| 1369 |
+
try:
|
| 1370 |
+
weekly_id = get_current_weekly_id()
|
| 1371 |
+
# Build a lightweight UserEntry via submit_to_leaderboard (it returns (success, rank))
|
| 1372 |
+
submit_to_leaderboard(
|
| 1373 |
+
"weekly",
|
| 1374 |
+
weekly_id,
|
| 1375 |
+
None, # user_entry will be constructed inside submit_to_leaderboard if used directly; instead construct minimal entry below
|
| 1376 |
+
settings,
|
| 1377 |
+
)
|
| 1378 |
+
except Exception:
|
| 1379 |
+
# swallow fallback errors - main attempt already logged by leaderboard module
|
| 1380 |
+
pass
|
| 1381 |
return results
|
| 1382 |
|
| 1383 |
|
| 1384 |
# Check if share URL already generated
|
| 1385 |
if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
|
|
|
|
| 1386 |
|
| 1387 |
# Show separate "Submit to Leaderboard" button when NOT in a challenge
|
| 1388 |
if not is_shared_game and not st.session_state.get("leaderboard_submitted", False):
|
| 1389 |
+
if btn_flag != "generate_share_link":
|
| 1390 |
+
if st.button("🏆 Submit to Leaderboards", key="submit_leaderboard_only", width="stretch", disabled=not can_submit):
|
| 1391 |
+
st.session_state["gameover_button_pressed"] = "submit_leaderboard_only"
|
| 1392 |
+
try:
|
| 1393 |
+
word_list = [w.text for w in state.puzzle.words]
|
| 1394 |
+
lb_results = _submit_to_leaderboards(
|
| 1395 |
+
username, state.score, elapsed_seconds, word_list
|
| 1396 |
+
)
|
| 1397 |
+
st.session_state["leaderboard_submitted"] = True
|
| 1398 |
+
st.session_state["leaderboard_results"] = lb_results
|
| 1399 |
+
|
| 1400 |
+
# Show results
|
| 1401 |
+
daily_info = lb_results.get("daily", {})
|
| 1402 |
+
weekly_info = lb_results.get("weekly", {})
|
| 1403 |
+
|
| 1404 |
+
if daily_info.get("qualified") or weekly_info.get("qualified"):
|
| 1405 |
+
msg_parts = []
|
| 1406 |
+
if daily_info.get("qualified"):
|
| 1407 |
+
msg_parts.append(f"Daily #{daily_info['rank']}")
|
| 1408 |
+
if weekly_info.get("qualified"):
|
| 1409 |
+
msg_parts.append(f"Weekly #{weekly_info['rank']}")
|
| 1410 |
+
st.success(f"✅ Submitted! Ranked: {', '.join(msg_parts)}")
|
| 1411 |
+
else:
|
| 1412 |
+
st.info("✅ Score submitted (not in top 20)")
|
| 1413 |
+
st.rerun()
|
| 1414 |
+
except Exception as e:
|
| 1415 |
+
st.error(f"Failed to submit to leaderboards: {e}")
|
|
|
|
| 1416 |
|
| 1417 |
button_text = "📊 Submit Your Result" if is_shared_game else "🔗 Generate Share Link"
|
| 1418 |
|
| 1419 |
+
if btn_flag != "submit_leaderboard_only":
|
| 1420 |
+
if st.button(button_text, key="generate_share_link", width="stretch", disabled=not can_submit):
|
| 1421 |
+
with st.spinner("Saving result..."):
|
| 1422 |
+
try:
|
| 1423 |
+
st.session_state["gameover_button_pressed"] = "generate_share_link"
|
| 1424 |
+
st.markdown("---")
|
| 1425 |
+
# Extract game data
|
| 1426 |
+
word_list = [w.text for w in state.puzzle.words]
|
| 1427 |
+
spacer = state.puzzle.spacer
|
| 1428 |
+
may_overlap = state.puzzle.may_overlap
|
| 1429 |
+
wordlist_source = st.session_state.get("selected_wordlist", "classic.txt")
|
| 1430 |
+
|
| 1431 |
+
if is_shared_game and existing_sid:
|
| 1432 |
+
# Add result to existing game
|
| 1433 |
+
success = add_user_result_to_game(
|
| 1434 |
+
sid=existing_sid,
|
| 1435 |
+
username=username,
|
| 1436 |
+
word_list=word_list, # Each user gets different words
|
| 1437 |
+
score=state.score,
|
| 1438 |
+
time_seconds=elapsed_seconds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1439 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1440 |
|
| 1441 |
+
if success:
|
| 1442 |
+
share_url = get_shareable_url(existing_sid)
|
| 1443 |
+
st.session_state["share_url"] = share_url
|
| 1444 |
+
st.session_state["share_sid"] = existing_sid
|
| 1445 |
+
st.session_state["show_challenge_share_links"] = True
|
| 1446 |
+
|
| 1447 |
+
# Also submit to daily/weekly leaderboards
|
| 1448 |
+
try:
|
| 1449 |
+
lb_results = _submit_to_leaderboards(
|
| 1450 |
+
username, state.score, elapsed_seconds, word_list, existing_sid
|
| 1451 |
+
)
|
| 1452 |
+
st.session_state["leaderboard_submitted"] = True
|
| 1453 |
+
st.session_state["leaderboard_results"] = lb_results
|
| 1454 |
+
except Exception as lb_err:
|
| 1455 |
+
st.warning(f"Challenge submitted but leaderboard update failed: {lb_err}")
|
| 1456 |
+
|
| 1457 |
+
|
| 1458 |
+
st.success(f"✅ Result submitted for {username}!")
|
| 1459 |
+
st.rerun()
|
| 1460 |
+
else:
|
| 1461 |
+
st.error("Failed to submit result")
|
| 1462 |
+
else:
|
| 1463 |
+
# Create new game
|
| 1464 |
+
# Note: save_game_to_hf still uses grid_size for backward compatibility
|
| 1465 |
+
# For Wrdler 8x6 grid, we pass 6 (number of rows/words)
|
| 1466 |
+
challenge_id, full_url, sid = save_game_to_hf(
|
| 1467 |
+
word_list=word_list,
|
| 1468 |
+
username=username,
|
| 1469 |
+
score=state.score,
|
| 1470 |
+
time_seconds=elapsed_seconds,
|
| 1471 |
+
game_mode=current_settings["game_mode"],
|
| 1472 |
+
grid_size=6, # Wrdler: 6 rows (8 columns)
|
| 1473 |
+
spacer=current_settings["spacer"],
|
| 1474 |
+
may_overlap=current_settings["may_overlap"],
|
| 1475 |
+
wordlist_source=current_settings["selected_wordlist"],
|
| 1476 |
+
game_title=current_settings["game_title"],
|
| 1477 |
+
show_incorrect_guesses=current_settings["show_incorrect_guesses"],
|
| 1478 |
+
enable_free_letters=current_settings["enable_free_letters"]
|
| 1479 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1480 |
|
| 1481 |
+
if sid:
|
| 1482 |
+
share_url = get_shareable_url(sid)
|
| 1483 |
+
st.session_state["share_url"] = share_url
|
| 1484 |
+
st.session_state["share_sid"] = sid
|
| 1485 |
+
st.session_state["show_challenge_share_links"] = True
|
| 1486 |
+
|
| 1487 |
+
# Also submit to daily/weekly leaderboards
|
| 1488 |
+
try:
|
| 1489 |
+
lb_results = _submit_to_leaderboards(
|
| 1490 |
+
username, state.score, elapsed_seconds, word_list, sid
|
| 1491 |
+
)
|
| 1492 |
+
st.session_state["leaderboard_submitted"] = True
|
| 1493 |
+
st.session_state["leaderboard_results"] = lb_results
|
| 1494 |
+
except Exception as lb_err:
|
| 1495 |
+
st.warning(f"Challenge created but leaderboard update failed: {lb_err}")
|
| 1496 |
+
|
| 1497 |
+
st.rerun()
|
| 1498 |
+
else:
|
| 1499 |
+
st.error("Failed to generate short URL")
|
| 1500 |
+
|
| 1501 |
+
except Exception as e:
|
| 1502 |
+
st.error(f"Failed to save game: {e}")
|
| 1503 |
else:
|
| 1504 |
# Conditionally display the generated share URL
|
| 1505 |
if st.session_state.get("show_challenge_share_links", False):
|
|
|
|
| 1685 |
# Start a fresh game with updated options
|
| 1686 |
_new_game()
|
| 1687 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1688 |
def run_app():
|
| 1689 |
# Render PWA service worker registration (meta tags in <head> via Docker)
|
| 1690 |
st.markdown(pwa_service_worker, unsafe_allow_html=True)
|
| 1691 |
|
| 1692 |
+
# --- Game Init/Load Spinner (Two-phase approach) ---
|
| 1693 |
+
|
| 1694 |
+
# Show spinner during new game creation
|
| 1695 |
+
if st.session_state.get("needs_new_game", False):
|
| 1696 |
+
spinner_placeholder = st.empty()
|
| 1697 |
+
with CustomSpinner(spinner_placeholder, "Creating new game..."):
|
| 1698 |
+
st.session_state.needs_new_game = False
|
| 1699 |
+
st.rerun()
|
| 1700 |
+
return
|
| 1701 |
+
|
| 1702 |
# Handle query params using new API
|
| 1703 |
try:
|
| 1704 |
params = st.query_params
|
|
|
|
| 1722 |
page = params.get("page", "")
|
| 1723 |
|
| 1724 |
if page == "settings":
|
| 1725 |
+
spinner_placeholder = st.empty()
|
| 1726 |
+
with CustomSpinner(spinner_placeholder, "Loading Settings..."):
|
| 1727 |
+
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1728 |
+
inject_ocean_layers()
|
| 1729 |
+
render_settings_page(_on_game_option_change)
|
| 1730 |
+
_render_footer(current_page="settings")
|
| 1731 |
return
|
| 1732 |
|
| 1733 |
if page in {"today", "daily", "weekly", "history"}:
|
| 1734 |
from .leaderboard_page import render_leaderboard_page
|
| 1735 |
+
spinner_placeholder = st.empty()
|
| 1736 |
+
with CustomSpinner(spinner_placeholder, "Loading Leaderboard..."):
|
| 1737 |
+
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1738 |
+
inject_ocean_layers()
|
| 1739 |
+
default = page if page in {"daily", "weekly", "history"} else "today"
|
| 1740 |
+
render_leaderboard_page(default_tab=default)
|
| 1741 |
+
_render_footer(current_page=page if page else "play")
|
| 1742 |
return
|
| 1743 |
|
| 1744 |
# Handle game_id for loading shared games
|
| 1745 |
if "game_id" in params and "shared_game_loaded" not in st.session_state:
|
| 1746 |
sid = params.get("game_id")
|
| 1747 |
+
spinner_placeholder = st.empty()
|
| 1748 |
+
with CustomSpinner(spinner_placeholder, "Loading challenge..."):
|
| 1749 |
+
try:
|
| 1750 |
+
settings = load_game_from_sid(sid)
|
| 1751 |
+
if settings:
|
| 1752 |
+
# Store loaded settings and sid for initialization
|
| 1753 |
+
st.session_state["shared_game_settings"] = settings
|
| 1754 |
+
st.session_state["loaded_game_sid"] = sid # Store sid for adding results later
|
| 1755 |
+
st.session_state["shared_game_loaded"] = True
|
| 1756 |
+
|
| 1757 |
+
# Apply challenge-specific settings (show_incorrect_guesses, enable_free_letters, game_title)
|
| 1758 |
+
_apply_challenge_settings(settings)
|
| 1759 |
+
|
| 1760 |
+
# Get best score and time from users array
|
| 1761 |
+
users = settings.get("users", [])
|
| 1762 |
+
if users:
|
| 1763 |
+
best_score = max(u["score"] for u in users)
|
| 1764 |
+
best_time = min(u["time"] for u in users)
|
| 1765 |
+
st.toast(
|
| 1766 |
+
f"🎯 Loading shared challenge (Best: {best_score} pts in {best_time}s by {len(users)} player(s))",
|
| 1767 |
+
icon="🎯"
|
| 1768 |
+
)
|
| 1769 |
+
else:
|
| 1770 |
+
st.toast("🎯 Loading shared challenge", icon="🎯")
|
| 1771 |
else:
|
| 1772 |
+
st.warning(f"No shared game found for ID: {sid}. Starting a normal game.")
|
| 1773 |
+
st.session_state["shared_game_loaded"] = True # Prevent repeated attempts
|
| 1774 |
+
except Exception as e:
|
| 1775 |
+
st.error(f"❌ Error loading shared game: {e}")
|
| 1776 |
+
st.session_state["shared_game_loaded"] = True # Prevent repeated attempts
|
| 1777 |
+
|
| 1778 |
+
# Show spinner during game initialization
|
| 1779 |
+
if st.session_state.get("needs_initialization", True):
|
| 1780 |
+
spinner_placeholder = st.empty()
|
| 1781 |
+
with CustomSpinner(spinner_placeholder, "Initializing Game..."):
|
| 1782 |
+
st.session_state.needs_initialization = False
|
| 1783 |
+
_render_game_tab()
|
| 1784 |
+
st.rerun()
|
| 1785 |
+
return
|
| 1786 |
+
else:
|
| 1787 |
+
_render_game_tab()
|
| 1788 |
|
| 1789 |
+
# End condition (only show overlay if dismissed)
|
| 1790 |
+
state = _to_state()
|
| 1791 |
+
if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
|
| 1792 |
+
_render_game_over(state)
|
| 1793 |
+
_render_footer(current_page="play")
|
| 1794 |
+
|
| 1795 |
+
def _render_game_tab():
|
| 1796 |
+
"""
|
| 1797 |
+
Render the main game tab layout.
|
| 1798 |
+
"""
|
| 1799 |
_init_session()
|
| 1800 |
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 1801 |
+
inject_ocean_layers # <-- add the animated layers
|
| 1802 |
_render_header()
|
| 1803 |
|
| 1804 |
state = _to_state()
|
|
|
|
| 1820 |
# New Game button moved above grid to prevent unnecessary rerenders on grid clicks
|
| 1821 |
# Using on_click callback ensures _new_game runs before widgets are instantiated
|
| 1822 |
st.button("New Game", width=125, on_click=_new_game, key="new_game_btn")
|
| 1823 |
+
|
| 1824 |
# Show free letter selection if enabled and not complete
|
| 1825 |
if st.session_state.get("enable_free_letters", False) and state.free_letters_used < 2:
|
| 1826 |
_render_free_letters(state)
|
| 1827 |
+
|
| 1828 |
+
_render_grid(state, st.session_state.letter_map, show_grid_ticks=st.session_state.get("show_grid_ticks", True))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
wrdler/ui_helpers.py
ADDED
|
@@ -0,0 +1,678 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import numpy as np
|
| 3 |
+
from PIL import Image
|
| 4 |
+
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
|
| 5 |
+
from . import __version__ as version
|
| 6 |
+
from .modules.constants import APP_SETTINGS
|
| 7 |
+
import base64
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
# --- Utility: Convert Matplotlib Figure to PIL RGBA Image ---
|
| 11 |
+
def fig_to_pil_rgba(fig):
|
| 12 |
+
canvas = FigureCanvas(fig)
|
| 13 |
+
canvas.draw()
|
| 14 |
+
w, h = fig.canvas.get_width_height()
|
| 15 |
+
img = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
|
| 16 |
+
return Image.fromarray(img, mode="RGBA")
|
| 17 |
+
|
| 18 |
+
def _get_effective_game_title() -> str:
|
| 19 |
+
"""
|
| 20 |
+
Get the effective game title, prioritizing:
|
| 21 |
+
1. Challenge-specific game_title from shared_game_settings
|
| 22 |
+
2. Session state game_title (if set)
|
| 23 |
+
3. APP_SETTINGS default
|
| 24 |
+
4. Fallback to "Wrdler"
|
| 25 |
+
"""
|
| 26 |
+
# First check shared game settings (challenge mode)
|
| 27 |
+
shared_settings = st.session_state.get("shared_game_settings")
|
| 28 |
+
if shared_settings and shared_settings.get("game_title"):
|
| 29 |
+
return shared_settings["game_title"]
|
| 30 |
+
|
| 31 |
+
# Then check session state
|
| 32 |
+
if st.session_state.get("game_title"):
|
| 33 |
+
return st.session_state["game_title"]
|
| 34 |
+
|
| 35 |
+
# Fall back to APP_SETTINGS
|
| 36 |
+
return APP_SETTINGS.get("game_title", "Wrdler")
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# --- PWA Service Worker JS (for Streamlit head injection) ---
|
| 40 |
+
pwa_service_worker = """
|
| 41 |
+
<script>
|
| 42 |
+
// Register service worker for offline functionality
|
| 43 |
+
// Note: Using inline Blob URL to bypass Streamlit's text/plain content-type for .js files
|
| 44 |
+
if ('serviceWorker' in navigator) {
|
| 45 |
+
window.addEventListener('load', () => {
|
| 46 |
+
// Service worker code as string (inline to avoid MIME type issues)
|
| 47 |
+
const swCode = `
|
| 48 |
+
const CACHE_NAME = 'wrdler-v0.0.1';
|
| 49 |
+
const RUNTIME_CACHE = 'wrdler-runtime';
|
| 50 |
+
|
| 51 |
+
const PRECACHE_URLS = [
|
| 52 |
+
'/',
|
| 53 |
+
'/app/static/manifest.json',
|
| 54 |
+
'/app/static/icon-192.png',
|
| 55 |
+
'/app/static/icon-512.png'
|
| 56 |
+
];
|
| 57 |
+
|
| 58 |
+
self.addEventListener('install', event => {
|
| 59 |
+
console.log('[ServiceWorker] Installing...');
|
| 60 |
+
event.waitUntil(
|
| 61 |
+
caches.open(CACHE_NAME)
|
| 62 |
+
.then(cache => {
|
| 63 |
+
console.log('[ServiceWorker] Precaching app shell');
|
| 64 |
+
return cache.addAll(PRECACHE_URLS);
|
| 65 |
+
})
|
| 66 |
+
.then(() => self.skipWaiting())
|
| 67 |
+
);
|
| 68 |
+
});
|
| 69 |
+
|
| 70 |
+
self.addEventListener('activate', event => {
|
| 71 |
+
console.log('[ServiceWorker] Activating...');
|
| 72 |
+
event.waitUntil(
|
| 73 |
+
caches.keys().then(cacheNames => {
|
| 74 |
+
return Promise.all(
|
| 75 |
+
cacheNames.map(cacheName => {
|
| 76 |
+
if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
|
| 77 |
+
console.log('[ServiceWorker] Deleting old cache:', cacheName);
|
| 78 |
+
return caches.delete(cacheName);
|
| 79 |
+
}
|
| 80 |
+
})
|
| 81 |
+
);
|
| 82 |
+
}).then(() => self.clients.claim())
|
| 83 |
+
);
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
self.addEventListener('fetch', event => {
|
| 87 |
+
if (event.request.method !== 'GET') return;
|
| 88 |
+
if (!event.request.url.startsWith('http')) return;
|
| 89 |
+
|
| 90 |
+
event.respondWith(
|
| 91 |
+
caches.open(RUNTIME_CACHE).then(cache => {
|
| 92 |
+
return fetch(event.request)
|
| 93 |
+
.then(response => {
|
| 94 |
+
if (response.status === 200) {
|
| 95 |
+
cache.put(event.request, response.clone());
|
| 96 |
+
}
|
| 97 |
+
return response;
|
| 98 |
+
})
|
| 99 |
+
.catch(() => {
|
| 100 |
+
return caches.match(event.request).then(cachedResponse => {
|
| 101 |
+
if (cachedResponse) {
|
| 102 |
+
console.log('[ServiceWorker] Serving from cache:', event.request.url);
|
| 103 |
+
return cachedResponse;
|
| 104 |
+
}
|
| 105 |
+
return new Response('Offline - Please check your connection', {
|
| 106 |
+
status: 503,
|
| 107 |
+
statusText: 'Service Unavailable',
|
| 108 |
+
headers: new Headers({'Content-Type': 'text/plain'})
|
| 109 |
+
});
|
| 110 |
+
});
|
| 111 |
+
});
|
| 112 |
+
})
|
| 113 |
+
);
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
self.addEventListener('message', event => {
|
| 117 |
+
if (event.data.action === 'skipWaiting') {
|
| 118 |
+
self.skipWaiting();
|
| 119 |
+
}
|
| 120 |
+
});
|
| 121 |
+
`;
|
| 122 |
+
|
| 123 |
+
// Create Blob URL for service worker
|
| 124 |
+
const blob = new Blob([swCode], { type: 'application/javascript' });
|
| 125 |
+
const swUrl = URL.createObjectURL(blob);
|
| 126 |
+
|
| 127 |
+
navigator.serviceWorker.register(swUrl)
|
| 128 |
+
.then(registration => {
|
| 129 |
+
console.log('[PWA] Service Worker registered successfully:', registration.scope);
|
| 130 |
+
|
| 131 |
+
registration.addEventListener('updatefound', () => {
|
| 132 |
+
const newWorker = registration.installing;
|
| 133 |
+
newWorker.addEventListener('statechange', () => {
|
| 134 |
+
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
| 135 |
+
console.log('[PWA] New version available! Refresh to update.');
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
});
|
| 139 |
+
})
|
| 140 |
+
.catch(error => {
|
| 141 |
+
console.log('[PWA] Service Worker registration failed:', error);
|
| 142 |
+
});
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// Prompt user to install PWA (for browsers that support it)
|
| 147 |
+
let deferredPrompt;
|
| 148 |
+
window.addEventListener('beforeinstallprompt', (e) => {
|
| 149 |
+
console.log('[PWA] Install prompt available');
|
| 150 |
+
e.preventDefault();
|
| 151 |
+
deferredPrompt = e;
|
| 152 |
+
// Could show custom install button here if desired
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
// Track when user installs the app
|
| 156 |
+
window.addEventListener('appinstalled', () => {
|
| 157 |
+
console.log('[PWA] Wrdler installed successfully!');
|
| 158 |
+
deferredPrompt = null;
|
| 159 |
+
});
|
| 160 |
+
</script>
|
| 161 |
+
"""
|
| 162 |
+
|
| 163 |
+
# --- Ocean Background CSS ---
|
| 164 |
+
ocean_background_css = """
|
| 165 |
+
<style>
|
| 166 |
+
:root {
|
| 167 |
+
--water-deep: #0b2a4a;
|
| 168 |
+
--water-mid: #0f3968;
|
| 169 |
+
--water-lite: #165ba8;
|
| 170 |
+
--water-sky: #1d64c8;
|
| 171 |
+
--foam: rgba(255,255,255,0.18);
|
| 172 |
+
}
|
| 173 |
+
.stAppHeader{ opacity:0.6; }
|
| 174 |
+
.stApp {
|
| 175 |
+
margin: 0;
|
| 176 |
+
min-height: 100vh;
|
| 177 |
+
overflow: hidden;
|
| 178 |
+
background-attachment: scroll;
|
| 179 |
+
position: relative;
|
| 180 |
+
background-image:
|
| 181 |
+
linear-gradient(180deg, var(--water-sky) 0%, var(--water-lite) 35%, var(--water-mid) 70%, var(--water-deep) 100%);
|
| 182 |
+
background-size: 100% 100%;
|
| 183 |
+
background-position: 50% 50%;
|
| 184 |
+
animation: none !important;
|
| 185 |
+
will-change: background-position;
|
| 186 |
+
}
|
| 187 |
+
.stApp::before {
|
| 188 |
+
content: "";
|
| 189 |
+
position: absolute;
|
| 190 |
+
inset: -10% -10%;
|
| 191 |
+
z-index: 0;
|
| 192 |
+
pointer-events: none;
|
| 193 |
+
background:
|
| 194 |
+
repeating-linear-gradient(0deg, rgba(255,255,255,0.10) 0 2px, transparent 2px 22px),
|
| 195 |
+
repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0 1px, transparent 1px 18px);
|
| 196 |
+
mix-blend-mode: screen;
|
| 197 |
+
opacity: 0.10;
|
| 198 |
+
}
|
| 199 |
+
</style>
|
| 200 |
+
"""
|
| 201 |
+
|
| 202 |
+
# --- Ocean Layers Injection ---
|
| 203 |
+
def inject_ocean_layers() -> None:
|
| 204 |
+
st.markdown(
|
| 205 |
+
"""
|
| 206 |
+
<style>
|
| 207 |
+
.bw-bg-container {
|
| 208 |
+
position: fixed;
|
| 209 |
+
inset: 0;
|
| 210 |
+
z-index: 1;
|
| 211 |
+
pointer-events: none;
|
| 212 |
+
overflow: hidden;
|
| 213 |
+
}
|
| 214 |
+
.bw-bg-layer {
|
| 215 |
+
position: absolute;
|
| 216 |
+
inset: 0;
|
| 217 |
+
width: 100vw;
|
| 218 |
+
height: 100vh;
|
| 219 |
+
pointer-events: none;
|
| 220 |
+
}
|
| 221 |
+
.bw-bg-highlight {
|
| 222 |
+
z-index: 11;
|
| 223 |
+
background: radial-gradient(150% 100% at 50% -20%, rgba(255,255,255,0.10) 0%, transparent 60%);
|
| 224 |
+
background-size: 150% 150%;
|
| 225 |
+
}
|
| 226 |
+
.bw-bg-long {
|
| 227 |
+
z-index: 12;
|
| 228 |
+
background: repeating-linear-gradient(-6deg, rgba(255,255,255,0.08) 0px, rgba(255,255,255,0.08) 18px, rgba(0,0,0,0.04) 18px, rgba(0,0,0,0.04) 48px);
|
| 229 |
+
background-size: 150% 150%;
|
| 230 |
+
opacity: 0.2;
|
| 231 |
+
}
|
| 232 |
+
.bw-bg-mid {
|
| 233 |
+
z-index: 13;
|
| 234 |
+
background: repeating-linear-gradient(-12deg, rgba(255,255,255,0.10) 0px, rgba(255,255,255,0.10) 10px, rgba(0,0,0,0.05) 10px, rgba(0,0,0,0.05) 26px);
|
| 235 |
+
background-size: 150% 150%;
|
| 236 |
+
opacity: 0.2;
|
| 237 |
+
}
|
| 238 |
+
.bw-bg-fine {
|
| 239 |
+
z-index: 14;
|
| 240 |
+
background: repeating-linear-gradient(-18deg, var(--foam) 0px, var(--foam) 4px, transparent 4px, transparent 12px);
|
| 241 |
+
background-size: 120% 120%;
|
| 242 |
+
opacity: 0.15;
|
| 243 |
+
}
|
| 244 |
+
</style>
|
| 245 |
+
<div class="bw-bg-container">
|
| 246 |
+
<div class="bw-bg-layer bw-bg-highlight"></div>
|
| 247 |
+
<div class="bw-bg-layer bw-bg-long"></div>
|
| 248 |
+
<div class="bw-bg-layer bw-bg-mid"></div>
|
| 249 |
+
<div class="bw-bg-layer bw-bg-fine"></div>
|
| 250 |
+
</div>
|
| 251 |
+
""",
|
| 252 |
+
unsafe_allow_html=True,
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
# --- General UI Styles Injection ---
|
| 256 |
+
def inject_styles() -> None:
|
| 257 |
+
st.markdown(
|
| 258 |
+
"""
|
| 259 |
+
<style>
|
| 260 |
+
/* Center main content and limit width */
|
| 261 |
+
# .stApp, body {
|
| 262 |
+
# background: rgba(29, 100, 200, 0.5);
|
| 263 |
+
# }
|
| 264 |
+
.stMainBlockContainer {
|
| 265 |
+
max-width: 1100px;
|
| 266 |
+
transition-timing-function: step-end;
|
| 267 |
+
transition-timing-function: steps(1, end);
|
| 268 |
+
}
|
| 269 |
+
.stHeading {
|
| 270 |
+
margin-bottom: -1.5rem !important;
|
| 271 |
+
margin-top: -1.5rem !important;
|
| 272 |
+
# font-size: 1.75rem !important; /* Title */
|
| 273 |
+
line-height: 1.1 !important;
|
| 274 |
+
}
|
| 275 |
+
.stHeading h3 {font-size: 1.35rem;}
|
| 276 |
+
/* Base grid cell visuals */
|
| 277 |
+
.bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; min-height: 48px;}
|
| 278 |
+
.bw-cell {
|
| 279 |
+
width: 100%;
|
| 280 |
+
gap: 0.1rem;
|
| 281 |
+
aspect-ratio: 16 / 11;
|
| 282 |
+
line-height: 1.6;
|
| 283 |
+
display: flex;
|
| 284 |
+
align-items: center;
|
| 285 |
+
justify-content: center;
|
| 286 |
+
border: 1px solid #1d64c8;
|
| 287 |
+
border-radius: 0;
|
| 288 |
+
font-weight: 700;
|
| 289 |
+
user-select: none;
|
| 290 |
+
padding: 0.5rem 0.75rem;
|
| 291 |
+
font-size: 1.4rem;
|
| 292 |
+
min-height: 1.75rem;
|
| 293 |
+
min-width: 1.25em;
|
| 294 |
+
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
| 295 |
+
background: #1d64c8; /* Base cell color */
|
| 296 |
+
color: #FFFFFF; /* Base text color for contrast */
|
| 297 |
+
}
|
| 298 |
+
/* Found letter cells */
|
| 299 |
+
.bw-cell.letter { background: #d7faff; color: #050057; }
|
| 300 |
+
/* Optional empty state if ever used */
|
| 301 |
+
.bw-cell.empty { background: #3a3a3a; color: #ffffff;}
|
| 302 |
+
/* Completed word cells */
|
| 303 |
+
.bw-cell.bw-cell-complete { background: #050057 !important; color: #d7faff !important; }
|
| 304 |
+
|
| 305 |
+
/* Free letter buttons */
|
| 306 |
+
.bw-free-letter-btn {
|
| 307 |
+
min-width: 60px !important;
|
| 308 |
+
height: 60px !important;
|
| 309 |
+
font-size: 1.5rem !important;
|
| 310 |
+
font-weight: 700 !important;
|
| 311 |
+
border-radius: 50% !important;
|
| 312 |
+
background: linear-gradient(135deg, #20d46c 0%, #1ca41c 100%) !important;
|
| 313 |
+
color: #ffffff !important;
|
| 314 |
+
border: 3px solid #1ca41c !important;
|
| 315 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2) !important;
|
| 316 |
+
transition: all 0.2s ease !important;
|
| 317 |
+
}
|
| 318 |
+
.bw-free-letter-btn:hover {
|
| 319 |
+
background: linear-gradient(135deg, #1ca41c 0%, #20d46c 100%) !important;
|
| 320 |
+
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.3) !important;
|
| 321 |
+
transform: translateY(-2px) !important;
|
| 322 |
+
}
|
| 323 |
+
.bw-free-letter-btn:disabled {
|
| 324 |
+
background: #888 !important;
|
| 325 |
+
border-color: #666 !important;
|
| 326 |
+
opacity: 0.5 !important;
|
| 327 |
+
cursor: not-allowed !important;
|
| 328 |
+
}
|
| 329 |
+
.bw-free-letter-container {
|
| 330 |
+
display: flex;
|
| 331 |
+
flex-direction: column;
|
| 332 |
+
align-items: center;
|
| 333 |
+
gap: 1rem;
|
| 334 |
+
padding: 1rem;
|
| 335 |
+
background: linear-gradient(135deg, rgba(29, 100, 200, 0.15), rgba(11, 42, 74, 0.15));
|
| 336 |
+
border-radius: 1rem;
|
| 337 |
+
border: 2px solid rgba(29, 100, 200, 0.3);
|
| 338 |
+
margin-bottom: 1rem;
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
.bw-free-letter-grid {
|
| 342 |
+
display: grid;
|
| 343 |
+
aspect-ratio: auto;
|
| 344 |
+
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
|
| 345 |
+
gap: 0.5rem;
|
| 346 |
+
width: 100%;
|
| 347 |
+
max-width: 400px;
|
| 348 |
+
z-index: 1000;
|
| 349 |
+
position: relative;
|
| 350 |
+
}
|
| 351 |
+
/* Apply bw-free-letter-btn styles to buttons inside bw-free-letter-grid */
|
| 352 |
+
.bw-free-letter-grid button,
|
| 353 |
+
.bw-free-letter-grid div[data-testid="stButton"] button {
|
| 354 |
+
min-width: 60px !important;
|
| 355 |
+
height: 60px !important;
|
| 356 |
+
font-size: 1.5rem !important;
|
| 357 |
+
font-weight: 700 !important;
|
| 358 |
+
border-radius: 50% !important;
|
| 359 |
+
background: linear-gradient(135deg, #20d46c 0%, #1ca41c 100%) !important;
|
| 360 |
+
color: #ffffff !important;
|
| 361 |
+
border: 3px solid #1ca41c !important;
|
| 362 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2) !important;
|
| 363 |
+
transition: all 0.2s ease !important;
|
| 364 |
+
aspect-ratio: unset !important;
|
| 365 |
+
}
|
| 366 |
+
.bw-free-letter-grid button:hover,
|
| 367 |
+
.bw-free-letter-grid div[data-testid="stButton"] button:hover {
|
| 368 |
+
background: linear-gradient(135deg, #1ca41c 0%, #20d46c 100%) !important;
|
| 369 |
+
box-shadow: 0 6px 8px rgba(0, 0, 0, 0.3) !important;
|
| 370 |
+
transform: translateY(-2px) !important;
|
| 371 |
+
}
|
| 372 |
+
.bw-free-letter-grid button:disabled,
|
| 373 |
+
.bw-free-letter-grid div[data-testid="stButton"] button:disabled {
|
| 374 |
+
background: #888 !important;
|
| 375 |
+
border-color: #666 !important;
|
| 376 |
+
opacity: 0.5 !important;
|
| 377 |
+
cursor: not-allowed !important;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.bw-free-letter-title {
|
| 381 |
+
font-size: 1.1rem;
|
| 382 |
+
font-weight: 700;
|
| 383 |
+
color: #ffffff;
|
| 384 |
+
text-align: center;
|
| 385 |
+
margin: 0;
|
| 386 |
+
}
|
| 387 |
+
.bw-free-letter-status {
|
| 388 |
+
font-size: 0.9rem;
|
| 389 |
+
color: #d7faff;
|
| 390 |
+
text-align: center;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
/* Final score style */
|
| 394 |
+
.bw-final-score { color: #1ca41c !important; font-weight: 800; }
|
| 395 |
+
.stExpander {z-index: 10;width: 50%;}
|
| 396 |
+
div[data-testid="stToastContainer"], div[data-testid="stToast"] {
|
| 397 |
+
margin: 0 auto;
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
/* Make grid buttons square and fill their column */
|
| 401 |
+
div[data-testid="stButton"]{
|
| 402 |
+
margin: 0 auto;
|
| 403 |
+
text-align: center;
|
| 404 |
+
}
|
| 405 |
+
div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; border-radius: 0; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 1.75rem;}
|
| 406 |
+
.st-key-new_game_btn, .st-key-sort_wordlist_btn, .st-key-filter_wordlist_btn { margin: 0 auto; aspect-ratio: unset; z-index:9999;}
|
| 407 |
+
.st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto;}
|
| 408 |
+
|
| 409 |
+
div[data-testid="column"], .st-emotion-cache-zh2fnc { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; }
|
| 410 |
+
.st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.25rem !important; min-height: 2.5rem; min-width: 2.5rem;}
|
| 411 |
+
|
| 412 |
+
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 413 |
+
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
|
| 414 |
+
.bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
|
| 415 |
+
.st-emotion-cache-1n6tfoc { background: linear-gradient(-45deg, #a1a1a1, #c0c0c0, #a1a1c1, #666666) !important; gap: 0.1rem !important; color: white; border-radius:15px; padding: 10px 10px 10px 10px; }
|
| 416 |
+
.st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
|
| 417 |
+
.st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
|
| 418 |
+
.st-key-guess_input [id^="text_input"] {max-width: 80px;}
|
| 419 |
+
.st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
|
| 420 |
+
aspect-ratio: auto !important;
|
| 421 |
+
position:relative;
|
| 422 |
+
z-index: 1200;
|
| 423 |
+
}
|
| 424 |
+
.username_input [id^="text_input"], .st-key-username_input [id^="text_input"] { color: #666;}
|
| 425 |
+
.st-key-username_input [id^="text_input"]::-webkit-input-placeholder { color: rgb(61, 157, 243); }
|
| 426 |
+
.st-key-username_input [id^="text_input"]::-moz-placeholder { color: rgb(61, 157, 243); }
|
| 427 |
+
.st-key-username_input [id^="text_input"]:-ms-input-placeholder { color: rgb(61, 157, 243); }
|
| 428 |
+
.st-key-username_input [id^="text_input"]::-ms-input-placeholder { color: rgb(61, 157, 243); }
|
| 429 |
+
.st-key-username_input [id^="text_input"]::placeholder { color: rgb(61, 157, 243); }
|
| 430 |
+
.st-emotion-cache-18kf3ut, .stColumn.st-emotion-cache-116javk {padding-bottom: 4px;}
|
| 431 |
+
|
| 432 |
+
/* grid adjustments */
|
| 433 |
+
# @media (max-width: 705px){
|
| 434 |
+
# .bw-cell {
|
| 435 |
+
# min-height: 2.5rem;
|
| 436 |
+
# min-width: 1.75rem;
|
| 437 |
+
# }
|
| 438 |
+
# }
|
| 439 |
+
@media (min-width: 560px){
|
| 440 |
+
div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; min-height: 1.75rem; display: flex;}
|
| 441 |
+
.st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
|
| 442 |
+
.st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 16 / 11; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
|
| 443 |
+
/*.st-emotion-cache-1n6tfoc { aspect-ratio: 16 / 11; min-height: calc(100% + 20px) !important;}*/
|
| 444 |
+
.st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
|
| 445 |
+
.st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
|
| 446 |
+
aspect-ratio: auto !important;
|
| 447 |
+
position:relative;
|
| 448 |
+
z-index: 1200;
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
div[data-testid="stElementToolbarButtonContainer"], button[data-testid="stBaseButton-elementToolbar"], button[data-testid="stBaseButton-elementToolbar"]:hover {
|
| 453 |
+
display: none;
|
| 454 |
+
}
|
| 455 |
+
@media (max-width: 991px) and (min-width: 640px){
|
| 456 |
+
.st-emotion-cache-18kf3ut, .stColumn.st-emotion-cache-116javk {padding-bottom:0;}
|
| 457 |
+
}
|
| 458 |
+
/* Mobile styles */
|
| 459 |
+
@media (max-width: 640px) {
|
| 460 |
+
.bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:50px;}
|
| 461 |
+
#bw-main-anchor + div[data-testid="stHorizontalBlock"] { flex-direction: column-reverse !important; width: 100% !important; max-width: 100vw !important; }
|
| 462 |
+
#bw-main-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { width: 100% !important; min-width: 100% !important; max-width: 100% !important; flex: 1 1 100% !important; }
|
| 463 |
+
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 464 |
+
.st-emotion-cache-1tj828o { min-width: calc(8.33333% - 1rem); }
|
| 465 |
+
# .bw-free-letter-grid {
|
| 466 |
+
# grid-template-columns: repeat(auto-fit, minmax(50px, 1fr));
|
| 467 |
+
# }
|
| 468 |
+
.st-emotion-cache-1hxuzh3 {min-width: unset;}
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.bold-text { font-weight: 700; }
|
| 472 |
+
.blue-background { background:#1d64c8; opacity:0.9; }
|
| 473 |
+
.metal-border { position: relative; padding: 20px; background: #333; color: white; border: 4px solid; border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1c1, #666666) 1; border-radius: 8px; }
|
| 474 |
+
.shiny-border { position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
|
| 475 |
+
.shiny-border::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transition: left 0.5s; }
|
| 476 |
+
.bw-score-panel-container { height: 100%; overflow: hidden; text-align:center;}
|
| 477 |
+
.bw-score-panel-container table tbody tr h3 {display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}
|
| 478 |
+
.shiny-border:hover::before { left: 100%; }
|
| 479 |
+
|
| 480 |
+
.bw-radio-group { display:flex; align-items:flex-start; gap: 10px; flex-flow: row; }
|
| 481 |
+
.bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;}
|
| 482 |
+
.bw-radio-circle { width: 45px; height: 45px; border-radius: 50%; border: 4px solid; background: rgba(255,255,255,0.06); display: grid; place-items: center; color:#fff; font-weight:700; }
|
| 483 |
+
.bw-radio-circle .dot { width: 14px; height: 14px; border-radius: 50%; background:#777; box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25); }
|
| 484 |
+
.bw-radio-circle.active.hit { background: linear-gradient(135deg, rgba(0,255,127,0.18), rgba(0,128,64,0.38)); }
|
| 485 |
+
.bw-radio-circle.active.hit .dot { background:#20d46c; box-shadow: 0 0 10px rgba(32,212,108,0.85); }
|
| 486 |
+
.bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
|
| 487 |
+
.bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
|
| 488 |
+
.bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
|
| 489 |
+
@media (max-width:1000px) and (min-width: 641px) {
|
| 490 |
+
.bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
|
| 491 |
+
.bw-radio-item {margin: 0 auto;}
|
| 492 |
+
}
|
| 493 |
+
@media (max-width:640px) {
|
| 494 |
+
.bw-radio-item { margin:unset;}
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/* Make the sidebar scrollable */
|
| 498 |
+
section[data-testid="stSidebar"] {
|
| 499 |
+
display: none;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.st-emotion-cache-wp60of {
|
| 503 |
+
width: 720px;
|
| 504 |
+
position: absolute;
|
| 505 |
+
max-width:100%;
|
| 506 |
+
}
|
| 507 |
+
.stImage {max-width:300px;}
|
| 508 |
+
[id^="text_input"], .st-bb [id^="text_input"] {
|
| 509 |
+
background-color:#fff;
|
| 510 |
+
color:#000;
|
| 511 |
+
caret-color:#333;}
|
| 512 |
+
|
| 513 |
+
@media (min-width:720px) {
|
| 514 |
+
.st-emotion-cache-wp60of {
|
| 515 |
+
left: calc(calc(100% - 720px) / 2);
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
/* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
|
| 520 |
+
.bw-component-abs { position: fixed !important; inset: 0 !important; z-index: 99999 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; padding: 0 !important; }
|
| 521 |
+
/* Generic hide utility */
|
| 522 |
+
.hide { display: none !important; pointer-events: none !important; }
|
| 523 |
+
</style>
|
| 524 |
+
""",
|
| 525 |
+
unsafe_allow_html=True,
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
# --- Footer Navigation ---
|
| 529 |
+
def _render_footer(current_page: str = "play"):
|
| 530 |
+
"""Render footer with navigation links to leaderboards and main game.
|
| 531 |
+
|
| 532 |
+
Args:
|
| 533 |
+
current_page: Which page is currently active ("play", "today", "daily", "weekly", "history", "settings")
|
| 534 |
+
"""
|
| 535 |
+
# Determine which link should be highlighted as active
|
| 536 |
+
play_active = "active" if current_page == "play" else ""
|
| 537 |
+
leaderboard_active = "active" if current_page in {"today", "daily", "weekly", "history"} else ""
|
| 538 |
+
settings_active = "active" if current_page == "settings" else ""
|
| 539 |
+
game_title = _get_effective_game_title()
|
| 540 |
+
game_version = f"{game_title.lower()}-v{version.replace('.','-')}"
|
| 541 |
+
|
| 542 |
+
# Check if we're in challenge mode and need to preserve game_id
|
| 543 |
+
game_id = None
|
| 544 |
+
try:
|
| 545 |
+
params = st.query_params
|
| 546 |
+
if "game_id" in params:
|
| 547 |
+
game_id = params.get("game_id")
|
| 548 |
+
except Exception:
|
| 549 |
+
pass
|
| 550 |
+
|
| 551 |
+
# Also check session state for loaded challenge
|
| 552 |
+
if not game_id and st.session_state.get("loaded_game_sid"):
|
| 553 |
+
game_id = st.session_state.get("loaded_game_sid")
|
| 554 |
+
|
| 555 |
+
# Build URLs with game_id if in challenge mode
|
| 556 |
+
if game_id:
|
| 557 |
+
today_url = f"?page=today&game_id={game_id}#wrdler-leaderboards"
|
| 558 |
+
leaderboard_url = f"?page=today&game_id={game_id}#wrdler-leaderboards"
|
| 559 |
+
play_url = f"?game_id={game_id}#{game_version}"
|
| 560 |
+
settings_url = f"?page=settings&game_id={game_id}#settings"
|
| 561 |
+
else:
|
| 562 |
+
today_url = "?page=today#wrdler-leaderboards"
|
| 563 |
+
leaderboard_url = "?page=today#wrdler-leaderboards"
|
| 564 |
+
play_url = f"/#{game_version}"
|
| 565 |
+
settings_url = "?page=settings#settings"
|
| 566 |
+
|
| 567 |
+
st.markdown(
|
| 568 |
+
f"""
|
| 569 |
+
<style>
|
| 570 |
+
.bw-footer {{
|
| 571 |
+
position: fixed;
|
| 572 |
+
bottom: 0;
|
| 573 |
+
left: 0;
|
| 574 |
+
right: 0;
|
| 575 |
+
background: linear-gradient(180deg, transparent 0%, rgba(11, 42, 74, 0.95) 30%, rgba(11, 42, 74, 0.98) 100%);
|
| 576 |
+
padding: 0.75rem 1rem 0.5rem;
|
| 577 |
+
z-index: 9998;
|
| 578 |
+
text-align: center;
|
| 579 |
+
}}
|
| 580 |
+
.bw-footer-nav {{
|
| 581 |
+
display: flex;
|
| 582 |
+
justify-content: center;
|
| 583 |
+
align-items: center;
|
| 584 |
+
gap: 1rem;
|
| 585 |
+
flex-wrap: wrap;
|
| 586 |
+
}}
|
| 587 |
+
.bw-footer-nav a {{
|
| 588 |
+
color: #d7faff;
|
| 589 |
+
text-decoration: none;
|
| 590 |
+
font-weight: 600;
|
| 591 |
+
font-size: 0.85rem;
|
| 592 |
+
padding: 0.4rem 0.8rem;
|
| 593 |
+
border-radius: 0.5rem;
|
| 594 |
+
background: rgba(29, 100, 200, 0.3);
|
| 595 |
+
border: 1px solid rgba(215, 250, 255, 0.3);
|
| 596 |
+
transition: all 0.2s ease;
|
| 597 |
+
}}
|
| 598 |
+
.bw-footer-nav a:hover {{
|
| 599 |
+
background: rgba(29, 100, 200, 0.6);
|
| 600 |
+
border-color: rgba(215, 250, 255, 0.6);
|
| 601 |
+
color: #ffffff;
|
| 602 |
+
text-decoration: none;
|
| 603 |
+
}}
|
| 604 |
+
.bw-footer-nav a.active {{
|
| 605 |
+
background: rgba(32, 212, 108, 0.3);
|
| 606 |
+
border-color: rgba(32, 212, 108, 0.5);
|
| 607 |
+
}}
|
| 608 |
+
/* Add padding to main content to prevent footer overlap */
|
| 609 |
+
.stMainBlockContainer {{
|
| 610 |
+
padding-bottom: 70px !important;
|
| 611 |
+
}}
|
| 612 |
+
@media (max-width: 640px) {{
|
| 613 |
+
.bw-footer-nav {{
|
| 614 |
+
gap: 0.5rem;
|
| 615 |
+
}}
|
| 616 |
+
.bw-footer-nav a {{
|
| 617 |
+
font-size: 0.75rem;
|
| 618 |
+
padding: 0.35rem 0.6rem;
|
| 619 |
+
}}
|
| 620 |
+
}}
|
| 621 |
+
</style>
|
| 622 |
+
<div class="bw-footer">
|
| 623 |
+
<nav class="bw-footer-nav">
|
| 624 |
+
<a href="{leaderboard_url if not leaderboard_active else '#wrdler-leaderboards'}" title="View Leaderboards" target="_self" class="{leaderboard_active}">🏆 Leaderboard</a>
|
| 625 |
+
<a href="{play_url if not play_active else f'#{game_version}'}" title="Play Wrdler" target="_self" class="{play_active}">🎮 Play</a>
|
| 626 |
+
<a href="{settings_url if not settings_active else '#settings'}" title="Settings" target="_self" class="{settings_active}">⚙️ Settings</a>
|
| 627 |
+
</nav>
|
| 628 |
+
</div>
|
| 629 |
+
""",
|
| 630 |
+
unsafe_allow_html=True,
|
| 631 |
+
)
|
| 632 |
+
|
| 633 |
+
|
| 634 |
+
# --- Spinner Overlay ---
|
| 635 |
+
def show_spinner(message: str = "Loading..."):
|
| 636 |
+
"""
|
| 637 |
+
Show a full-page overlay with a wrdler.gif spinner and optional message.
|
| 638 |
+
Expects wrdler/assets/wrdler.gif to exist.
|
| 639 |
+
"""
|
| 640 |
+
gif_path = os.path.join(os.path.dirname(__file__), "assets", "wrdler.gif")
|
| 641 |
+
gif_data = None
|
| 642 |
+
if os.path.exists(gif_path):
|
| 643 |
+
with open(gif_path, "rb") as f:
|
| 644 |
+
gif_data = base64.b64encode(f.read()).decode("utf-8")
|
| 645 |
+
img_tag = f'<img src="data:image/gif;base64,{gif_data}" alt="Loading..." width="80" height="80" />'
|
| 646 |
+
else:
|
| 647 |
+
img_tag = '<div style="width:80px;height:80px;background:#eee;border-radius:16px;"></div>'
|
| 648 |
+
|
| 649 |
+
st.markdown(
|
| 650 |
+
f'''
|
| 651 |
+
<style>
|
| 652 |
+
.bw-spinner-overlay {{
|
| 653 |
+
position: fixed; z-index: 99999; top: 0; left: 0; width: 100vw; height: 100vh;
|
| 654 |
+
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
| 655 |
+
background: rgba(11,42,74,0.99); display: flex; align-items: center; justify-content: center;
|
| 656 |
+
}}
|
| 657 |
+
.bw-spinner-img {{
|
| 658 |
+
width: 150px; height: 150px; border-radius: 16px; box-shadow: 0 0 8px #1d64c8;
|
| 659 |
+
background: #fff; display: flex; align-items: center; justify-content: center;
|
| 660 |
+
}}
|
| 661 |
+
.modal-spinner-inner {{
|
| 662 |
+
background: rgba(255,255,255,0.75); padding: 1rem 2rem; border-radius: 1rem;
|
| 663 |
+
box-shadow: 0 0 16px #1d64c8;
|
| 664 |
+
font-size: 1.5rem; color: #1d64c8;
|
| 665 |
+
}}
|
| 666 |
+
.bw-spinner-msg {{
|
| 667 |
+
color: #1d64c8; font-size: 1.2rem; margin-top: 1.5rem; text-align: center; font-weight: 600;
|
| 668 |
+
}}
|
| 669 |
+
</style>
|
| 670 |
+
<div class="bw-spinner-overlay">
|
| 671 |
+
<div class="modal-spinner-inner" style="display:flex; flex-direction:column; align-items:center;">
|
| 672 |
+
<div class="bw-spinner-img stImage">{img_tag}</div>
|
| 673 |
+
<div class="bw-spinner-msg">{message}</div>
|
| 674 |
+
</div>
|
| 675 |
+
</div>
|
| 676 |
+
''',
|
| 677 |
+
unsafe_allow_html=True
|
| 678 |
+
)
|