Surn commited on
Commit
7bfeb28
·
1 Parent(s): 26d1169

Refactor settings and update to version 0.2.6

Refactored the settings logic by moving sidebar controls to a dedicated `settings_page.py` file, improving modularity and user experience. Added routing for the new "Settings" page and updated the footer navigation.

Centralized audio handling with a new `_handle_audio` function for consistent behavior across pages. Simplified and cleaned up redundant code in `ui.py`.

Updated version to `0.2.6` across all relevant files, including `CLAUDE.md`, `GAMEPLAY_GUIDE.md`, `README.md`, and `pyproject.toml`. Enhanced changelogs and documentation to reflect new features.

Introduced a paywall specification (`paywall.md`) for future subscription-based access. Added detailed plans for settings refactoring in `settings.md`.

Improved maintainability and prepared the app for monetization while enhancing the user interface and overall functionality.

CLAUDE.md CHANGED
@@ -8,11 +8,11 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
8
  - **2 free letter guesses at game start** (all instances revealed)
9
  - **Word composition:** 2 four-letter, 2 five-letter, 2 six-letter words per puzzle
10
 
11
- **Current Version:** 0.2.4
12
  **Repository:** https://github.com/Oncorporation/Wrdler.git
13
  **Branch:** AI (working branch)
14
 
15
- ## Current Features (v0.2.4)
16
 
17
  ### Core Gameplay
18
  - 8x6 grid with 6 hidden words (one per row, horizontal only)
 
8
  - **2 free letter guesses at game start** (all instances revealed)
9
  - **Word composition:** 2 four-letter, 2 five-letter, 2 six-letter words per puzzle
10
 
11
+ **Current Version:** 0.2.6
12
  **Repository:** https://github.com/Oncorporation/Wrdler.git
13
  **Branch:** AI (working branch)
14
 
15
+ ## Current Features (v0.2.6)
16
 
17
  ### Core Gameplay
18
  - 8x6 grid with 6 hidden words (one per row, horizontal only)
GAMEPLAY_GUIDE.md CHANGED
@@ -1,5 +1,5 @@
1
  # Wrdler Gameplay Guide
2
- **Version:** 0.0.2
3
  **Last Updated:** 2025-01-31
4
 
5
  ## Welcome to Wrdler!
 
1
  # Wrdler Gameplay Guide
2
+ **Version:** 0.2.6
3
  **Last Updated:** 2025-01-31
4
 
5
  ## Welcome to Wrdler!
README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🧩
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: streamlit
7
- sdk_version: 1.51.0
8
  python_version: 3.12.8
9
  app_port: 8501
10
  app_file: app.py
@@ -25,7 +25,7 @@ thumbnail: >-
25
 
26
  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.
27
 
28
- **Current Version:** v0.2.4
29
 
30
  ## Key Differences from BattleWords
31
 
@@ -242,7 +242,7 @@ All test files must be placed in the `/tests` folder. This ensures a clean proje
242
 
243
  ## Changelog
244
 
245
- ### v0.2.4 (Current) ✅
246
  **Word List Filtering**
247
  - ✅ Added "Filter Wordlist" button to sidebar
248
  - ✅ Filters words against `assets/filter.txt` blocklist
 
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: streamlit
7
+ sdk_version: 1.52.1
8
  python_version: 3.12.8
9
  app_port: 8501
10
  app_file: app.py
 
25
 
26
  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.
27
 
28
+ **Current Version:** v0.2.6
29
 
30
  ## Key Differences from BattleWords
31
 
 
242
 
243
  ## Changelog
244
 
245
+ ### v0.2.6 (Current) ✅
246
  **Word List Filtering**
247
  - ✅ Added "Filter Wordlist" button to sidebar
248
  - ✅ Filters words against `assets/filter.txt` blocklist
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "wrdler"
3
- version = "0.2.2"
4
  description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, 2 free letter guesses, and a settings-based daily/weekly leaderboard system. Features leaderboard UI, challenge sharing, and AI word lists."
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
@@ -23,7 +23,6 @@ include-package-data = true
23
 
24
  [tool.setuptools.packages.find]
25
  where = [""]
26
- include = ["wrdler*"]
27
 
28
  [tool.setuptools.package-data]
29
  "wrdler.words" = ["*.txt"]
 
1
  [project]
2
  name = "wrdler"
3
+ version = "0.2.6"
4
  description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, 2 free letter guesses, and a settings-based daily/weekly leaderboard system. Features leaderboard UI, challenge sharing, and AI word lists."
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
 
23
 
24
  [tool.setuptools.packages.find]
25
  where = [""]
 
26
 
27
  [tool.setuptools.package-data]
28
  "wrdler.words" = ["*.txt"]
specs/leaderboard_spec.md CHANGED
@@ -1,7 +1,7 @@
1
  # Wrdler Leaderboard System Specification
2
 
3
  **Document Version:** 1.4.0
4
- **Project Version:** 0.2.0
5
  **Author:** GitHub Copilot
6
  **Last Updated:** 2025-12-08
7
  **Status:** ✅ Implemented and Documented
 
1
  # Wrdler Leaderboard System Specification
2
 
3
  **Document Version:** 1.4.0
4
+ **Project Version:** 0.2.6
5
  **Author:** GitHub Copilot
6
  **Last Updated:** 2025-12-08
7
  **Status:** ✅ Implemented and Documented
