Surn commited on
Commit
99df003
·
1 Parent(s): d891c6e

0.2.5 - partial

Browse files
Files changed (3) hide show
  1. README.md +5 -2
  2. battlewords/__init__.py +1 -1
  3. battlewords/ui.py +427 -226
README.md CHANGED
@@ -105,10 +105,13 @@ docker run -p 8501:8501 battlewords
105
 
106
  ## Changelog
107
 
 
 
 
 
108
  - 0.2.4
109
  - Add music files to repo
110
- - disable music by default
111
- - fix finale pop up issue
112
 
113
  - 0.2.3
114
  - Update version information display
 
105
 
106
  ## Changelog
107
 
108
+ - 0.2.5
109
+ - fix finale pop up issue
110
+ - make grid cells square on wider devices
111
+
112
  - 0.2.4
113
  - Add music files to repo
114
+ - disable music by default
 
115
 
116
  - 0.2.3
117
  - Update version information display
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.2.4"
2
  __all__ = ["models", "generator", "logic", "ui"]
 
1
+ __version__ = "0.2.5"
2
  __all__ = ["models", "generator", "logic", "ui"]
battlewords/ui.py CHANGED
@@ -1,13 +1,13 @@
1
  from __future__ import annotations
2
  from . import __version__ as version
3
  from typing import Iterable, Tuple, Optional
 
4
 
5
  import matplotlib.pyplot as plt
6
  from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
7
  from matplotlib import colors as mcolors
8
  import tempfile
9
  import os
10
- import streamlit as st
11
  from PIL import Image
12
  import numpy as np
13
 
@@ -24,6 +24,7 @@ from .audio import (
24
  _inject_audio_control_sync,
25
  )
26
 
 
27
 
28
  CoordLike = Tuple[int, int]
29
 
@@ -122,9 +123,10 @@ def inject_styles() -> None:
122
  border-radius: 0;
123
  font-weight: 700;
124
  user-select: none;
125
- padding: 0.25rem 0.75rem;
126
  font-size: 1.4rem;
127
  min-height: 2.5rem;
 
128
  transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
129
  background: #1d64c8; /* Base cell color */
130
  color: #ffffff; /* Base text color for contrast */
@@ -144,170 +146,209 @@ def inject_styles() -> None:
144
  margin: 0 auto;
145
  text-align: center;
146
  }
147
- div[data-testid="stButton"] button {
148
- max-width: 100%;
149
- aspect-ratio: 1 / 1;
150
- border-radius: 0;
151
- #border: 1px solid #1d64c8;
152
- background: #1d64c8;
153
- color: #ffffff;
154
- font-weight: 700;
155
- padding: 0.25rem 0.75rem;
156
- min-height: 2.5rem;
157
- }
158
- .st-key-new_game_btn, .st-key-sort_wordlist_btn {
159
- margin: 0 auto;
160
- aspect-ratio: unset;
161
- }
162
- .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button {
163
- aspect-ratio: unset;
164
- text-align:center;
165
- height: auto;
166
- }
 
167
 
168
- /* Ensure grid cell columns expand equally for both buttons and revealed cells */
169
- div[data-testid="column"], .st-emotion-cache-zh2fnc {
170
- width: auto !important;
171
- flex: 1 1 auto !important;
172
- min-width: 100% !important;
173
- max-width: 100% !important;
174
- }
175
- .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc {
176
- gap:0.1rem !important;
177
- min-height: 2.5rem;
178
  }
179
 
180
- /* Ensure grid rows generated via st.columns do not wrap and can scroll horizontally. */
181
- .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] {
182
- flex-wrap: nowrap !important;
183
- overflow-x: auto !important;
184
- margin: 2px 0 !important; /* Reduce gap between rows */
 
 
185
  }
186
- .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] {
187
- flex: 0 0 auto !important;
 
 
 
 
 
 
 
 
 
 
188
  }
189
- .bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
190
- .st-emotion-cache-1n6tfoc {
191
- background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);
192
- gap: 0.1rem !important;
193
- color: white;
194
- # border: 10px solid;
195
- # border-image: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1;
196
- border-radius:15px;
197
- padding: 10px;
 
 
 
 
 
 
 
198
  }
199
- .st-emotion-cache-1n6tfoc::before {
200
- content: '';
201
- position: absolute;
202
- top: 0; left: 0; right: 0; bottom: 0;
203
- border-radius: 10px;
204
- margin: 5px; /* Border thickness */
205
  }
206
- .st-key-guess_input, .st-key-guess_submit {
207
- flex-direction: row;
208
- display: flex;
209
- flex-wrap: wrap;
210
- justify-content: flex-start;
211
- align-items: flex-end;
212
  }
 
213
 
214
- /* Mobile styles */
215
- @media (max-width: 640px) {
216
- /* Reverse the main two-column layout (radar above grid) and force full width */
217
- #bw-main-anchor + div[data-testid="stHorizontalBlock"] {
218
- flex-direction: column-reverse !important;
219
- width: 100% !important;
220
- max-width: 100vw !important;
221
- }
222
- #bw-main-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] {
223
- width: 100% !important;
224
- min-width: 100% !important;
225
- max-width: 100% !important;
226
- flex: 1 1 100% !important;
227
- }
228
 
