Surn commited on
Commit
f449a3a
·
1 Parent(s): 09427c9

v0.2.10 Refactor spinner handling with CustomSpinner

Browse files

Replaced `_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 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.9
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.9
 
16
  **Repository:** https://github.com/Oncorporation/Wrdler.git
17
  **Branch:** AI (working branch)
18
 
19
- ## Current Features (v0.2.9)
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
- - Sidebar controls for sorting and filtering word lists
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
- - Updated to prevent reloading active pages
 
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.9
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
- ## Next Step: OAuth-Protected Settings Page
249
-
250
- ### Goal
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
- - **OAuth detection:** Check `st.session_state.get("oauth_user")`
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.9
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.9
 
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.1
4
- **Project Version:** 0.2.8
5
  **Author:** GitHub Copilot
6
- **Last Updated:** 2025-12-08
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 Sidebar Navigation
525
-
526
- Add to `_render_sidebar()` in `ui.py`:
527
 
528
- ```python
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
- Modify `_game_over_content()` in `ui.py` to:
538
 
539
- 1. Call `submit_score_to_all_leaderboards()` after generating share link
540
- 2. Display qualification results:
541
 
542
- ```python
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.8
4
  **Status:** Production Ready - Leaderboards Implemented
5
- **Last Updated:** 2025-12-09
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.9
 
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

  • SHA256: e1411ccb575de816fc8cb0553c6334a963955687191322bfd96d2c07288981eb
  • Pointer size: 131 Bytes
  • Size of remote file: 202 kB
wrdler/__init__.py CHANGED
@@ -9,5 +9,5 @@ Key differences from BattleWords:
9
  - Daily and weekly leaderboards
10
  """
11
 
12
- __version__ = "0.2.9"
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

  • SHA256: e1411ccb575de816fc8cb0553c6334a963955687191322bfd96d2c07288981eb
  • Pointer size: 131 Bytes
  • Size of remote file: 202 kB
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": true,
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
- st.set_page_config(initial_sidebar_state="collapsed")
 
 
 
 
 
 
 
 
 
37
 
38
- # PWA (Progressive Web App) Support
39
- # Enables installing Wrdler as a native-feeling mobile app
40
- # Note: PWA meta tags are injected into <head> via Docker build (inject-pwa-head.sh)
41
- # This ensures proper PWA detection by browsers
42
- pwa_service_worker = """
43
- <script>
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
- CoordLike = Tuple[int, int]
 
 
 
 
166
 
167
- def _get_effective_game_title() -> str:
168
- """
169
- Get the effective game title, prioritizing:
170
- 1. Challenge-specific game_title from shared_game_settings
171
- 2. Session state game_title (if set)
172
- 3. APP_SETTINGS default
173
- 4. Fallback to "Wrdler"
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: 16px;
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 { color: #1d64c8;}
 
 
 
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: 16px;
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("### 🎮 Enter Name to submit to leaderboard")
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 your name (required)",
1826
  value=st.session_state.get("player_username", ""),
1827
  key="username_input",
1828
- placeholder="Your Name"
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
- try:
1856
- results = submit_score_to_all_leaderboards(
1857
- username=username,
1858
- score=score,
1859
- time_seconds=time_secs,
1860
- word_list=word_list,
1861
- settings=settings,
1862
- word_list_difficulty=difficulty_value,
1863
- source_challenge_id=challenge_id,
1864
- )
1865
- except Exception:
1866
- # If the unified submit fails, try a best-effort weekly submit below
1867
- results = {"daily": {"qualified": False, "rank": None, "id": None}, "weekly": {"qualified": False, "rank": None, "id": None}}
1868
-
1869
- # Check weekly result and attempt a fallback single-weekly submission if missing
1870
- weekly_info = results.get("weekly") or {}
1871
- if weekly_info.get("id") is None or (weekly_info.get("qualified") is False and weekly_info.get("rank") is None):
1872
- try:
1873
- weekly_id = get_current_weekly_id()
1874
- # Build a lightweight UserEntry via submit_to_leaderboard (it returns (success, rank))
1875
- submit_to_leaderboard(
1876
- "weekly",
1877
- weekly_id,
1878
- None, # user_entry will be constructed inside submit_to_leaderboard if used directly; instead construct minimal entry below
1879
- settings,
1880
- )
1881
- except Exception:
1882
- # swallow fallback errors - main attempt already logged by leaderboard module
1883
- pass
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
- #st.markdown("##### 🏆 Or just submit to leaderboards")
1896
- if st.button("🏆 Submit to Leaderboards", key="submit_leaderboard_only", width="stretch", disabled=not can_submit):
1897
- try:
1898
- word_list = [w.text for w in state.puzzle.words]
1899
- lb_results = _submit_to_leaderboards(
1900
- username, state.score, elapsed_seconds, word_list
1901
- )
1902
- st.session_state["leaderboard_submitted"] = True
1903
- st.session_state["leaderboard_results"] = lb_results
1904
-
1905
- # Show results
1906
- daily_info = lb_results.get("daily", {})
1907
- weekly_info = lb_results.get("weekly", {})
1908
-
1909
- if daily_info.get("qualified") or weekly_info.get("qualified"):
1910
- msg_parts = []
1911
- if daily_info.get("qualified"):
1912
- msg_parts.append(f"Daily #{daily_info['rank']}")
1913
- if weekly_info.get("qualified"):
1914
- msg_parts.append(f"Weekly #{weekly_info['rank']}")
1915
- st.success(f"✅ Submitted! Ranked: {', '.join(msg_parts)}")
1916
- else:
1917
- st.info("✅ Score submitted (not in top 20)")
1918
- st.rerun()
1919
- except Exception as e:
1920
- st.error(f"Failed to submit to leaderboards: {e}")
1921
-
1922
 
1923
  button_text = "📊 Submit Your Result" if is_shared_game else "🔗 Generate Share Link"
1924
 
1925
- if not can_submit:
1926
- st.warning("⚠️ Please enter your name to submit.")
1927
-
1928
- if st.button(button_text, key="generate_share_link", width="stretch", disabled=not can_submit):
1929
- try:
1930
- st.markdown("---")
1931
- # Extract game data
1932
- word_list = [w.text for w in state.puzzle.words]
1933
- spacer = state.puzzle.spacer
1934
- may_overlap = state.puzzle.may_overlap
1935
- wordlist_source = st.session_state.get("selected_wordlist", "classic.txt")
1936
-
1937
- if is_shared_game and existing_sid:
1938
- # Add result to existing game
1939
- success = add_user_result_to_game(
1940
- sid=existing_sid,
1941
- username=username,
1942
- word_list=word_list, # Each user gets different words
1943
- score=state.score,
1944
- time_seconds=elapsed_seconds
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
- if sid:
1984
- share_url = get_shareable_url(sid)
1985
- st.session_state["share_url"] = share_url
1986
- st.session_state["share_sid"] = sid
1987
- st.session_state["show_challenge_share_links"] = True
1988
-
1989
- # Also submit to daily/weekly leaderboards
1990
- try:
1991
- lb_results = _submit_to_leaderboards(
1992
- username, state.score, elapsed_seconds, word_list, sid
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- except Exception as e:
2004
- st.error(f"Failed to save game: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.markdown(ocean_background_css, unsafe_allow_html=True)
2320
- inject_ocean_layers()
2321
- render_settings_page(_on_game_option_change)
2322
- _render_footer(current_page="settings")
 
 
2323
  return
2324
 
2325
  if page in {"today", "daily", "weekly", "history"}:
2326
  from .leaderboard_page import render_leaderboard_page
2327
- st.markdown(ocean_background_css, unsafe_allow_html=True)
2328
- inject_ocean_layers()
2329
- default = page if page in {"daily", "weekly", "history"} else "today"
2330
- render_leaderboard_page(default_tab=default)
2331
- _render_footer(current_page=page if page else "play")
 
 
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
- try:
2338
- settings = load_game_from_sid(sid)
2339
- if settings:
2340
- # Store loaded settings and sid for initialization
2341
- st.session_state["shared_game_settings"] = settings
2342
- st.session_state["loaded_game_sid"] = sid # Store sid for adding results later
2343
- st.session_state["shared_game_loaded"] = True
2344
-
2345
- # Apply challenge-specific settings (show_incorrect_guesses, enable_free_letters, game_title)
2346
- _apply_challenge_settings(settings)
2347
-
2348
- # Get best score and time from users array
2349
- users = settings.get("users", [])
2350
- if users:
2351
- best_score = max(u["score"] for u in users)
2352
- best_time = min(u["time"] for u in users)
2353
- st.toast(
2354
- f"🎯 Loading shared challenge (Best: {best_score} pts in {best_time}s by {len(users)} player(s))",
2355
- icon="ℹ️"
2356
- )
 
 
 
 
2357
  else:
2358
- st.toast("🎯 Loading shared challenge", icon="℉")
2359
- else:
2360
- st.warning(f"No shared game found for ID: {sid}. Starting a normal game.")
2361
- st.session_state["shared_game_loaded"] = True # Prevent repeated attempts
2362
- except Exception as e:
2363
- st.error(f"❌ Error loading shared game: {e}")
2364
- st.session_state["shared_game_loaded"] = True # Prevent repeated attempts
 
 
 
 
 
 
 
 
 
2365
 
 
 
 
 
 
 
 
 
 
 
2366
  _init_session()
2367
  st.markdown(ocean_background_css, unsafe_allow_html=True)
2368
- inject_ocean_layers() # <-- add the animated 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
+ )