specs/paywall.md ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Paywall Integration Specification
2
+
3
+ This document outlines the plan to integrate `st-paywall` into the Wrdler application to enable subscription-based access.
4
+
5
+ ## 1. Dependencies
6
+
7
+ Add `st-paywall` to the project dependencies.
8
+
9
+ - **File**: `pyproject.toml`
10
+ - **Action**: Add `st-paywall` to the `dependencies` list.
11
+
12
+ ## 2. Configuration
13
+
14
+ Configure the payment provider (Stripe or Buy Me A Coffee) in Streamlit secrets.
15
+
16
+ - **File**: `.streamlit/secrets.toml` (User needs to create this if not exists)
17
+ - **Content**:
18
+ ```toml
19
+ [stripe]
20
+ api_key = "sk_live_..."
21
+ link = "https://buy.stripe.com/..."
22
+ # OR
23
+ [bmac]
24
+ api_key = "..."
25
+ link = "https://www.buymeacoffee.com/..."
26
+ ```
27
+ *Note: `st-paywall` expects specific keys. Refer to documentation for exact structure.*
28
+
29
+ ## 3. Code Integration
30
+
31
+ Integrate the authentication and subscription check into the main UI flow.
32
+
33
+ - **File**: `wrdler/ui.py`
34
+ - **Import**: `from st_paywall import add_auth`
35
+ - **Placement**:
36
+ - The `add_auth` function should be called early in the `run_app` function or before rendering sensitive content.
37
+ - It handles the login UI and subscription check.
38
+
39
+ ### Integration Strategy
40
+
41
+ We will add a "Premium" toggle or check, or enforce it for the whole app depending on requirements. For now, we will document how to gate the entire app or specific premium features (like AI Wordlist generation).
42
+
43
+ **Option A: Gate Entire App**
44
+ Call `add_auth(required=True)` at the start of `run_app`.
45
+
46
+ **Option B: Gate Premium Features**
47
+ Call `add_auth(required=False)` to show the login/subscribe sidebar but allow free access. Then check `st.session_state.user_subscribed` (or similar provided by the library) before enabling features like:
48
+ - AI Wordlist Generation
49
+ - Challenge Mode creation
50
+
51
+ ## 4. Implementation Plan
52
+
53
+ 1. **Update `pyproject.toml`**: Add `st-paywall`.
54
+ 2. **Update `wrdler/ui.py`**:
55
+ - Import `add_auth`.
56
+ - Insert `add_auth(...)` call in `run_app` or `_render_sidebar`.
57
+ - (Optional) Wrap premium features with subscription checks.
58
+
59
+ ## 5. Testing
60
+
61
+ - Verify that the login button appears.
62
+ - Verify that non-subscribed users are prompted to subscribe.
63
+ - Verify that subscribed users can access the content.
64
+ - Test with `testing_mode = true` in secrets.
specs/requirements.md CHANGED
@@ -1,5 +1,5 @@
1
  # Wrdler: Implementation Requirements
2
- **Version:** 0.2.4
3
  **Status:** Production Ready - Leaderboards Implemented
4
  **Last Updated:** 2025-12-08
5
 
 
1
  # Wrdler: Implementation Requirements
2
+ **Version:** 0.2.6
3
  **Status:** Production Ready - Leaderboards Implemented
4
  **Last Updated:** 2025-12-08
5
 
specs/settings.md ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Settings Page Refactoring Plan
2
+
3
+ ## Goal
4
+ Move the sidebar configuration controls (`_render_sidebar`) from `wrdler/ui.py` to a dedicated Settings page (`wrdler/settings_page.py`), accessible via the footer navigation.
5
+
6
+ ## Files to Create
7
+ 1. `wrdler/settings_page.py`
8
+
9
+ ## Files to Modify
10
+ 1. `wrdler/ui.py`
11
+
12
+ ## Detailed Steps
13
+
14
+ ### 1. Create `wrdler/settings_page.py`
15
+ This file will encapsulate the rendering logic for the settings.
16
+
17
+ - **Imports**:
18
+ - `streamlit as st`
19
+ - `os`
20
+ - `time`
21
+ - From `wrdler.word_loader`: `get_wordlist_files`, `get_wordlist_info`
22
+ - From `wrdler.generator`: `sort_word_file`, `filter_word_file`
23
+ - From `wrdler.audio`: `get_audio_tracks`, `_inject_audio_control_sync`
24
+ - From `wrdler.version_info`: `versions_html`
25
+
26
+ - **Functions**:
27
+ - `render_settings_page(new_game_callback)`:
28
+ - Renders the title "Settings".
29
+ - Contains the logic previously in `_render_sidebar` (Game Mode, Wordlist Controls, Grid Options, Audio Controls).
30
+ - **Important**: Does *not* include `_mount_background_audio` (this will be global).
31
+ - Uses `st.container` or main layout instead of `st.sidebar`.
32
+ - Accepts `new_game_callback` to trigger a game reset when settings change.
33
+ - `_sort_wordlist(filename)`: Moved from `ui.py`.
34
+ - `_filter_wordlist(filename)`: Moved from `ui.py`.
35
+ - `_filter_results_dialog`: Moved from `ui.py` (if used by `_filter_wordlist`).
36
+ - Local callbacks `_on_wordlist_change` and `_on_ai_generate` that utilize `new_game_callback`.
37
+
38
+ ### 2. Modify `wrdler/ui.py`
39
+
40
+ - **Extract Audio Logic**:
41
+ - Create a helper function `_handle_audio()` that contains the audio initialization and mounting logic previously in `_render_sidebar`.
42
+ - This ensures audio persists across pages.
43
+
44
+ - **Update `run_app()`**:
45
+ - Call `_handle_audio()` at the top level (before page routing).
46
+ - Add routing logic for `page="settings"`:
47
+ - Import `render_settings_page`.
48
+ - Render background.
49
+ - Call `render_settings_page(_on_game_option_change)`.
50
+ - Render footer with `current_page="settings"`.
51
+ - Return (stop execution of main game).
52
+ - Remove `_render_sidebar()` call.
53
+
54
+ - **Update `_render_footer()`**:
55
+ - Add a link to `?page=settings` with label "?? Settings".
56
+ - Highlight it when `current_page="settings"`.
57
+
58
+ - **Cleanup**:
59
+ - Remove `_render_sidebar` function.
60
+ - Remove `_sort_wordlist`, `_filter_wordlist` (moved to settings page).
61
+
62
+ ## Implementation Notes
63
+ - **Audio**: The audio *controls* (volume, track) will be in the Settings page, but the audio *player* (hidden HTML/JS) must be mounted on every page load via `_handle_audio()` in `run_app` to ensure continuous playback or proper state.
64
+ - **Callbacks**: `_on_game_option_change` in `ui.py` calls `_new_game`. This callback will be passed to `render_settings_page` so that changing settings triggers the necessary state resets.
65
+ - **Navigation**: The footer will serve as the primary navigation between "Play", "Leaderboard", and "Settings".
specs/specs.md CHANGED
@@ -1,5 +1,5 @@
1
  # Wrdler Game Specifications (specs.md)
2
- **Version:** 0.2.4
3
  **Status:** Production Ready - Leaderboards Implemented
4
  **Last Updated:** 2025-12-08
5
 
 
1
  # Wrdler Game Specifications (specs.md)