229
- /* Keep grid rows on one line on small screens too */
230
- .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] {
231
- flex-wrap: nowrap !important;
232
- overflow-x: auto !important;
233
- margin: 2px 0 !important; /* Keep tighter row gap on mobile */
234
- }
235
- .st-emotion-cache-17i4tbh {
236
- min-width: calc(8.33333% - 1rem);
237
- }
 
 
 
 
 
 
 
 
 
 
 
238
  }
239
 
240
- .bold-text {
241
- font-weight: 700;
242
- }
243
- .blue-background {
244
- background:#1d64c8;
245
- opacity:0.9;
246
- }
247
- .metal-border {
248
- position: relative;
249
- padding: 20px;
250
- background: #333;
251
- color: white;
252
- border: 4px solid;
253
- border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1;
254
- border-radius: 8px;
255
- }
256
 
257
- .shiny-border {
258
- position: relative;
259
- padding: 12px;
260
- background: #333;
261
- color: white;
262
- border-radius: 1.25rem;
263
- overflow: hidden;
264
- }
265
 
266
- .shiny-border::before {
267
- content: '';
268
- position: absolute;
269
- top: 0;
270
- left: -100%;
271
- width: 100%;
272
- height: 100%;
273
- background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent);
274
- transition: left 0.5s;
275
- }
276
 
277
- .bw-score-panel-container {
278
- height: 100%;
279
- overflow: hidden;
280
- }
281
 
282
- .shiny-border:hover::before {
283
- left: 100%;
284
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
 
286
- /* Hit/Miss radio indicators - circular group */
287
- .bw-radio-group { display:flex; align-items:flex-start; gap: 10px; flex-flow: row;}
288
- .bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;}
289
- .bw-radio-circle {
290
- width: 46px; height: 46px; border-radius: 50%;
291
- border: 4px solid; /* border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1; */
292
- background: rgba(255,255,255,0.06);
293
- display: grid; place-items: center; color:#fff; font-weight:700;
294
- }
295
- .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); }
296
- .bw-radio-circle.active.hit { background: linear-gradient(135deg, rgba(0,255,127,0.18), rgba(0,128,64,0.38)); }
297
- .bw-radio-circle.active.hit .dot { background:#20d46c; box-shadow: 0 0 10px rgba(32,212,108,0.85); }
298
- .bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
299
- .bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
300
- .bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
301
-
302
- /* Make the sidebar scrollable */
303
- section[data-testid="stSidebar"] {
304
- max-height: 100vh;
305
- overflow-y: auto;
306
- overflow-x: hidden;
307
- scrollbar-width: thin;
308
- scrollbar-color: transparent transparent;
309
- }
310
- </style>
 
 
 
 
 
 
 
311
  """,
312
  unsafe_allow_html=True,
313
  )
@@ -316,6 +357,7 @@ def inject_styles() -> None:
316
  def _init_session() -> None:
317
  if "initialized" in st.session_state and st.session_state.initialized:
318
  return
 
319
 
320
  # Ensure a default selection exists before creating the puzzle
321
  files = get_wordlist_files()
@@ -351,7 +393,7 @@ def _new_game() -> None:
351
  # --- Preserve music settings ---
352
  music_enabled = st.session_state.get("music_enabled", False)
353
  music_track_path = st.session_state.get("music_track_path")
354
- music_volume = st.session_state.get("music_volume", 20)
355
  st.session_state.clear()
356
  if selected:
357
  st.session_state.selected_wordlist = selected
@@ -472,7 +514,7 @@ def _render_sidebar():
472
  # disabled by default
473
  st.session_state.music_enabled = False #if tracks else True
474
  if "music_volume" not in st.session_state:
475
- st.session_state.music_volume = 20
476
 
477
  enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
478
 
@@ -741,7 +783,7 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
741
  font-size: 1.4rem;
742
  }
743
  /* Further tighten vertical spacing between rows inside the grid container */
744
- .bw-grid-row-anchor + div[data-testid=\"stHorizontalBlock\"] {
745
  margin: 2px 0 !important;
746
  }
747
  .st-emotion-cache-14d5v98 {
@@ -835,6 +877,24 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
835
  _sync_back(state)
836
  st.rerun()
837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
 
839
  def _render_hit_miss(state: GameState):
840
  # Determine last reveal outcome from last_action string
@@ -917,93 +977,234 @@ def _render_guess_form(state: GameState):
917
  st.rerun()
918
 
919
 
 
920
  def _render_score_panel(state: GameState):
921
- # Only show modal from here if game is over AND overlay wasn't dismissed
922
- if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
923
- _render_game_over(state)
924
- else:
925
- # Build a simple table with independent column backgrounds and visible gaps
926
- rows_html = []
927
- # Header row
928
- header_html = (
929
- "<tr>"
930
- "<th class=\"blue-background bold-text\">Word</th>"
931
- "<th class=\"blue-background bold-text\">Letters</th>"
932
- "<th class=\"blue-background bold-text\">Extra</th>"
933
- "</tr>"
934
- )
935
- rows_html.append(header_html)
936
 
937
- for w in state.puzzle.words:
938
- pts = state.points_by_word.get(w.text, 0)
939
- if pts > 0 or state.game_mode == "too easy":
940
- word_display = w.text
941
- letters_display = len(w.text)
942
  # Extra = total points for the word minus its length (bonus earned)
943
- extra_pts = max(0, pts - letters_display)
944
- row_html = (
945
- "<tr>"
946
- f"<td class=\"blue-background \"'>{word_display}</td>"
947
- f"<td class=\"blue-background \"'>{letters_display}</td>"
948
- f"<td class=\"blue-background \"'>{extra_pts}</td>"
949
- "</tr>"
950
- )
951
- rows_html.append(row_html)
952
- total_row_html = (f"<tr class=\"blue-background\"><td colspan='3'><h3 class=\"bold-text\">Total: {state.score}</h3></td></tr>")
953
- rows_html.append(total_row_html)
954
- table_html = (
955
- "<table class='shiny-border' style=\"background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); width:100%; margin: 0 auto;border-collapse:separate; border-spacing:0;\">"
956
- f"{''.join(rows_html)}"
957
- "</table>"
958
- )
959
- st.markdown(f"<div class='bw-score-panel-container'>{table_html}</div>", unsafe_allow_html=True)
960
-
961
-
962
 
 
963
  def _render_game_over(state: GameState):
964
- # Prepare table rows for words
 
 
 
 
 
965
  word_rows = []
966
  for w in state.puzzle.words:
967
- pts = state.points_by_word.get(w.text, 0)
968
  extra_pts = max(0, pts - len(w.text))
969
  word_rows.append(
970
- f"<tr><td class='blue-background'>{w.text}</td><td class='blue-background'>{len(w.text)}</td><td class='blue-background'>{extra_pts}</td></tr>"
971
  )
972
  table_html = (
973
- "<table class='shiny-border' style=\"background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666); width:100%; margin: 0 auto;border-collapse:separate; border-spacing:0;\">"
974
- "<tr>"
975
- "<th class='blue-background bold-text'>Word</th>"
976
- "<th class='blue-background bold-text'>Letters</th>"
977
- "<th class='blue-background bold-text'>Extra</th>"
978
- "</tr>"
979
- f"{''.join(word_rows)}"
980
- f"<tr class='blue-background'><td colspan='3'><h3 class='bold-text'>Total: {state.score}</h3></td></tr>"
 
981
  "</table>"
982
  )
983
- # Overlay HTML with close link (forces navigation in same tab)
984
- st.markdown(
985
- f'''
986
- <div id="bw-modal-overlay" style="position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999;background:rgba(20,80,180,0.95);display:flex;flex-direction:column;justify-content:center;align-items:center;">
987
- <div class="shiny-border" style="position:relative;background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);padding:2rem 3rem;box-shadow:0 0 32px #1d64c8;min-width:340px;max-width:90vw; margin:8vh auto 5vh;">
988
- <a href="?overlay=0" target="_self" title="Close" style="position:absolute;top:12px;right:12px;display:inline-grid;place-items:center;width:40px;height:40px;border-radius:50%;background:rgba(0,0,0,0.25);color:#fff;text-decoration:none;font-size:1.6rem;font-weight:700;">&times;</a>
989
- <h1 style="color:#fff;font-size:2.5rem;margin-bottom:0.5rem;">Congratulations!</h1>
990
- <h2 style="color:#fff;font-size:2rem;margin-bottom:1rem;">Game Over</h2>
991
- <div style="font-size:1.5rem;color:#fff;margin-bottom:1rem;">Final score: <span style="color:#1ca41c;font-weight:800;">{state.score}</span></div>
992
- <div style="font-size:1.2rem;color:#fff;margin-bottom:2rem;">Tier: <strong>{compute_tier(state.score)}</strong></div>
993
- <div style="margin-bottom:2rem;">{table_html}</div>
994
- <div style="color:#fff;opacity:0.7;font-size:1rem;margin-bottom:2rem;background:#1d64c8;text-align:center;">Thank you for playing BattleWords!</div>
995
- </div>
996
- </div>
997
- ''', unsafe_allow_html=True)
998
- st.markdown("<div style='height:32px'></div>", unsafe_allow_html=True)
999
- if st.button("New Game", key="modal_new_game_btn", help="Start a new game", type="primary"):
1000
- _new_game()
1001
- st.markdown(versions_html(), unsafe_allow_html=True)
1002
- st.stop()
1003
 
1004
-
1005
- def _sort_wordlist(filename):
1006
- import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1007
  import time # Add this import
1008
 
1009
  WORDS_DIR = os.path.join(os.path.dirname(__file__), "words")
@@ -1045,7 +1246,7 @@ def run_app():
1045
 
1046
  # Anchor to target the main two-column layout for mobile reversal
1047
  st.markdown('<div id="bw-main-anchor"></div>', unsafe_allow_html=True)
1048
- left, right = st.columns([2, 2], gap="medium")
1049
  with right:
1050
  _render_radar(state.puzzle, size=state.grid_size, r_max=0.8, max_frames=25, sinusoid_expand=True, stagger_radar=False, show_ticks=st.session_state.get("show_grid_ticks", False))
1051
  one, two = st.columns([1, 3], gap="medium")
 
1
  from __future__ import annotations
2
  from . import __version__ as version
3
  from typing import Iterable, Tuple, Optional
4
+ import streamlit as st
5
 
6
  import matplotlib.pyplot as plt
7
  from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
8
  from matplotlib import colors as mcolors
9
  import tempfile
10
  import os
 
11
  from PIL import Image
12
  import numpy as np
13
 
 
24
  _inject_audio_control_sync,
25
  )
26
 
27
+ st.set_page_config(initial_sidebar_state="collapsed")
28
 
29
  CoordLike = Tuple[int, int]
30
 
 
123
  border-radius: 0;
124
  font-weight: 700;
125
  user-select: none;
126
+ padding: 0.5rem 0.75rem;
127
  font-size: 1.4rem;
128
  min-height: 2.5rem;
129
+ min-width: 1.25em;
130
  transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
131
  background: #1d64c8; /* Base cell color */
132
  color: #ffffff; /* Base text color for contrast */
 
146
  margin: 0 auto;
147
  text-align: center;
148
  }
149
+ div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; border-radius: 0; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 2.5rem;}
150
+ .st-key-new_game_btn, .st-key-sort_wordlist_btn { margin: 0 auto; aspect-ratio: unset; }
151
+ .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto;}
152
+
153
+ div[data-testid="column"], .st-emotion-cache-zh2fnc { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; }
154
+ .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.1rem !important; min-height: 2.5rem; min-width: 2.5rem;}
155
+
156
+ .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
157
+ .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
158
+ .bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
159
+ .st-emotion-cache-1n6tfoc { background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); gap: 0.1rem !important; color: white; border-radius:15px; padding: 10px 10px 10px 5px; }
160
+ .st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
161
+ .st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
162
+
163
+ /* grid adjustments */
164
+ @media (min-width: 560px){
165
+ div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}
166
+ .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
167
+ .st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 1 / 1; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
168
+ /*.st-emotion-cache-1n6tfoc { aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}*/
169
+ .st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
170
 
 
 
 
 
 
 
 
 
 
 
171
  }
172
 
173
+ /* Mobile styles */
174
+ @media (max-width: 640px) {
175
+ .bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:40px;}
176
+ #bw-main-anchor + div[data-testid="stHorizontalBlock"] { flex-direction: column-reverse !important; width: 100% !important; max-width: 100vw !important; }
177
+ #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; }
178
+ .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
179
+ .st-emotion-cache-17i4tbh { min-width: calc(8.33333% - 1rem); }
180
  }
181
+
182
+ .bold-text { font-weight: 700; }
183
+ .blue-background { background:#1d64c8; opacity:0.9; }
184
+ .metal-border { position: relative; padding: 20px; background: #333; color: white; border: 4px solid; border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1a1, #666666) 1; border-radius: 8px; }
185
+ .shiny-border { position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
186
+ .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; }
187
+ .bw-score-panel-container { height: 100%; overflow: hidden; }
188
+ .shiny-border:hover::before { left: 100%; }
189
+
190
+ .bw-radio-group { display:flex; align-items:flex-start; gap: 10px; flex-flow: row; }
191
+ @media (max-width:1000px) and (min-width:641px) {
192
+ .bw-radio-group { flex-wrap:wrap; gap: 5px; }
193
  }
194
+ .bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;}
195
+ .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; }
196
+ .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); }
197
+ .bw-radio-circle.active.hit { background: linear-gradient(135deg, rgba(0,255,127,0.18), rgba(0,128,64,0.38)); }
198
+ .bw-radio-circle.active.hit .dot { background:#20d46c; box-shadow: 0 0 10px rgba(32,212,108,0.85); }
199
+ .bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
200
+ .bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
201
+ .bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
202
+
203
+ /* Make the sidebar scrollable */
204
+ section[data-testid="stSidebar"] {
205
+ max-height: 100vh;
206
+ overflow-y: auto;
207
+ overflow-x: hidden;
208
+ scrollbar-width: thin;
209
+ scrollbar-color: transparent transparent;
210
  }
211
+
212
+ .st-emotion-cache-wp60of {
213
+ width: 720px;
214
+ position: absolute;
215
+ max-width:100%;
 
216
  }
217
+
218
+ @media (min-width:720px) {
219
+ .st-emotion-cache-wp60of {
220
+ left: calc(calc(100% - 720px) / 2);
 
 
221
  }
222
+ }
223
 
224
+ /* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
225
+ .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; }
226
+ /* Generic hide utility */
227
+ .hide { display: none !important; pointer-events: none !important; }
228
+ </style>
229
+ """,
230
+ unsafe_allow_html=True,
231
+ )
 
 
 
 
 
 
232
 