2
+ **Version:** 0.2.6
3
  **Status:** Production Ready - Leaderboards Implemented
4
  **Last Updated:** 2025-12-08
5
 
wrdler/__init__.py CHANGED
@@ -9,5 +9,5 @@ Key differences from BattleWords:
9
  - Daily and weekly leaderboards
10
  """
11
 
12
- __version__ = "0.2.5"
13
  __all__ = ["models", "generator", "logic", "ui", "word_loader", "leaderboard", "leaderboard_page"]
 
9
  - Daily and weekly leaderboards
10
  """
11
 
12
+ __version__ = "0.2.6"
13
  __all__ = ["models", "generator", "logic", "ui", "word_loader", "leaderboard", "leaderboard_page"]
wrdler/settings_page.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import os
3
+ import time
4
+ from .word_loader import get_wordlist_files, get_wordlist_info
5
+ from .generator import sort_word_file, filter_word_file
6
+ from .audio import get_audio_tracks, _inject_audio_control_sync
7
+ from .version_info import versions_html
8
+
9
+ def _sort_wordlist(filename, new_game_callback):
10
+ import os
11
+ import time
12
+
13
+ WORDS_DIR = os.path.join(os.path.dirname(__file__), "words")
14
+ filepath = os.path.join(WORDS_DIR, filename)
15
+ sorted_words = sort_word_file(filepath)
16
+ # Optionally, write sorted words back to file
17
+ with open(filepath, "w", encoding="utf-8") as f:
18
+ # Re-add header if needed
19
+ f.write("# Optional: place a large A-Z word list here (one word per line).\n")
20
+ f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
21
+ for word in sorted_words:
22
+ f.write(f"{word}\n")
23
+ # Show a message in Streamlit
24
+ st.success(f"{filename} sorted by length and alphabetically. Starting new game in 5 seconds...")
25
+ time.sleep(5) # 5 second delay before starting new game
26
+ new_game_callback()
27
+
28
+ def _filter_results_content(count: int, words: list[str], filename: str):
29
+ st.success(f"Removed {count} words from {filename}")
30
+ st.markdown("### Removed Words")
31
+ st.text_area("List", value="\n".join(words), height=200, disabled=True)
32
+ if st.button("Close", key="close_filter_dialog"):
33
+ st.rerun()
34
+
35
+ # Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable
36
+ _Dialog = getattr(st, "dialog", getattr(st, "experimental_dialog", None))
37
+ if _Dialog:
38
+ @_Dialog("Filter Results")
39
+ def _filter_results_dialog(count, words, filename):
40
+ _filter_results_content(count, words, filename)
41
+ else:
42
+ def _filter_results_dialog(count, words, filename):
43
+ modal_ctx = getattr(st, "modal", None)
44
+ if callable(modal_ctx):
45
+ with modal_ctx("Filter Results"):
46
+ _filter_results_content(count, words, filename)
47
+ else:
48
+ st.info(f"Removed {count} words from {filename}")
49
+ st.text_area("Removed Words", value="\n".join(words), height=200)
50
+
51
+ def _filter_wordlist(filename):
52
+ filter_path = os.path.join(os.path.dirname(__file__), "assets", "filter.txt")
53
+ words_path = os.path.join(os.path.dirname(__file__), "words", filename)
54
+
55
+ if not os.path.exists(filter_path):
56
+ st.error("Filter file not found (wrdler/assets/filter.txt).")
57
+ return
58
+
59
+ count, removed_words = filter_word_file(filter_path, words_path)
60
+
61
+ if count > 0:
62
+ _filter_results_dialog(count, removed_words, filename)
63
+ else:
64
+ st.info(f"No words removed from {filename}.")
65
+
66
+ def render_settings_page(new_game_callback):
67
+ st.header("SETTINGS")
68
+
69
+ st.header("Game Mode")
70
+ game_modes = ["classic", "easy", "too easy"]
71
+ default_mode = "classic"
72
+ if "game_mode" not in st.session_state:
73
+ st.session_state.game_mode = default_mode
74
+ current_mode = st.session_state.game_mode
75
+ st.selectbox(
76
+ "Select game mode",
77
+ options=game_modes,
78
+ index=game_modes.index(current_mode) if current_mode in game_modes else 0,
79
+ key="game_mode",
80
+ on_change=new_game_callback,
81
+ )
82
+
83
+ st.header("Wordlist Controls")
84
+
85
+ # Initialize AI mode settings
86
+ if "use_ai_wordlist" not in st.session_state:
87
+ st.session_state.use_ai_wordlist = False
88
+ if "ai_topic" not in st.session_state:
89
+ st.session_state.ai_topic = "English"
90
+
91
+ wordlist_files = get_wordlist_files()
92
+
93
+ # Add AI Generated option to file list
94
+ wordlist_options = ["AI Generated"] + wordlist_files if wordlist_files else ["AI Generated"]
95
+
96
+ def _on_wordlist_change():
97
+ selected = st.session_state.get("wordlist_selector")
98
+ if selected == "AI Generated":
99
+ st.session_state.use_ai_wordlist = True
100
+ if "ai_topic" not in st.session_state:
101
+ st.session_state.ai_topic = "English"
102
+ else:
103
+ st.session_state.use_ai_wordlist = False
104
+ st.session_state.selected_wordlist = selected
105
+ new_game_callback()
106
+
107
+ def _on_ai_generate():
108
+ new_game_callback()
109
+
110
+ if wordlist_files:
111
+ # Determine current selection index
112
+ if st.session_state.get("use_ai_wordlist", False):
113
+ current_index = 0 # AI Generated
114
+ elif st.session_state.get("selected_wordlist") in wordlist_files:
115
+ current_index = wordlist_options.index(st.session_state.selected_wordlist)
116
+ else:
117
+ # Default to first file
118
+ st.session_state.selected_wordlist = wordlist_files[0]
119
+ st.session_state.use_ai_wordlist = False
120
+ current_index = 1
121
+
122
+ # Wordlist selector
123
+ selected = st.selectbox(
124
+ "Select list",
125
+ options=wordlist_options,
126
+ index=current_index,
127
+ format_func=lambda f: f if f == "AI Generated" else f.rsplit(".", 1)[0],
128
+ key="wordlist_selector",
129
+ on_change=_on_wordlist_change,
130
+ )
131
+
132
+ # Show topic input if AI mode selected
133
+ if selected == "AI Generated":
134
+ st.session_state.use_ai_wordlist = True
135
+
136
+ # Topic input and Generate button in columns
137
+ topic_col, gen_col = st.columns([3, 1], gap="small")
138
+
139
+ with topic_col:
140
+ st.text_input(
141
+ "Topic",
142
+ value=st.session_state.ai_topic,
143
+ key="ai_topic",
144
+ placeholder="e.g., Ocean Life, Space, History",
145
+ help="Enter a topic for AI-generated words",
146
+ # Remove on_change to prevent automatic generation
147
+ )
148
+
149
+ with gen_col:
150
+ # Add custom CSS for the generate button
151
+ st.markdown(
152
+ """
153
+ <style>
154
+ .st-key-ai_generate_btn {
155
+ margin-top: 1.85rem; /* Align with text input */
156
+ }
157
+ .st-key-ai_generate_btn button {
158
+ aspect-ratio: auto !important;
159
+ height: auto !important;
160
+ padding: 0.5rem 0.75rem !important;
161
+ }
162
+ </style>
163
+ """,
164
+ unsafe_allow_html=True,
165
+ )
166
+ st.button(
167
+ "🎲",
168
+ key="ai_generate_btn",
169
+ help="Generate wordlist from topic",
170
+ on_click=_on_ai_generate,
171
+ use_container_width=True,
172
+ )
173
+
174
+ # Display wordlist info below topic input
175
+ info_text = get_wordlist_info(
176
+ use_ai=True,
177
+ ai_topic=st.session_state.ai_topic
178
+ )
179
+ if info_text:
180
+ st.caption(info_text)
181
+ else:
182
+ st.session_state.use_ai_wordlist = False
183
+ st.session_state.selected_wordlist = selected
184
+
185
+ # Display wordlist info below selection
186
+ info_text = get_wordlist_info(
187
+ use_ai=False,
188
+ selected_file=selected
189
+ )
190
+ if info_text:
191
+ st.caption(info_text)
192
+
193
+ # Only show Sort button for file-based wordlists
194
+ if not st.session_state.use_ai_wordlist:
195
+ if st.button("Sort Wordlist", width=125, key="sort_wordlist_btn"):
196
+ _sort_wordlist(st.session_state.selected_wordlist, new_game_callback)
197
+ if st.button("Filter Wordlist", width=125, key="filter_wordlist_btn"):
198
+ _filter_wordlist(st.session_state.selected_wordlist)
199
+ else:
200
+ st.info("No word lists found in words/ directory. Using AI or built-in fallback.")
201
+ # Force AI mode if no files available
202
+ st.session_state.use_ai_wordlist = True
203
+
204
+ # Topic input and Generate button in columns
205
+ topic_col, gen_col = st.columns([3, 1], gap="small")
206
+
207
+ with topic_col:
208
+ st.text_input(
209
+ "Topic",
210
+ value=st.session_state.ai_topic,
211
+ key="ai_topic",
212
+ placeholder="e.g., Ocean Life, Space, History",
213
+ help="Enter a topic for AI-generated words",
214
+ # Remove on_change to prevent automatic generation
215
+ )
216
+
217
+ with gen_col:
218
+ # Add custom CSS for the generate button
219
+ st.markdown(
220
+ """
221
+ <style>
222
+ .st-key-ai_generate_btn {
223
+ margin-top: 1.85rem; /* Align with text input */
224
+ }
225
+ .st-key-ai_generate_btn button {
226
+ aspect-ratio: auto !important;
227
+ height: auto !important;
228
+ padding: 0.5rem 0.75rem !important;
229
+ }
230
+ </style>
231
+ """,
232
+ unsafe_allow_html=True,
233
+ )
234
+ st.button(
235
+ "🎲",
236
+ key="ai_generate_btn",
237
+ help="Generate wordlist from topic",
238
+ on_click=_on_ai_generate,
239
+ use_container_width=True,
240
+ )
241
+
242
+ # Display wordlist info below topic input
243
+ info_text = get_wordlist_info(
244
+ use_ai=True,
245
+ ai_topic=st.session_state.ai_topic
246
+ )
247
+ if info_text:
248
+ st.caption(info_text)
249
+
250
+ # Add Show Grid ticks option
251
+ if "show_grid_ticks" not in st.session_state:
252
+ st.session_state.show_grid_ticks = False
253
+ st.checkbox("Show Grid ticks", value=st.session_state.show_grid_ticks, key="show_grid_ticks")
254
+
255
+ # Add Spacer option
256
+ spacer_options = [0,1,2]
257
+ if "spacer" not in st.session_state:
258
+ st.session_state.spacer = 1
259
+ st.selectbox(
260
+ "Spacer (space between words)",
261
+ options=spacer_options,
262
+ index=spacer_options.index(st.session_state.spacer),
263
+ key="spacer",
264
+ on_change=new_game_callback, # add callback
265
+ )
266
+
267
+ # Add Show Incorrect Guesses option - now enabled by default
268
+ if "show_incorrect_guesses" not in st.session_state:
269
+ st.session_state.show_incorrect_guesses = True
270
+ st.checkbox("Show incorrect guesses", value=st.session_state.show_incorrect_guesses, key="show_incorrect_guesses")
271
+
272
+ # NEW: Add Show Challenge Share Links option - default ON
273
+ if "show_challenge_share_links" not in st.session_state:
274
+ st.session_state.show_challenge_share_links = True
275
+ st.checkbox("Show Challenge Share Links", value=st.session_state.show_challenge_share_links, key="show_challenge_share_links")
276
+
277
+ # NEW: Initialize Enable Free Letters (default OFF)
278
+ if "enable_free_letters" not in st.session_state:
279
+ st.session_state.enable_free_letters = False
280
+ st.checkbox("Enable Free Letters (2 per game)", value=st.session_state.enable_free_letters, key="enable_free_letters")
281
+
282
+ # Audio settings
283
+ st.header("Audio")
284
+ tracks = get_audio_tracks()
285
+ st.caption(f"{len(tracks)} audio file{'s' if len(tracks) != 1 else ''} found in wrdler/assets/audio/music")
286
+
287
+ if "music_enabled" not in st.session_state:
288
+ st.session_state.music_enabled = False
289
+ if "music_volume" not in st.session_state:
290
+ st.session_state.music_volume = 15
291
+ # --- Add sound effects volume ---
292
+ if "effects_volume" not in st.session_state:
293
+ st.session_state.effects_volume = 25
294
+ # --- Add enable sound effects (default OFF) ---
295
+ if "enable_sound_effects" not in st.session_state:
296
+ st.session_state.enable_sound_effects = False
297
+ st.checkbox("Enable Sound Effects", value=st.session_state.enable_sound_effects, key="enable_sound_effects")
298
+
299
+ enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
300
+
301
+ st.slider(
302
+ "Volume",
303
+ 0,
304
+ 100,
305
+ value=int(st.session_state.music_volume),
306
+ step=1,
307
+ key="music_volume",
308
+ disabled=not (enabled and bool(tracks)),
309
+ )
310
+
311
+ # --- Add sound effects volume slider ---
312
+ st.slider(
313
+ "Sound Effects Volume",
314
+ 0,
315
+ 100,
316
+ value=int(st.session_state.effects_volume),
317
+ step=1,
318
+ key="effects_volume",
319
+ )
320
+
321
+ selected_path = None
322
+ if tracks:
323
+ options = [p for _, p in tracks]
324
+ # Default to first track if none chosen yet
325
+ if "music_track_path" not in st.session_state or st.session_state.music_track_path not in options:
326
+ st.session_state.music_track_path = options[0]
327
+
328
+ def _fmt(p: str) -> str:
329
+ # Find friendly label for path
330
+ for name, path in tracks:
331
+ if path == p:
332
+ return name
333
+ return os.path.splitext(os.path.basename(p))[0]
334
+
335
+ selected_path = st.selectbox(
336
+ "Track",
337
+ options=options,
338
+ index=options.index(st.session_state.music_track_path),
339
+ format_func=_fmt,
340
+ key="music_track_path",
341
+ disabled=not enabled,
342
+ )
343
+ else:
344
+ st.caption("Place .mp3 files in wrdler/assets/audio/music to enable music.")
345
+
346
+ _inject_audio_control_sync()
347
+ st.markdown(versions_html(), unsafe_allow_html=True)
wrdler/ui.py CHANGED
@@ -30,6 +30,7 @@ from .audio import (
30
  from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
31
  from .modules.constants import APP_SETTINGS
32
  from .leaderboard import submit_score_to_all_leaderboards
 
33
 
34
  st.set_page_config(initial_sidebar_state="collapsed")
35
 
@@ -614,12 +615,7 @@ border-radius: 50% !important;
614
 
615
  /* Make the sidebar scrollable */
616
  section[data-testid="stSidebar"] {
617
- max-height: 100vh;
618
- overflow-y: auto;
619
- overflow-x: hidden;
620
- scrollbar-width: thin;
621
- scrollbar-color: transparent transparent;
622
- opacity:0.75;
623
  }
624
 
625
  .st-emotion-cache-wp60of {
@@ -993,279 +989,30 @@ def _render_header():
993
 
994
  inject_styles()
995
 
996
- def _render_sidebar():
997
- with st.sidebar:
998
- st.header("SETTINGS")
999
-
1000
- st.header("Game Mode")
1001
- game_modes = ["classic", "easy", "too easy"]
1002
- default_mode = "classic"
1003
- if "game_mode" not in st.session_state:
1004
- st.session_state.game_mode = default_mode
1005
- current_mode = st.session_state.game_mode
1006
- st.selectbox(
1007
- "Select game mode",
1008
- options=game_modes,
1009
- index=game_modes.index(current_mode) if current_mode in game_modes else 0,
1010
- key="game_mode",
1011
- on_change=_on_game_option_change,
1012
- )
1013
-
1014
- st.header("Wordlist Controls")
1015
-
1016
- # Initialize AI mode settings
1017
- if "use_ai_wordlist" not in st.session_state:
1018
- st.session_state.use_ai_wordlist = False
1019
- if "ai_topic" not in st.session_state:
1020
- st.session_state.ai_topic = "English"
1021
-
1022
- wordlist_files = get_wordlist_files()
1023
-
1024
- # Add AI Generated option to file list
1025
- wordlist_options = ["AI Generated"] + wordlist_files if wordlist_files else ["AI Generated"]
1026
-
1027
- if wordlist_files:
1028
- # Determine current selection index
1029
- if st.session_state.get("use_ai_wordlist", False):
1030
- current_index = 0 # AI Generated
1031
- elif st.session_state.get("selected_wordlist") in wordlist_files:
1032
- current_index = wordlist_options.index(st.session_state.selected_wordlist)
1033
- else:
1034
- # Default to first file
1035
- st.session_state.selected_wordlist = wordlist_files[0]
1036
- st.session_state.use_ai_wordlist = False
1037
- current_index = 1
1038
-
1039
- # Wordlist selector
1040
- selected = st.selectbox(
1041
- "Select list",
1042
- options=wordlist_options,
1043
- index=current_index,
1044
- format_func=lambda f: f if f == "AI Generated" else f.rsplit(".", 1)[0],
1045
- key="wordlist_selector",
1046
- on_change=_on_wordlist_change,
1047
- )
1048
-
1049
- # Show topic input if AI mode selected
1050
- if selected == "AI Generated":
1051
- st.session_state.use_ai_wordlist = True
1052
-
1053
- # Topic input and Generate button in columns
1054
- topic_col, gen_col = st.columns([3, 1], gap="small")
1055
-
1056
- with topic_col:
1057
- st.text_input(
1058
- "Topic",
1059
- value=st.session_state.ai_topic,
1060
- key="ai_topic",
1061
- placeholder="e.g., Ocean Life, Space, History",
1062
- help="Enter a topic for AI-generated words",
1063
- # Remove on_change to prevent automatic generation
1064
- )
1065
-
1066
- with gen_col:
1067
- # Add custom CSS for the generate button
1068
- st.markdown(
1069
- """
1070
- <style>
1071
- .st-key-ai_generate_btn {
1072
- margin-top: 1.85rem; /* Align with text input */
1073
- }
1074
- .st-key-ai_generate_btn button {
1075
- aspect-ratio: auto !important;
1076
- height: auto !important;
1077
- padding: 0.5rem 0.75rem !important;
1078
- }
1079
- </style>
1080
- """,
1081
- unsafe_allow_html=True,
1082
- )
1083
- st.button(
1084
- "🎲",
1085
- key="ai_generate_btn",
1086
- help="Generate wordlist from topic",
1087
- on_click=_on_ai_generate,
1088
- use_container_width=True,
1089
- )
1090
-
1091
- # Display wordlist info below topic input
1092
- info_text = get_wordlist_info(
1093
- use_ai=True,
1094
- ai_topic=st.session_state.ai_topic
1095
- )
1096
- if info_text:
1097
- st.caption(info_text)
1098
- else:
1099
- st.session_state.use_ai_wordlist = False
1100
- st.session_state.selected_wordlist = selected
1101
-
1102
- # Display wordlist info below selection
1103
- info_text = get_wordlist_info(
1104
- use_ai=False,
1105
- selected_file=selected
1106
- )
1107
- if info_text:
1108
- st.caption(info_text)
1109
-
1110
- # Only show Sort button for file-based wordlists
1111
- if not st.session_state.use_ai_wordlist:
1112
- if st.button("Sort Wordlist", width=125, key="sort_wordlist_btn"):
1113
- _sort_wordlist(st.session_state.selected_wordlist)
1114
- if st.button("Filter Wordlist", width=125, key="filter_wordlist_btn"):
1115
- _filter_wordlist(st.session_state.selected_wordlist)
1116
- else:
1117
- st.info("No word lists found in words/ directory. Using AI or built-in fallback.")
1118
- # Force AI mode if no files available
1119
- st.session_state.use_ai_wordlist = True
1120
-
1121
- # Topic input and Generate button in columns
1122
- topic_col, gen_col = st.columns([3, 1], gap="small")
1123
-
1124
- with topic_col:
1125
- st.text_input(
1126
- "Topic",
1127
- value=st.session_state.ai_topic,
1128
- key="ai_topic",
1129
- placeholder="e.g., Ocean Life, Space, History",
1130
- help="Enter a topic for AI-generated words",
1131
- # Remove on_change to prevent automatic generation
1132
- )
1133
-
1134
- with gen_col:
1135
- # Add custom CSS for the generate button
1136
- st.markdown(
1137
- """
1138
- <style>
1139
- .st-key-ai_generate_btn {
1140
- margin-top: 1.85rem; /* Align with text input */
1141
- }
1142
- .st-key-ai_generate_btn button {
1143
- aspect-ratio: auto !important;
1144
- height: auto !important;
1145
- padding: 0.5rem 0.75rem !important;
1146
- }
1147
- </style>
1148
- """,
1149
- unsafe_allow_html=True,
1150
- )
1151
- st.button(
1152
- "🎲",
1153
- key="ai_generate_btn",
1154
- help="Generate wordlist from topic",
1155
- on_click=_on_ai_generate,
1156
- use_container_width=True,
1157
- )
1158
-
1159
- # Display wordlist info below topic input
1160
- info_text = get_wordlist_info(
1161
- use_ai=True,
1162
- ai_topic=st.session_state.ai_topic
1163
- )
1164
- if info_text:
1165
- st.caption(info_text)
1166
-
1167
- # Add Show Grid ticks option
1168
- if "show_grid_ticks" not in st.session_state:
1169
- st.session_state.show_grid_ticks = False
1170
- st.checkbox("Show Grid ticks", value=st.session_state.show_grid_ticks, key="show_grid_ticks")
1171
-
1172
- # Add Spacer option
1173
- spacer_options = [0,1,2]
1174
- if "spacer" not in st.session_state:
1175
- st.session_state.spacer = 1
1176
- st.selectbox(
1177
- "Spacer (space between words)",
1178
- options=spacer_options,
1179
- index=spacer_options.index(st.session_state.spacer),
1180
- key="spacer",
1181
- on_change=_on_game_option_change, # add callback
1182
- )
1183
-
1184
- # Add Show Incorrect Guesses option - now enabled by default
1185
- if "show_incorrect_guesses" not in st.session_state:
1186
- st.session_state.show_incorrect_guesses = True
1187
- st.checkbox("Show incorrect guesses", value=st.session_state.show_incorrect_guesses, key="show_incorrect_guesses")
1188
 
1189
- # NEW: Add Show Challenge Share Links option - default ON
1190
- if "show_challenge_share_links" not in st.session_state:
1191
- st.session_state.show_challenge_share_links = True
1192
- st.checkbox("Show Challenge Share Links", value=st.session_state.show_challenge_share_links, key="show_challenge_share_links")
 
 
 
1193
 
1194
- # NEW: Initialize Enable Free Letters (default OFF)
1195
- if "enable_free_letters" not in st.session_state:
1196
- st.session_state.enable_free_letters = False
1197
- st.checkbox("Enable Free Letters (2 per game)", value=st.session_state.enable_free_letters, key="enable_free_letters")
1198
-
1199
- # Audio settings
1200
- st.header("Audio")
1201
- tracks = get_audio_tracks()
1202
- st.caption(f"{len(tracks)} audio file{'s' if len(tracks) != 1 else ''} found in wrdler/assets/audio/music")
1203
-
1204
- if "music_enabled" not in st.session_state:
1205
- st.session_state.music_enabled = False
1206
- if "music_volume" not in st.session_state:
1207
- st.session_state.music_volume = 15
1208
- # --- Add sound effects volume ---
1209
- if "effects_volume" not in st.session_state:
1210
- st.session_state.effects_volume = 25
1211
- # --- Add enable sound effects (default OFF) ---
1212
- if "enable_sound_effects" not in st.session_state:
1213
- st.session_state.enable_sound_effects = False
1214
- st.checkbox("Enable Sound Effects", value=st.session_state.enable_sound_effects, key="enable_sound_effects")
1215
-
1216
- enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
1217
-
1218
- st.slider(
1219
- "Volume",
1220
- 0,
1221
- 100,
1222
- value=int(st.session_state.music_volume),
1223
- step=1,
1224
- key="music_volume",
1225
- disabled=not (enabled and bool(tracks)),
1226
- )
1227
-
1228
- # --- Add sound effects volume slider ---
1229
- st.slider(
1230
- "Sound Effects Volume",
1231
- 0,
1232
- 100,
1233
- value=int(st.session_state.effects_volume),
1234
- step=1,
1235
- key="effects_volume",
1236
- )
1237
-
1238
- selected_path = None
1239
- if tracks:
1240
- options = [p for _, p in tracks]
1241
- # Default to first track if none chosen yet
1242
- if "music_track_path" not in st.session_state or st.session_state.music_track_path not in options:
1243
- st.session_state.music_track_path = options[0]
1244
-
1245
- def _fmt(p: str) -> str:
1246
- # Find friendly label for path
1247
- for name, path in tracks:
1248
- if path == p:
1249
- return name
1250
- return os.path.splitext(os.path.basename(p))[0]
1251
-
1252
- selected_path = st.selectbox(
1253
- "Track",
1254
- options=options,
1255
- index=options.index(st.session_state.music_track_path),
1256
- format_func=_fmt,
1257
- key="music_track_path",
1258
- disabled=not enabled,
1259
- )
1260
- src_url = _load_audio_data_url(selected_path) if enabled else None
1261
- _mount_background_audio(enabled, src_url, (st.session_state.music_volume or 0) / 100)
1262
- else:
1263
- st.caption("Place .mp3 files in wrdler/assets/audio/music to enable music.")
1264
- _mount_background_audio(False, None, 0.0)
1265
-
1266
- _inject_audio_control_sync()
1267
- st.markdown(versions_html(), unsafe_allow_html=True)
1268
-
1269
 
1270
  # NOTE: Radar/scope visualization functions removed for Wrdler (Sprint 3)
1271
  # - get_scope_image() removed
@@ -1658,6 +1405,16 @@ def _render_guess_form(state: GameState):
1658
  .st-emotion-cache-1xwdq91, .st-emotion-cache-1r70o5 {
1659
  max-width: max-content; min-width:33%;
1660
  }
 
 
 
 
 
 
 
 
 
 
1661
  }
1662
  </style>
1663
  """,
@@ -2319,10 +2076,6 @@ if _Dialog:
2319
  @_Dialog("Game Over")
2320
  def _game_over_dialog(state: GameState):
2321
  _game_over_content(state)
2322
-
2323
- @_Dialog("Filter Results")
2324
- def _filter_results_dialog(count, words, filename):
2325
- _filter_results_content(count, words, filename)
2326
  else:
2327
  def _game_over_dialog(state: GameState):
2328
  modal_ctx = getattr(st, "modal", None)
@@ -2334,15 +2087,6 @@ else:
2334
  st.subheader("Game Over")
2335
  _game_over_content(state)
2336
 
2337
- def _filter_results_dialog(count, words, filename):
2338
- modal_ctx = getattr(st, "modal", None)
2339
- if callable(modal_ctx):
2340
- with modal_ctx("Filter Results"):
2341
- _filter_results_content(count, words, filename)
2342
- else:
2343
- st.info(f"Removed {count} words from {filename}")
2344
- st.text_area("Removed Words", value="\n".join(words), height=200)
2345
-
2346
  def _render_game_over(state: GameState):
2347
  # Use hide_gameover_overlay (same key as run_app) with inverted logic
2348
  visible = not st.session_state.get("hide_gameover_overlay", False) and is_game_over(state)
@@ -2412,44 +2156,16 @@ def _on_game_option_change() -> None:
2412
  # Start a fresh game with updated options
2413
  _new_game()
2414
 
2415
- def _on_wordlist_change() -> None:
2416
- """
2417
- Callback when wordlist selection changes.
2418
- Updates session state flags for AI vs file mode.
2419
- """
2420
- selected = st.session_state.get("wordlist_selector")
2421
-
2422
- if selected == "AI Generated":
2423
- st.session_state.use_ai_wordlist = True
2424
- # Preserve current AI topic or use default
2425
- if "ai_topic" not in st.session_state:
2426
- st.session_state.ai_topic = "English"
2427
- # Don't trigger new game automatically for AI mode
2428
- # User must click Generate button explicitly
2429
- else:
2430
- st.session_state.use_ai_wordlist = False
2431
- st.session_state.selected_wordlist = selected
2432
- # Trigger new game immediately for file-based wordlists
2433
- _on_game_option_change()
2434
-
2435
-
2436
- def _on_ai_generate() -> None:
2437
- """
2438
- Callback when Generate button is clicked for AI wordlist.
2439
- Triggers new game generation with AI words.
2440
- """
2441
- # Start a fresh game with AI-generated words
2442
- _on_game_option_change()
2443
-
2444
  def _render_footer(current_page: str = "play"):
2445
  """Render footer with navigation links to leaderboards and main game.
2446
 
2447
  Args:
2448
- current_page: Which page is currently active ("play", "today", "daily", "weekly", "history")
2449
  """
2450
  # Determine which link should be highlighted as active
2451
  play_active = "active" if current_page == "play" else ""
2452
  leaderboard_active = "active" if current_page in {"today", "daily", "weekly", "history"} else ""
 
2453
 
2454
  # Check if we're in challenge mode and need to preserve game_id
2455
  game_id = None
@@ -2467,16 +2183,14 @@ def _render_footer(current_page: str = "play"):
2467
  # Build URLs with game_id if in challenge mode
2468
  if game_id:
2469
  today_url = f"?page=today&game_id={game_id}"
2470
- daily_url = f"?page=daily&game_id={game_id}"
2471
- weekly_url = f"?page=weekly&game_id={game_id}"
2472
  leaderboard_url = f"?page=today&game_id={game_id}"
2473
  play_url = f"?game_id={game_id}"
 
2474
  else:
2475
  today_url = "?page=today"
2476
- daily_url = "?page=daily"
2477
- weekly_url = "?page=weekly"
2478
  leaderboard_url = "?page=today"
2479
  play_url = "/"
 
2480
 
2481
  st.markdown(
2482
  f"""
@@ -2537,6 +2251,7 @@ def _render_footer(current_page: str = "play"):
2537
  <nav class="bw-footer-nav">
2538
  <a href="{leaderboard_url}" title="View Leaderboards" target="_self" class="{leaderboard_active}">🏆 Leaderboard</a>
2539
  <a href="{play_url}" title="Play Wrdler" target="_self" class="{play_active}">🎮 Play</a>
 
2540
  </nav>
2541
  </div>
2542
  """,
@@ -2562,8 +2277,19 @@ def run_app():
2562
  pass
2563
  st.session_state["hide_gameover_overlay"] = True
2564
 
 
 
 
2565
  # Handle page navigation via query params
2566
  page = params.get("page", "")
 
 
 
 
 
 
 
 
2567
  if page in {"today", "daily", "weekly"}:
2568
  from .leaderboard_page import render_leaderboard_page
2569
  st.markdown(ocean_background_css, unsafe_allow_html=True)
@@ -2609,7 +2335,6 @@ def run_app():
2609
  st.markdown(ocean_background_css, unsafe_allow_html=True)
2610
  inject_ocean_layers() # <-- add the animated layers
2611
  _render_header()
2612
- _render_sidebar()
2613
 
2614
  state = _to_state()
2615
 
 
30
  from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
31
  from .modules.constants import APP_SETTINGS
32
  from .leaderboard import submit_score_to_all_leaderboards
33
+ from .settings_page import render_settings_page
34
 
35
  st.set_page_config(initial_sidebar_state="collapsed")
36
 
 
615
 
616
  /* Make the sidebar scrollable */
617
  section[data-testid="stSidebar"] {
618
+ display: none;
 
 
 
 
 
619
  }
620
 
621
  .st-emotion-cache-wp60of {
 
989
 
990
  inject_styles()
991
 
992
+ def _handle_audio():
993
+ # Initialize state if needed
994
+ if "music_enabled" not in st.session_state:
995
+ st.session_state.music_enabled = False
996
+ if "music_volume" not in st.session_state:
997
+ st.session_state.music_volume = 15
998
+ if "effects_volume" not in st.session_state:
999
+ st.session_state.effects_volume = 25
1000
+ if "enable_sound_effects" not in st.session_state:
1001
+ st.session_state.enable_sound_effects = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1002
 
1003
+ tracks = get_audio_tracks()
1004
+ enabled = st.session_state.music_enabled
1005
+
1006
+ if tracks:
1007
+ options = [p for _, p in tracks]
1008
+ if "music_track_path" not in st.session_state or st.session_state.music_track_path not in options:
1009
+ st.session_state.music_track_path = options[0]
1010
 
1011
+ selected_path = st.session_state.music_track_path
1012
+ src_url = _load_audio_data_url(selected_path) if enabled else None
1013
+ _mount_background_audio(enabled, src_url, (st.session_state.music_volume or 0) / 100)
1014
+ else:
1015
+ _mount_background_audio(False, None, 0.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1016
 
1017
  # NOTE: Radar/scope visualization functions removed for Wrdler (Sprint 3)
1018
  # - get_scope_image() removed
 
1405
  .st-emotion-cache-1xwdq91, .st-emotion-cache-1r70o5 {
1406
  max-width: max-content; min-width:33%;
1407
  }
1408
+ .st-emotion-cache-emqb34 {
1409
+ min-width: calc(50% - 1.5rem);
1410
+ width: calc(50% - 1.5rem);
1411
+ flex:unset;
1412
+ }
1413
+ .st-emotion-cache-1c94k11 {
1414
+ min-width: calc(50% - 1.5rem);
1415
+ width: calc(50% - 1.5rem);
1416
+ flex:unset;
1417
+ }
1418
  }
1419
  </style>
1420
  """,
 
2076
  @_Dialog("Game Over")
2077
  def _game_over_dialog(state: GameState):
2078
  _game_over_content(state)
 
 
 
 
2079
  else:
2080
  def _game_over_dialog(state: GameState):
2081
  modal_ctx = getattr(st, "modal", None)
 
2087
  st.subheader("Game Over")
2088
  _game_over_content(state)
2089
 
 
 
 
 
 
 
 
 
 
2090
  def _render_game_over(state: GameState):
2091
  # Use hide_gameover_overlay (same key as run_app) with inverted logic
2092
  visible = not st.session_state.get("hide_gameover_overlay", False) and is_game_over(state)
 
2156
  # Start a fresh game with updated options
2157
  _new_game()
2158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2159
  def _render_footer(current_page: str = "play"):
2160
  """Render footer with navigation links to leaderboards and main game.
2161
 
2162
  Args:
2163
+ current_page: Which page is currently active ("play", "today", "daily", "weekly", "history", "settings")
2164
  """
2165
  # Determine which link should be highlighted as active
2166
  play_active = "active" if current_page == "play" else ""
2167
  leaderboard_active = "active" if current_page in {"today", "daily", "weekly", "history"} else ""
2168
+ settings_active = "active" if current_page == "settings" else ""
2169
 
2170
  # Check if we're in challenge mode and need to preserve game_id
2171
  game_id = None
 
2183
  # Build URLs with game_id if in challenge mode
2184
  if game_id:
2185
  today_url = f"?page=today&game_id={game_id}"
 
 
2186
  leaderboard_url = f"?page=today&game_id={game_id}"
2187
  play_url = f"?game_id={game_id}"
2188
+ settings_url = f"?page=settings&game_id={game_id}"
2189
  else:
2190
  today_url = "?page=today"
 
 
2191
  leaderboard_url = "?page=today"
2192
  play_url = "/"
2193
+ settings_url = "?page=settings"
2194
 
2195
  st.markdown(
2196
  f"""
 
2251
  <nav class="bw-footer-nav">
2252
  <a href="{leaderboard_url}" title="View Leaderboards" target="_self" class="{leaderboard_active}">🏆 Leaderboard</a>
2253
  <a href="{play_url}" title="Play Wrdler" target="_self" class="{play_active}">🎮 Play</a>
2254
+ <a href="{settings_url}" title="Settings" target="_self" class="{settings_active}">⚙️ Settings</a>
2255
  </nav>
2256
  </div>
2257
  """,
 
2277
  pass
2278
  st.session_state["hide_gameover_overlay"] = True
2279
 
2280
+ # Handle audio globally
2281
+ _handle_audio()
2282
+
2283
  # Handle page navigation via query params
2284
  page = params.get("page", "")
2285
+
2286
+ if page == "settings":
2287
+ st.markdown(ocean_background_css, unsafe_allow_html=True)
2288
+ inject_ocean_layers()
2289
+ render_settings_page(_on_game_option_change)
2290
+ _render_footer(current_page="settings")
2291
+ return
2292
+
2293
  if page in {"today", "daily", "weekly"}:
2294
  from .leaderboard_page import render_leaderboard_page
2295
  st.markdown(ocean_background_css, unsafe_allow_html=True)
 
2335
  st.markdown(ocean_background_css, unsafe_allow_html=True)
2336
  inject_ocean_layers() # <-- add the animated layers
2337
  _render_header()
 
2338
 
2339
  state = _to_state()
2340