233
+ # Bridge to control the wrapper around the iframe component from the parent page
234
+ def _inject_parent_modal_bridge() -> None:
235
+ if st.session_state.get("_bw_parent_bridge", False):
236
+ return
237
+ st.session_state["_bw_parent_bridge"] = True
238
+ st.markdown(
239
+ """
240
+ <script>
241
+ (function(){
242
+ // Debug: script injection confirmation
243
+ alert('Parent modal bridge script loaded');
244
+
245
+ // Find the Streamlit wrapper around the iframe that renders the modal (components.html wrapper)
246
+ function findWrapper(iframe) {
247
+ if (!iframe) return null;
248
+ // Try Streamlit's typical element wrapper first, then fallback to parentElement
249
+ var el = iframe.closest('.st-emotion-cache-wp60of') || iframe.parentElement;
250
+ // Debug: which wrapper did we find
251
+ alert('findWrapper called, found: ' + (el ? el.className : 'null'));
252
+ return el;
253
  }
254
 
255
+ // Listen for messages from the iframe (modal page) to toggle visibility/positioning
256
+ window.addEventListener('message', function(e) {
257
+ alert('Message event received');
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
+ var data = e.data || {};
260
+ var isVisibility = data && data.type === 'bw-bootstrap-modal-visibility';
261
+ var isStreamlitSet = data && data.type === 'streamlit:setComponentValue' && data.key === 'show_gameover_overlay';
 
 
 
 
 
262
 
263
+ // Only handle our two message types
264
+ if (!(isVisibility || isStreamlitSet)) return;
265
+ alert('Visibility or StreamlitSet message detected');
 
 
 
 
 
 
 
266
 
267
+ // Decide if the modal should be visible based on message type
268
+ var shouldBeVisible = isVisibility ? !!data.visible : !!data.value;
269
+ alert('shouldBeVisible: ' + shouldBeVisible);
 
270
 
271
+ // Try to get the iframe element directly from the message source
272
+ var iframeEl = null;
273
+ try {
274
+ iframeEl = (e.source && e.source.frameElement) ? e.source.frameElement : null;
275
+ } catch(_) {
276
+ iframeEl = null;
277
+ }
278
+ alert('iframeEl direct: ' + (iframeEl ? iframeEl.tagName : 'null'));
279
+
280
+ // Fallback: scan iframes and match by contentWindow reference
281
+ if (!iframeEl) {
282
+ var frames = document.querySelectorAll('iframe');
283
+ for (var i = 0; i < frames.length; i++) {
284
+ var f = frames[i];
285
+ try {
286
+ if (f.contentWindow !== e.source) continue;
287
+ } catch(_) {
288
+ continue;
289
+ }
290
+ iframeEl = f;
291
+ break;
292
+ }
293
+ }
294
+ alert('iframeEl after fallback: ' + (iframeEl ? iframeEl.tagName : 'null'));
295
+ if (!iframeEl) return;
296
+
297
+ // Find the wrapper that we will show/hide and absolutely position
298
+ var wrap = findWrapper(iframeEl);
299
+ alert('Wrapper found: ' + (wrap ? wrap.className : 'null'));
300
+
301
+ if (wrap) {
302
+ wrap.classList.add('bw-modal-wrapper');
303
+ if (shouldBeVisible) {
304
+ alert('Setting wrapper to visible');
305
+ wrap.style.display = 'block';
306
+ wrap.style.position = 'fixed';
307
+ wrap.style.inset = '0';
308
+ wrap.style.zIndex = '99999';
309
+ wrap.style.width = '100vw';
310
+ wrap.style.height = '100vh';
311
+ wrap.style.margin = '0';
312
+ wrap.style.padding = '0';
313
+ } else {
314
+ alert('Setting wrapper to hidden');
315
+ wrap.style.display = 'none';
316
+ wrap.style.position = 'relative';
317
+ }
318
+ }
319
 
320
+ // Always toggle the iframe element itself for robustness
321
+ try {
322
+ if (shouldBeVisible) {
323
+ alert('Setting iframe to visible');
324
+ iframeEl.style.display = 'block';
325
+ iframeEl.style.width = '100vw';
326
+ iframeEl.style.height = '100vh';
327
+ iframeEl.style.borderRadius = '15px';
328
+ iframeEl.style.background = 'rgba(29, 100, 200, 0.5)';
329
+ iframeEl.style.pointerEvents = 'auto';
330
+ iframeEl.style.zIndex = '1000';
331
+ iframeEl.style.position = 'fixed';
332
+ iframeEl.style.inset = '0';
333
+ } else {
334
+ alert('Setting iframe to hidden');
335
+ iframeEl.style.display = 'none';
336
+ iframeEl.style.width = '0px';
337
+ iframeEl.style.height = '0px';
338
+ iframeEl.style.pointerEvents = 'none';
339
+ iframeEl.style.zIndex = '-1';
340
+ iframeEl.style.position = '';
341
+ iframeEl.style.inset = '';
342
+ }
343
+ } catch(_) {
344
+ alert('Error toggling iframe style');
345
+ }
346
+ }, false);
347
+
348
+ // Note: Streamlit's components.html uses an iframe sandbox that includes allow-scripts and allow-same-origin.
349
+ // That combination raises a general warning, but is required here for messaging and proper behavior.
350
+ })();
351
+ </script>
352
  """,
353
  unsafe_allow_html=True,
354
  )
 
357
  def _init_session() -> None:
358
  if "initialized" in st.session_state and st.session_state.initialized:
359
  return
360
+ # --- Preserve music settings ---
361
 
362
  # Ensure a default selection exists before creating the puzzle
363
  files = get_wordlist_files()
 
393
  # --- Preserve music settings ---
394
  music_enabled = st.session_state.get("music_enabled", False)
395
  music_track_path = st.session_state.get("music_track_path")
396
+ music_volume = st.session_state.get("music_volume", 15)
397
  st.session_state.clear()
398
  if selected:
399
  st.session_state.selected_wordlist = selected
 
514
  # disabled by default
515
  st.session_state.music_enabled = False #if tracks else True
516
  if "music_volume" not in st.session_state:
517
+ st.session_state.music_volume = 15
518
 
519
  enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
520
 
 
783
  font-size: 1.4rem;
784
  }
785
  /* Further tighten vertical spacing between rows inside the grid container */
786
+ .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] {
787
  margin: 2px 0 !important;
788
  }
789
  .st-emotion-cache-14d5v98 {
 
877
  _sync_back(state)
878
  st.rerun()
879
 
880
+ def _sort_wordlist(filename):
881
+ import os
882
+ import time # Add this import
883
+
884
+ WORDS_DIR = os.path.join(os.path.dirname(__file__), "words")
885
+ filepath = os.path.join(WORDS_DIR, filename)
886
+ sorted_words = sort_word_file(filepath)
887
+ # Optionally, write sorted words back to file
888
+ with open(filepath, "w", encoding="utf-8") as f:
889
+ # Re-add header if needed
890
+ f.write("# Optional: place a large A–Z word list here (one word per line).\n")
891
+ f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
892
+ for word in sorted_words:
893
+ f.write(f"{word}\n")
894
+ # Show a message in Streamlit
895
+ st.success(f"{filename} sorted by length and alphabetically. Starting new game in 5 seconds...")
896
+ time.sleep(5) # 5 second delay before starting new game
897
+ _new_game()
898
 
899
  def _render_hit_miss(state: GameState):
900
  # Determine last reveal outcome from last_action string
 
977
  st.rerun()
978
 
979
 
980
+ # -------------------- Score Panel --------------------
981
  def _render_score_panel(state: GameState):
982
+ # Always render the score table (overlay handled separately)
983
+ rows_html = []
984
+ header_html = (
985
+ "<tr>"
986
+ "<th class=\"blue-background bold-text\">Word</th>"
987
+ "<th class=\"blue-background bold-text\">Letters</th>"
988
+ "<th class=\"blue-background bold-text\">Extra</th>"
989
+ "</tr>"
990
+ )
991
+ rows_html.append(header_html)
 
 
 
 
 
992
 
993
+ for w in state.puzzle.words:
994
+ pts = state.points_by_word.get(w.text, 0)
995
+ if pts > 0 or state.game_mode == "too easy":
996
+ letters_display = len(w.text)
 
997
  # Extra = total points for the word minus its length (bonus earned)
998
+ extra_pts = max(0, pts - letters_display)
999
+ row_html = (
1000
+ "<tr>"
1001
+ f"<td class=\"blue-background \">{w.text}</td>"
1002
+ f"<td class=\"blue-background \">{letters_display}</td>"
1003
+ f"<td class=\"blue-background \">{extra_pts}</td>"
1004
+ "</tr>"
1005
+ )
1006
+ rows_html.append(row_html)
1007
+ total_row_html = (f"<tr class=\"blue-background\"><td colspan='3'><h3 class=\"bold-text\">Total: {state.score}</h3></td></tr>")
1008
+ rows_html.append(total_row_html)
1009
+ table_html = (
1010
+ "<table class='shiny-border' style=\"background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); width:100%; margin: 0 auto;border-collapse:separate; border-spacing:0;\">"
1011
+ f"{''.join(rows_html)}"
1012
+ "</table>"
1013
+ )
1014
+ st.markdown(f"<div class='bw-score-panel-container'>{table_html}</div>", unsafe_allow_html=True)
 
 
1015
 
1016
+ # -------------------- Game Over Modal --------------------
1017
  def _render_game_over(state: GameState):
1018
+ import streamlit.components.v1 as components
1019
+
1020
+ # Determine visibility
1021
+ visible = bool(st.session_state.get("show_gameover_overlay", True)) and is_game_over(state)
1022
+
1023
+ # Build table body HTML for modal content
1024
  word_rows = []
1025
  for w in state.puzzle.words:
1026
+ pts = st.session_state.points_by_word.get(w.text, 0)
1027
  extra_pts = max(0, pts - len(w.text))
1028
  word_rows.append(
1029
+ f"<tr><td class=\"blue-background\">{w.text}</td><td class=\"blue-background\">{len(w.text)}</td><td class=\"blue-background\">{extra_pts}</td></tr>"
1030
  )
1031
  table_html = (
1032
+ "<table class=\"table table-sm table-dark table-striped mb-0 shiny-border\" style=\"border-radius:0.75rem; overflow:hidden;\">"
1033
+ "<thead><tr>"
1034
+ "<th scope=\"col\">Word</th>"
1035
+ "<th scope=\"col\">Letters</th>"
1036
+ "<th scope=\"col\">Extra</th>"
1037
+ "</tr></thead>"
1038
+ f"<tbody>{''.join(word_rows)}"
1039
+ f"<tr><td colspan=\"3\"><h5 class=\"m-2\">Total: {state.score}</h5></td></tr>"
1040
+ "</tbody>"
1041
  "</table>"
1042
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1043
 
1044
+ # Server-driven initial modal state for CSS class/style
1045
+ modal_initial_class = "modal fade show" if visible else "modal fade"
1046
+ modal_initial_style = "display:block;" if visible else ""
1047
+
1048
+ # Modal markup inside iframe with wrapper div for scoping
1049
+ bootstrap_html = f"""
1050
+ <style>
1051
+ body {{
1052
+ background-color: transparent !important;
1053
+ }}
1054
+ /* Ensure shiny-border is available inside iframe scope */
1055
+ .shiny-border {{ position: relative; padding: 12px; color: white; border-radius: 1.25rem; overflow: hidden; }}
1056
+ .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; }}
1057
+ .bw-modal-iframe-root {{ position: fixed; inset: 0; background: rgba(29, 100, 200, 0.5); /* iframe page bg */ }}
1058
+ /* Make modal background transparent */
1059
+ .modal-content {{ background-color: transparent !important; box-shadow: none !important; width: calc(100% - 16px) !important;}}
1060
+ .modal-backdrop{{ background-color: transparent !important; z-index:999 !important;}}
1061
+ .bw-modal-iframe-root{{ z-index: 1000; }}
1062
+ .hide {{ display: none !important; z-index: -1000 !important;}}
1063
+
1064
+ /* Center the modal content */
1065
+ .modal-dialog {{
1066
+ display: flex;
1067
+ align-items: center;
1068
+ min-height: calc(100% - 1rem);
1069
+ margin: 0.5rem auto;
1070
+ border-radius: 0.75rem;
1071
+ max-width: 100%;
1072
+ }}
1073
+ .modal-title, .text-success {{
1074
+ font-weight: bold;
1075
+ font-size: 1.5rem;
1076
+ text-align: center;
1077
+ flex: auto;
1078
+ filter:drop-shadow(1px 1px 2px #003);
1079
+ }}
1080
+ .modal-content {{
1081
+ width: 100%;
1082
+ max-width: 640px;
1083
+ margin: 0 auto;
1084
+ border-radius: 0.75rem;
1085
+ overflow: hidden;
1086
+ }}
1087
+ </style>
1088
+ <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css\">\n
1089
+ <div class=\"bw-modal-iframe-root bw-bootstrap-scope\">\n
1090
+ <!-- Remove static aria-hidden to avoid focus conflicts; let Bootstrap manage it -->
1091
+ <div class=\"{modal_initial_class}\" id=\"bwGameOverModal\" tabindex=\"-1\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"bwGameOverTitle\" data-bs-backdrop=\"true\" style=\"{modal_initial_style}\">\n
1092
+ <div class=\"modal-dialog modal-dialog-centered modal-lg\">\n <div class=\"modal-content\">\n
1093
+ <div class=\"modal-body p-0\">\n <div class=\"shiny-border\" style=\"border-radius:1rem; box-shadow:0 0 32px #1d64c8; background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666); color:#fff;\">\n <div class=\"d-flex justify-content-between align-items-center px-3 pt-3\">\n <h5 class=\"modal-title m-0\" id=\"bwGameOverTitle\">Game Over</h5>\n <button type=\"button\" class=\"btn-close btn-close-white\" data-bs-dismiss=\"modal\" aria-label=\"Close\"><span class=\"visually-hidden\">Close</span></button>\n </div>\n <div class=\"p-3 pt-2\">\n <div class=\"mb-2\">Congratulations!</div>\n <div class=\"mb-2\">Final score: <strong class=\"text-success\">{state.score}</strong></div>\n <div class=\"mb-2\">Tier: <strong>{compute_tier(state.score)}</strong></div>\n <div class=\"mb-2\">Game Mode: <strong>{state.game_mode}</strong></div>\n <div class=\"mb-2\">Wordlist: <strong>{st.session_state.get('selected_wordlist', '')}</strong></div>\n <div class=\"mb-0\">{table_html}</div>\n </div>\n </div>\n </div>\n
1094
+ </div>\n </div>\n
1095
+ </div>\n
1096
+ </div>\n
1097
+ <script src=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js\"></script>\n
1098
+ <script>
1099
+ // Human-readable, commented JavaScript to control the Bootstrap modal inside the iframe
1100
+ (function() {{
1101
+ // Acquire the modal element by id within this iframe
1102
+ var modalEl = document.getElementById('bwGameOverModal');
1103
+ if (!modalEl) {{
1104
+ console.warn('[BW Modal] #bwGameOverModal not found');
1105
+ return;
1106
+ }}
1107
+
1108
+ // Create or get a Bootstrap Modal controller for this element
1109
+ var m = bootstrap.Modal.getOrCreateInstance(modalEl, {{
1110
+ backdrop: true,
1111
+ keyboard: true
1112
+ }});
1113
+
1114
+ // Server-driven visibility state (computed in Python)
1115
+ var shouldShow = {str(visible).lower()};
1116
+
1117
+ // Show or hide based on server state
1118
+ if (shouldShow) {{
1119
+ // Ensure visible and let Bootstrap handle classes/backdrop
1120
+ modalEl.style.display = 'block';
1121
+ console.debug('[BW Modal] Showing modal');
1122
+ try {{ m.show(); }} catch(e) {{ console.warn('[BW Modal] show() failed', e); }}
1123
+ }} else {{
1124
+ // Hide if currently shown
1125
+ if (modalEl.classList.contains('show')) {{
1126
+ console.debug('[BW Modal] Hiding modal');
1127
+ try {{ m.hide(); }} catch(e) {{ console.warn('[BW Modal] hide() failed', e); }}
1128
+ }}
1129
+ // Reset styles after hide
1130
+ modalEl.classList.remove('show');
1131
+ modalEl.style.display = '';
1132
+ }}
1133
+
1134
+ // Helper to notify the parent page (Streamlit) to toggle wrapper around this iframe
1135
+ function notifyParent(visible) {{
1136
+ try {{
1137
+ window.parent.postMessage({{
1138
+ type: 'bw-bootstrap-modal-visibility',
1139
+ visible: visible
1140
+ }}, '*');
1141
+ }} catch (e) {{
1142
+ console.warn('[BW Modal] notifyParent failed', e);
1143
+ }}
1144
+ }}
1145
+
1146
+ // Send initial visibility to parent
1147
+ notifyParent(shouldShow);
1148
+
1149
+ // On show, remove any aria-hidden that might conflict and set focus to close button
1150
+ modalEl.addEventListener('show.bs.modal', function() {{
1151
+ try {{ modalEl.removeAttribute('aria-hidden'); }} catch (e) {{}}
1152
+ }});
1153
+
1154
+ modalEl.addEventListener('shown.bs.modal', function() {{
1155
+ try {{
1156
+ var btn = modalEl.querySelector('.btn-close');
1157
+ if (btn) btn.focus();
1158
+ }} catch (e) {{}}
1159
+ notifyParent(true);
1160
+ }});
1161
+
1162
+ // Utility to clear focus to avoid aria-hidden focus conflicts
1163
+ function blurActive() {{
1164
+ try {{
1165
+ if (document.activeElement) document.activeElement.blur();
1166
+ }} catch (e) {{}}
1167
+ }}
1168
+
1169
+ // On hide start, clear focus
1170
+ modalEl.addEventListener('hide.bs.modal', function() {{
1171
+ blurActive();
1172
+ }});
1173
+
1174
+ // On fully hidden, let Streamlit know and parent wrapper update
1175
+ function closeActions() {{
1176
+ try {{
1177
+ blurActive();
1178
+ window.parent.postMessage({{
1179
+ type: 'streamlit:setComponentValue',
1180
+ key: 'show_gameover_overlay',
1181
+ value: false
1182
+ }}, '*');
1183
+ }} catch (e) {{}}
1184
+ notifyParent(false);
1185
+ }}
1186
+ modalEl.addEventListener('hidden.bs.modal', closeActions);
1187
+
1188
+ // Close if backdrop clicked (click outside modal content)
1189
+ modalEl.addEventListener('click', function(ev) {{
1190
+ if (ev.target === modalEl) {{
1191
+ try {{ m.hide(); }} catch (e) {{}}
1192
+ }}
1193
+ }});
1194
+
1195
+ // Note: Streamlit components use an iframe sandbox that may include allow-scripts and allow-same-origin.
1196
+ // Browsers may warn that this combination allows escape of sandboxing; this is expected for Streamlit components.
1197
+ }})();
1198
+ </script>\n
1199
+ """
1200
+
1201
+ # Mount the iframe; keep zero height when hidden, parent bridge will hide wrapper too
1202
+ components.html(bootstrap_html, height=(720 if visible else 0), scrolling=False, width=720)
1203
+
1204
+ # Ensure parent bridge exists to control wrapper visibility/position
1205
+ _inject_parent_modal_bridge()
1206
+
1207
+ def _sort_wordlist(filename):
1208
  import time # Add this import
1209
 
1210
  WORDS_DIR = os.path.join(os.path.dirname(__file__), "words")
 
1246
 
1247
  # Anchor to target the main two-column layout for mobile reversal
1248
  st.markdown('<div id="bw-main-anchor"></div>', unsafe_allow_html=True)
1249
+ left, right = st.columns([3, 2], gap="medium")
1250
  with right:
1251
  _render_radar(state.puzzle, size=state.grid_size, r_max=0.8, max_frames=25, sinusoid_expand=True, stagger_radar=False, show_ticks=st.session_state.get("show_grid_ticks", False))
1252
  one, two = st.columns([1, 3], gap="medium")