Wire gatelock.LockConfig into every window, not just the lock overlay
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

self._colors = LockConfig(...) is now built unconditionally in
ScreenLocker.__init__ (previously only the fullscreen lock path built a
local `config`), and every raw hex literal across status_view.py,
_ui_flows.py, _ui_flows_relaxed.py, _sick_dialog.py,
_manual_workout_dialog.py, _manual_workout_widgets.py, and
_temperature_status_mixin.py now reads from it instead -- replacing
ad hoc greens/oranges/reds (#00ff00, #0066cc, #ff4444, ...) with the
shared success/accent/danger/warning/muted tokens. Also fixes the
verify-window and status-window paths that still hardcoded #1a1a1a for
the background.

This was the prerequisite the on-fill fix (previous commit) actually
needed: _window_setup.py/_ui_widgets.py/_heat_skip.py reference
self._colors, which nothing in the codebase constructed outside the
fullscreen lock path until this commit.

Verified: 859/859 tests, 100% coverage.
This commit is contained in:
Krzysztof Rudnicki 2026-07-21 21:45:59 +02:00
parent 96701134c6
commit 0c6f6957a8
13 changed files with 271 additions and 183 deletions

View File

@ -39,23 +39,28 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
def _show_manual_workout_form(self) -> None:
"""Render the manual-workout evidence form, or a budget-exhausted note."""
self.clear_container()
self._label("Log Manual Workout", color="#0088cc", pady=10)
self._label("Log Manual Workout", color=self._colors.accent, pady=10)
if _manual_workout.is_budget_exhausted(self.log_file):
self._text(
"Manual-workout budget exhausted for this window.",
color="#ff4444",
color=self._colors.danger,
)
self._text(
_manual_workout.budget_summary(self.log_file),
color=self._colors.muted,
)
self._text(_manual_workout.budget_summary(self.log_file), color="#888888")
row = self._button_row()
self._button(
row,
"BACK",
bg="#aa0000",
bg=self._colors.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=10)
return
self._text(_manual_workout.budget_summary(self.log_file), color="#88ccff")
self._text(
_manual_workout.budget_summary(self.log_file), color=self._colors.accent
)
self._build_manual_workout_form()
def _mw_scrollable_form(self) -> tk.Frame:
@ -67,11 +72,11 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
the inner form to the viewport so its two columns split the full width.
Harmless on StatusWindow, whose container already fills its window.
"""
outer = tk.Frame(self.container, bg="#1a1a1a")
outer = tk.Frame(self.container, bg=self._colors.bg)
outer.pack(fill="both", expand=True, pady=10)
canvas = tk.Canvas(outer, bg="#1a1a1a", highlightthickness=0)
canvas = tk.Canvas(outer, bg=self._colors.bg, highlightthickness=0)
scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview)
form = tk.Frame(canvas, bg="#1a1a1a")
form = tk.Frame(canvas, bg=self._colors.bg)
form.grid_columnconfigure(0, weight=1, uniform="mw")
form.grid_columnconfigure(1, weight=1, uniform="mw")
form.bind(
@ -129,8 +134,8 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
# Both sport sub-frames occupy the same full-width grid slot; only the
# selected one is shown (see _on_mw_sport_changed).
act_row = self._mw_next_full_row(form)
self._mw_tt_frame = tk.Frame(form, bg="#1a1a1a")
self._mw_other_frame = tk.Frame(form, bg="#1a1a1a")
self._mw_tt_frame = tk.Frame(form, bg=self._colors.bg)
self._mw_other_frame = tk.Frame(form, bg=self._colors.bg)
for sport_frame in (self._mw_tt_frame, self._mw_other_frame):
sport_frame.grid_columnconfigure(0, weight=1, uniform="mwact")
sport_frame.grid_columnconfigure(1, weight=1, uniform="mwact")
@ -168,19 +173,19 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
f"(min {MANUAL_WORKOUT_REFLECTION_MIN_CHARS} chars):",
)
self._mw_error_label = self._text("", color="#ff4444", pady=5)
self._mw_error_label = self._text("", color=self._colors.danger, pady=8)
button_row = self._button_row()
self._button(
button_row,
"SUBMIT",
bg="#0066cc",
bg=self._colors.accent,
command=self._submit_manual_workout_form,
width=12,
).pack(side="left", padx=10)
self._button(
button_row,
"BACK",
bg="#aa0000",
bg=self._colors.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=10)
@ -190,13 +195,13 @@ class ManualWorkoutDialogMixin(ManualWorkoutFormWidgetsMixin):
self._mw_sport_var = tk.StringVar(
value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
)
row = tk.Frame(parent, bg="#1a1a1a")
row = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
row,
text="Sport:",
font=("Arial", 16),
fg="white",
bg="#1a1a1a",
font=(self._colors.font_family, 16),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
tk.OptionMenu(
row,

View File

@ -44,7 +44,7 @@ class ManualWorkoutFormWidgetsMixin:
columnspan=2 if full else 1,
sticky="ew",
padx=12,
pady=6,
pady=8,
)
counters[id(parent)] = idx + (2 if full else 1)
@ -67,9 +67,9 @@ class ManualWorkoutFormWidgetsMixin:
label = tk.Label(
parent,
text=title,
font=("Arial", 20, "bold"),
fg="#88ccff",
bg="#1a1a1a",
font=(self._colors.font_family, 20, "bold"),
fg=self._colors.accent,
bg=self._colors.bg,
anchor="w",
)
self._mw_grid(parent, label, full=True)
@ -82,24 +82,24 @@ class ManualWorkoutFormWidgetsMixin:
the two-column grid. Paste stays disabled via ``disable_paste``.
"""
var = tk.StringVar()
cell = tk.Frame(parent, bg="#1a1a1a")
cell = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
cell,
text=label,
font=("Arial", 16),
fg="white",
bg="#1a1a1a",
font=(self._colors.font_family, 16),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
).pack(fill="x")
entry = tk.Entry(
cell,
textvariable=var,
font=("Arial", 18),
bg="#2a2a2a",
fg="white",
insertbackground="white",
font=(self._colors.font_family, 18),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
)
entry.pack(fill="x", pady=2)
entry.pack(fill="x", pady=4)
disable_paste(entry)
self._mw_grid(parent, cell)
return var
@ -109,13 +109,13 @@ class ManualWorkoutFormWidgetsMixin:
) -> tk.IntVar:
"""Add a half-width label + numeric Spinbox and return its IntVar."""
var = tk.IntVar(value=0)
row = tk.Frame(parent, bg="#1a1a1a")
row = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
row,
text=label,
font=("Arial", 16),
fg="white",
bg="#1a1a1a",
font=(self._colors.font_family, 16),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
tk.Spinbox(
row,
@ -123,47 +123,47 @@ class ManualWorkoutFormWidgetsMixin:
to=to,
textvariable=var,
width=4,
font=("Arial", 16),
font=(self._colors.font_family, 16),
).pack(side="left", padx=5)
self._mw_grid(parent, row)
return var
def _mw_textbox(self, parent: tk.Widget, label: str) -> tk.Text:
"""Add a full-width label + multi-line Text cell and return the widget."""
cell = tk.Frame(parent, bg="#1a1a1a")
cell = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
cell,
text=label,
font=("Arial", 16),
fg="white",
bg="#1a1a1a",
font=(self._colors.font_family, 16),
fg=self._colors.fg,
bg=self._colors.bg,
anchor="w",
).pack(fill="x", pady=(5, 0))
).pack(fill="x", pady=(8, 0))
text_widget = tk.Text(
cell,
height=3,
font=("Arial", 14),
bg="#2a2a2a",
fg="white",
insertbackground="white",
font=(self._colors.font_family, 14),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
)
text_widget.pack(pady=2, fill="x")
text_widget.pack(pady=4, fill="x")
disable_paste(text_widget)
self._mw_grid(parent, cell, full=True)
return text_widget
def _mw_rpe_row(self, parent: tk.Widget) -> None:
"""Add the RPE (rate of perceived exertion) spinbox row."""
row = tk.Frame(parent, bg="#1a1a1a")
row = tk.Frame(parent, bg=self._colors.bg)
tk.Label(
row,
text=(
f"RPE — perceived exertion "
f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):"
),
font=("Arial", 16),
fg="white",
bg="#1a1a1a",
font=(self._colors.font_family, 16),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
tk.Spinbox(
row,
@ -171,6 +171,6 @@ class ManualWorkoutFormWidgetsMixin:
to=MANUAL_WORKOUT_RPE_MAX,
textvariable=self._mw_rpe_var,
width=4,
font=("Arial", 16),
font=(self._colors.font_family, 16),
).pack(side="left", padx=5)
self._mw_grid(parent, row)

View File

@ -34,32 +34,34 @@ class SickDialogMixin:
history = _sick_tracker.load_history()
self._sick_history_cache: SickHistory = history
self.clear_container()
self._label("Sick Day Request", color="#cc6600", pady=10)
self._text(_sick_tracker.budget_summary(history), color="#ffaa00")
self._label("Sick Day Request", color=self._colors.warning, pady=10)
self._text(_sick_tracker.budget_summary(history), color=self._colors.warning)
recent = _sick_tracker.format_recent_justifications(history)
if recent:
self._text("Recent sick days:", font_size=14, color="#888888", pady=5)
self._text(recent, font_size=14, color="#cccccc", pady=5)
self._text(
"Recent sick days:", font_size=14, color=self._colors.muted, pady=8
)
self._text(recent, font_size=14, color=self._colors.muted, pady=8)
had_commitment = _sick_tracker.had_commitment_for_today(history)
if had_commitment:
self._text(
"⚠ Yesterday you committed to working out today.",
font_size=18,
color="#ff6666",
color=self._colors.danger,
)
self._text(
"Breaking the commitment costs 2 sick-budget days.",
font_size=14,
color="#ff6666",
color=self._colors.danger,
)
self._build_justification_form(had_commitment=had_commitment)
def _build_justification_form(self, *, had_commitment: bool) -> None:
"""Add justification form fields and submit button to the container."""
form = tk.Frame(self.container, bg="#1a1a1a")
form = tk.Frame(self.container, bg=self._colors.bg)
form.pack(pady=10)
self._sick_symptom_var = tk.StringVar()
@ -67,13 +69,16 @@ class SickDialogMixin:
self._sick_severity_var = tk.IntVar(value=5)
self._sick_text_widget = self._add_form_widgets(form)
self._sick_error_label = self._text("", color="#ff4444", pady=5)
self._sick_error_label = self._text("", color=self._colors.danger, pady=8)
button_row = self._button_row()
# Starts disabled during the forced-read delay -- field_bg (our
# neutral/secondary token) signals "not yet actionable" better than
# the accent used for an immediately-clickable submit.
self._sick_submit_button = self._button(
button_row,
"SUBMIT",
bg="#666666",
bg=self._colors.field_bg,
command=self._submit_sick_justification,
width=12,
)
@ -81,7 +86,7 @@ class SickDialogMixin:
self._button(
button_row,
"BACK",
bg="#aa0000",
bg=self._colors.field_bg,
command=self._start_phone_check,
width=12,
).pack(side="left", padx=10)
@ -103,14 +108,14 @@ class SickDialogMixin:
label="When did it start? (e.g. last night):",
variable=self._sick_onset_var,
)
sev_row = tk.Frame(parent, bg="#1a1a1a")
sev_row.pack(pady=5)
sev_row = tk.Frame(parent, bg=self._colors.bg)
sev_row.pack(pady=8)
tk.Label(
sev_row,
text="Severity (1-10):",
font=("Arial", 14),
fg="white",
bg="#1a1a1a",
font=(self._colors.font_family, 14),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(side="left", padx=5)
tk.Spinbox(
sev_row,
@ -118,26 +123,26 @@ class SickDialogMixin:
to=10,
textvariable=self._sick_severity_var,
width=4,
font=("Arial", 14),
font=(self._colors.font_family, 14),
).pack(side="left", padx=5)
tk.Label(
parent,
text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"),
font=("Arial", 14),
fg="white",
bg="#1a1a1a",
).pack(pady=5)
font=(self._colors.font_family, 14),
fg=self._colors.fg,
bg=self._colors.bg,
).pack(pady=8)
text_widget = tk.Text(
parent,
width=60,
height=6,
font=("Arial", 12),
bg="#2a2a2a",
fg="white",
insertbackground="white",
font=(self._colors.font_family, 14),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
)
text_widget.pack(pady=5)
text_widget.pack(pady=8)
_disable_paste(text_widget)
return text_widget
@ -199,32 +204,35 @@ class SickDialogMixin:
self._label(
"Commit to working out tomorrow?",
font_size=32,
color="#ffaa00",
color=self._colors.warning,
pady=20,
)
self._text(
# ~80 chars at font_size 16 would render as one unbroken line past the
# ~70-char readable range (rule 21) -- wrap it explicitly.
prompt = self._text(
"If you say YES and skip via 'I'm sick' tomorrow, "
"the sick day costs 2x normal.",
font_size=16,
)
prompt.config(wraplength=560, justify="center")
self._commitment_done_fn = on_done
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
self._commitment_timer_label = self._text(
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
color="#888888",
color=self._colors.muted,
)
row = self._button_row()
self._button(
row,
"YES",
bg="#00aa00",
bg=self._colors.success,
command=lambda: self._answer_commitment(commit=True),
width=12,
).pack(side="left", padx=10)
self._button(
row,
"NO",
bg="#aa0000",
bg=self._colors.field_bg,
command=lambda: self._answer_commitment(commit=False),
width=12,
).pack(side="left", padx=10)

View File

@ -40,21 +40,23 @@ class TemperatureStatusMixin:
self._label("Warsaw Temperature", font_size=16, pady=8)
result = self._temp_result
if result is None:
self._text("Checking Warsaw temperature…", font_size=12, color="#888888")
self._text(
"Checking Warsaw temperature…", font_size=12, color=self._colors.muted
)
return
if result.timed_out:
self._text(
f"Warsaw temperature check timed out after ~"
f"{int(HARD_TIMEOUT_SECONDS)}s — network may be down.",
font_size=12,
color="#ff8844",
color=self._colors.warning,
)
return
if result.temp_celsius is None:
self._text(
"Warsaw temperature check failed (network/API error).",
font_size=12,
color="#ff8844",
color=self._colors.warning,
)
return
hot = result.temp_celsius >= HEAT_SKIP_TEMP_THRESHOLD
@ -62,10 +64,14 @@ class TemperatureStatusMixin:
f"Warsaw: {result.temp_celsius:.0f}°C (heat-skip threshold "
f"{HEAT_SKIP_TEMP_THRESHOLD}°C)",
font_size=12,
color="#ff8844" if hot else "#cccccc",
color=self._colors.warning if hot else self._colors.muted,
)
if hot:
self._text("Would trigger heat-skip today.", font_size=11, color="#ff4444")
self._text(
"Would trigger heat-skip today.",
font_size=11,
color=self._colors.danger,
)
def _start_temperature_check(self) -> None:
"""Submit a background Warsaw-temperature fetch and start polling it.

View File

@ -22,7 +22,9 @@ class UIFlowsMixin:
def _start_phone_check(self) -> None:
"""Check phone for today's workout immediately at startup."""
self.clear_container()
self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30)
self._label(
"Checking phone...", font_size=36, color=self._colors.warning, pady=30
)
self._text("Looking for today's workout in StrongLifts...", font_size=18)
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
@ -40,42 +42,46 @@ class UIFlowsMixin:
def _show_retry_and_sick(self, message: str) -> None:
"""Show TRY AGAIN and (if budget allows) I'm sick after a failed check."""
self.clear_container()
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
self._text(message, color="#ffaa00")
self._label(
"No Workout Found", font_size=36, color=self._colors.danger, pady=20
)
self._text(message, color=self._colors.warning)
history = _sick_tracker.load_history()
self._text(_sick_tracker.budget_summary(history), color="#888888")
self._text(_manual_workout.budget_summary(self.log_file), color="#888888")
self._text(_sick_tracker.budget_summary(history), color=self._colors.muted)
self._text(
_manual_workout.budget_summary(self.log_file), color=self._colors.muted
)
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg="#0066cc",
bg=self._colors.accent,
command=self._start_phone_check,
width=12,
).pack(side="left", padx=10)
if _sick_tracker.is_budget_exhausted(history):
self._text(
"Sick budget exhausted. No 'I'm sick' option available.",
color="#ff6666",
color=self._colors.danger,
)
else:
self._button(
frame,
"I'm sick",
bg="#cc6600",
bg=self._colors.warning,
command=self.ask_if_sick,
width=12,
).pack(side="left", padx=10)
if _manual_workout.is_budget_exhausted(self.log_file):
self._text(
"Manual-workout budget exhausted. No manual-log option available.",
color="#ff6666",
color=self._colors.danger,
)
else:
self._button(
frame,
"Log Manual Workout",
bg="#0088cc",
bg=self._colors.accent,
command=self._show_manual_workout_form,
width=16,
).pack(side="left", padx=10)
@ -84,9 +90,14 @@ class UIFlowsMixin:
"""Show confirmation and unlock after a manual-workout entry is built."""
self.workout_data = entry
self.clear_container()
self._label("✓ Manual Workout Logged!", font_size=42, color="#00cc44", pady=30)
self._text(entry.get("source", ""), font_size=20, color="#aaffaa")
self._text("Unlocking...", font_size=18, color="#888888")
self._label(
"✓ Manual Workout Logged!",
font_size=42,
color=self._colors.success,
pady=30,
)
self._text(entry.get("source", ""), font_size=20, color=self._colors.success)
self._text("Unlocking...", font_size=18, color=self._colors.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
@ -100,9 +111,14 @@ class UIFlowsMixin:
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
self.clear_container()
self._label("✓ Workout Verified!", font_size=42, color="#00cc44", pady=30)
self._text(message, font_size=20, color="#aaffaa")
self._text("Unlocking...", font_size=18, color="#888888")
self._label(
"✓ Workout Verified!",
font_size=42,
color=self._colors.success,
pady=30,
)
self._text(message, font_size=20, color=self._colors.success)
self._text("Unlocking...", font_size=18, color=self._colors.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
elif status == "too_short":
@ -138,7 +154,9 @@ class UIFlowsMixin:
either unlocks (run verified) or calls ``on_failure``.
"""
self.clear_container()
self._label("Checking RunnerUp...", font_size=36, color="#ffaa00", pady=30)
self._label(
"Checking RunnerUp...", font_size=36, color=self._colors.warning, pady=30
)
self._text("Looking for today's run in RunnerUp...", font_size=18)
executor = ThreadPoolExecutor(max_workers=1)
self._runnerup_future = executor.submit(self._verify_runnerup_workout)
@ -154,9 +172,14 @@ class UIFlowsMixin:
self.workout_data["type"] = "runnerup_verified"
self.workout_data["source"] = message
self.clear_container()
self._label("✓ Run Verified!", font_size=42, color="#00cc44", pady=30)
self._text(message, font_size=20, color="#aaffaa")
self._text("Unlocking...", font_size=18, color="#888888")
self._label(
"✓ Run Verified!",
font_size=42,
color=self._colors.success,
pady=30,
)
self._text(message, font_size=20, color=self._colors.success)
self._text("Unlocking...", font_size=18, color=self._colors.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
else:
@ -171,12 +194,15 @@ class UIFlowsMixin:
def _get_sick_day_status(self) -> tuple[str, str]:
"""Determine sick day status text and color."""
if self._sick_mode_used_today():
return "Shutdown time already adjusted today", "#ffaa00"
return "Shutdown time already adjusted today", self._colors.warning
if self._adjust_shutdown_time_earlier():
return (
"Shutdown time moved 1.5 hours earlier ✓\n(Will revert tomorrow)"
), "#00aa00"
return "Could not adjust shutdown time (check permissions)", "#ff4444"
), self._colors.success
return (
"Could not adjust shutdown time (check permissions)",
self._colors.danger,
)
def _proceed_to_sick_countdown(self) -> None:
"""Start the (escalated) sick day countdown after justification."""
@ -202,7 +228,7 @@ class UIFlowsMixin:
countdown: int,
) -> None:
"""Display sick day UI labels and countdown."""
self._label("Sick Day Mode", color="#cc6600", pady=20)
self._label("Sick Day Mode", color=self._colors.warning, pady=20)
self._text(status_text, color=status_color)
minutes = countdown // 60
self._text(
@ -250,7 +276,7 @@ class UIFlowsMixin:
self.lockout_label = self._label(
f"Go work out!\nLocked for {self.lockout_time} seconds",
font_size=48,
color="#ff4444",
color=self._colors.danger,
pady=30,
)
self.countdown_label = self._label(
@ -298,10 +324,10 @@ class UIFlowsMixin:
self._label(
"Cannot Verify Workout",
font_size=36,
color="#ff8800",
color=self._colors.warning,
pady=20,
)
self._text(message, color="#ffaa00")
self._text(message, color=self._colors.warning)
self._text(
"Connect phone via ADB to skip this wait,\n"
"or wait for the penalty timer.\n\n"

View File

@ -23,7 +23,7 @@ class UIFlowsRelaxedMixin:
self._label(
"Verifying Workout",
font_size=36,
color="#ffaa00",
color=self._colors.warning,
pady=30,
)
self._text(
@ -59,15 +59,15 @@ class UIFlowsRelaxedMixin:
self._label(
"✓ Workout Verified!",
font_size=42,
color="#00cc44",
color=self._colors.success,
pady=30,
)
self._text(message, font_size=20, color="#aaffaa")
self._text(message, font_size=20, color=self._colors.success)
if adjusted:
self._text(
"Shutdown time moved later!",
font_size=20,
color="#ffaa00",
color=self._colors.warning,
)
self.root.after(2000, self.close)
else:
@ -79,22 +79,22 @@ class UIFlowsRelaxedMixin:
self._label(
"Workout Not Found",
font_size=36,
color="#ff4444",
color=self._colors.danger,
pady=20,
)
self._text(message, color="#ffaa00")
self._text(message, color=self._colors.warning)
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg="#0066cc",
bg=self._colors.accent,
command=self._start_verify_workout_check,
width=12,
).pack(side="left", padx=10)
self._button(
frame,
"Close",
bg="#aa0000",
bg=self._colors.field_bg,
command=self.close,
width=12,
).pack(side="left", padx=10)
@ -114,28 +114,28 @@ class UIFlowsRelaxedMixin:
self._label(
"Optional Day (Tue / Wed / Thu)",
font_size=30,
color="#ffaa00",
color=self._colors.warning,
pady=20,
)
self._text(
f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n"
"No penalty for skipping today.",
font_size=20,
color="#aaaaaa",
color=self._colors.muted,
pady=10,
)
frame = self._button_row()
self._button(
frame,
"Skip — No Penalty",
bg="#006600",
bg=self._colors.success,
command=self.close,
width=18,
).pack(side="left", padx=10)
self._button(
frame,
"Log Stronglift Workout",
bg="#0066cc",
bg=self._colors.accent,
command=self._start_relaxed_phone_check,
width=20,
).pack(side="left", padx=10)
@ -143,7 +143,9 @@ class UIFlowsRelaxedMixin:
def _start_relaxed_phone_check(self) -> None:
"""Run Stronglift check in relaxed mode (no screen grab, no sick option)."""
self.clear_container()
self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30)
self._label(
"Checking phone...", font_size=36, color=self._colors.warning, pady=30
)
self._text("Looking for today's workout in StrongLifts...", font_size=18)
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
@ -175,20 +177,22 @@ class UIFlowsRelaxedMixin:
def _show_relaxed_retry(self, message: str, status: str) -> None:
"""Show retry and skip-close when workout not found in relaxed mode."""
self.clear_container()
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
self._text(f"❌ {message}\n\nReason: {status}", color="#ffaa00")
self._label(
"No Workout Found", font_size=36, color=self._colors.danger, pady=20
)
self._text(f"❌ {message}\n\nReason: {status}", color=self._colors.warning)
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg="#0066cc",
bg=self._colors.accent,
command=self._start_relaxed_phone_check,
width=12,
).pack(side="left", padx=10)
self._button(
frame,
"Close (Skip)",
bg="#006600",
bg=self._colors.success,
command=self.close,
width=14,
).pack(side="left", padx=10)

View File

@ -140,21 +140,25 @@ class ScreenLocker(
self.demo_mode = demo_mode
self.lockout_time = 10 if demo_mode else 1800
self._lock: LockWindow | None = None
# One shared token source for every window/widget this app creates,
# lock or not -- see the `unified-design-system` skill. `mode`/`grab`/
# `disable_vt` only matter for the fullscreen lock window itself; the
# color/font fields apply everywhere, so this is built unconditionally.
self._colors = LockConfig(
mode="hard",
grab="local" if demo_mode else "global",
disable_vt=not demo_mode,
)
if verify_only:
self._setup_verify_window()
elif self._relaxed_day_mode:
self._setup_relaxed_day_window()
else:
config = LockConfig(
mode="hard",
grab="local" if demo_mode else "global",
disable_vt=not demo_mode,
)
self._lock = LockWindow(self.root, config, hooks=self)
self._lock = LockWindow(self.root, self._colors, hooks=self)
self._lock.setup()
if demo_mode:
self._setup_demo_close_button()
self.container = tk.Frame(self.root, bg="#1a1a1a")
self.container = tk.Frame(self.root, bg=self._colors.bg)
self.container.place(relx=0.5, rely=0.5, anchor="center")
self._phone_future: Future[tuple[str, str]] | None = None
self._runnerup_future: Future[tuple[str, str]] | None = None
@ -311,31 +315,33 @@ class ScreenLocker(
credit = self._apply_workout_credit()
self.clear_container()
self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30)
self._label("Great job! 💪", font_size=48, color=self._colors.success, pady=30)
if credit.shutdown_adjusted:
self._text(
"Shutdown time +2h later! 🎁",
font_size=24,
color="#ffaa00",
color=self._colors.warning,
)
if credit.extra_bonus_delta > 0:
self._text(
f"Extra workout today! +{credit.extra_bonus_delta}h tonight",
font_size=20,
color="#ffaa00",
color=self._colors.warning,
)
if credit.new_debt is not None:
self._text(
f"Workout debt: {credit.new_debt}",
font_size=20,
color="#ffaa00" if credit.new_debt > 0 else "#888888",
color=self._colors.warning
if credit.new_debt > 0
else self._colors.muted,
)
streak = current_streak(EXTRA_BENEFITS_FILE)
if streak >= 1:
self._text(
f"🔥 {streak}-week streak (5+ workouts each)",
font_size=14,
color="#888888",
color=self._colors.muted,
)
self._text("Screen Unlocked!", font_size=36, pady=20)
if self.workout_data.get("type") in (

View File

@ -28,6 +28,8 @@ import sys
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import LockConfig
from screen_locker import _manual_workout
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
from screen_locker._status_data import (
@ -56,6 +58,7 @@ if TYPE_CHECKING:
from screen_locker.screen_lock import ScreenLocker
_DEFAULT_LOG_FILE = Path(__file__).resolve().parent / "workout_log.json"
_STATUS_COLORS = LockConfig()
class StatusWindow(
@ -96,7 +99,8 @@ class StatusWindow(
self._temp_future: Future[TemperatureCheck] | None = None
self._temp_result: TemperatureCheck | None = None
self._last_snapshot = snapshot
self.container = tk.Frame(root, bg="#1a1a1a")
self._colors = _STATUS_COLORS
self.container = tk.Frame(root, bg=self._colors.bg)
self.container.pack(fill="both", expand=True)
self._start_temperature_check()
self.render(snapshot)
@ -117,15 +121,20 @@ class StatusWindow(
self._section_shutdown(self.container, snapshot.shutdown)
if self._phone_check_result is not None:
status, message = self._phone_check_result
color = "#00cc44" if status == "verified" else "#ff8844"
self._text(f"Phone check ({status}): {message}", font_size=13, color=color)
color = (
self._colors.success if status == "verified" else self._colors.warning
)
self._text(f"Phone check ({status}): {message}", font_size=16, color=color)
if self._credit_message is not None:
self._text(self._credit_message, font_size=13, color="#00cc44")
self._text(self._credit_message, font_size=16, color=self._colors.success)
# "Check Phone"/"Log Manual Workout" are the primary write actions
# (accent, high contrast per rule 3); "Refresh"/"Close" are secondary
# utility actions (muted) -- two tiers instead of four arbitrary hues.
frame = self._button_row()
self._button(
frame,
"Check Phone",
bg="#0066cc",
bg=self._colors.accent,
command=self._on_check_phone_clicked,
width=14,
).pack(side="left", padx=8)
@ -133,15 +142,23 @@ class StatusWindow(
self._button(
frame,
"Log Manual Workout",
bg="#0088cc",
bg=self._colors.accent,
command=self._show_manual_workout_form,
width=16,
).pack(side="left", padx=8)
self._button(
frame, "Refresh", bg="#006600", command=self._on_refresh_clicked, width=10
frame,
"Refresh",
bg=self._colors.field_bg,
command=self._on_refresh_clicked,
width=10,
).pack(side="left", padx=8)
self._button(
frame, "Close", bg="#aa0000", command=self.root.destroy, width=8
frame,
"Close",
bg=self._colors.field_bg,
command=self.root.destroy,
width=8,
).pack(side="left", padx=8)
def _section_today(self, parent: tk.Widget, day: DayStatus) -> None:
@ -154,10 +171,12 @@ class StatusWindow(
self._text(
f"{mark} Today ({day.label}): {entry_str}",
font_size=18,
color="#aaffaa" if day.counted else "#ffaa00",
color=self._colors.success if day.counted else self._colors.warning,
)
if day.source:
self._text(day.source, font_size=12, color="#888888", pady=2)
# Secondary provenance note on an already-shown entry -- a
# deliberately caption-sized exception to the 16px floor.
self._text(day.source, font_size=12, color=self._colors.muted, pady=4)
def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None:
"""Render this ISO week's per-day breakdown and totals."""
@ -172,17 +191,21 @@ class StatusWindow(
)
self._text(
f"{mark} {day.label}: {entry_str}",
font_size=12,
color="#cccccc",
pady=1,
font_size=16,
color=self._colors.muted,
pady=4,
)
if week.remaining > 0:
self._text(
f"Need {week.remaining} more this week.", font_size=13, color="#ffaa00"
f"Need {week.remaining} more this week.",
font_size=16,
color=self._colors.warning,
)
elif week.extra > 0:
self._text(
f"{week.extra} above the weekly minimum!", font_size=13, color="#00cc44"
f"{week.extra} above the weekly minimum!",
font_size=16,
color=self._colors.success,
)
def _section_lock_explanation(
@ -192,13 +215,15 @@ class StatusWindow(
del parent
self._label("Why the lock did/didn't fire", font_size=16, pady=8)
self._text(
expl.reason, font_size=13, color="#ff4444" if expl.fired else "#00cc44"
expl.reason,
font_size=16,
color=self._colors.danger if expl.fired else self._colors.success,
)
if expl.auto_upgrade.would_attempt:
self._text(
f"Pending auto-upgrade: {expl.auto_upgrade.reason}",
font_size=11,
color="#ffaa00",
font_size=14,
color=self._colors.warning,
)
def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None:
@ -208,8 +233,8 @@ class StatusWindow(
self._text(
f"{sick.used_7d}/{sick.budget_7d} week · {sick.used_30d}/{sick.budget_30d} "
f"month · {sick.used_90d}/{sick.budget_90d} quarter · debt {sick.debt}",
font_size=12,
color="#ff4444" if sick.exhausted else "#cccccc",
font_size=16,
color=self._colors.danger if sick.exhausted else self._colors.muted,
)
def _section_manual_workout_budget(
@ -221,8 +246,8 @@ class StatusWindow(
self._text(
f"{manual.used_7d}/{manual.budget_7d} week · "
f"{manual.used_30d}/{manual.budget_30d} month",
font_size=12,
color="#ff4444" if manual.exhausted else "#cccccc",
font_size=16,
color=self._colors.danger if manual.exhausted else self._colors.muted,
)
def _section_shutdown(
@ -236,23 +261,31 @@ class StatusWindow(
self._text(
f"Live config — Mon-Wed {mon_wed_hour:02d}:00, "
f"Thu-Sun {thu_sun_hour:02d}:00",
font_size=12,
font_size=16,
)
else:
self._text(
"Live shutdown config unavailable.", font_size=12, color="#ff8844"
"Live shutdown config unavailable.",
font_size=16,
color=self._colors.warning,
)
# Rest-of-week/next-week/explanation are speculative annotations, not
# the section's primary content -- a deliberate caption-sized
# exception to the 16px floor, all sharing one muted tone instead of
# three unrelated ad hoc grays.
rest_line = ", ".join(
f"{d.label} {d.hour:02d}:00" for d in shutdown.rest_of_week
)
self._text(f"Rest of week: {rest_line}", font_size=10, color="#cccccc")
self._text(f"Rest of week: {rest_line}", font_size=12, color=self._colors.muted)
next_line = ", ".join(
f"{d.label} {d.hour:02d}:00" for d in shutdown.next_week_preview
)
self._text(
f"Next week (speculative): {next_line}", font_size=10, color="#888888"
f"Next week (speculative): {next_line}",
font_size=12,
color=self._colors.muted,
)
self._text(shutdown.explanation, font_size=10, color="#666666")
self._text(shutdown.explanation, font_size=12, color=self._colors.muted)
def _on_refresh_clicked(self) -> None:
"""Clear any stale phone-check/credit/temperature results, re-check both."""
@ -314,7 +347,7 @@ def main(argv: list[str] | None = None) -> None:
root = tk.Tk()
root.title("Workout Status")
root.configure(bg="#1a1a1a")
root.configure(bg=_STATUS_COLORS.bg)
root.minsize(560, 200)
def refresh() -> None:

View File

@ -26,25 +26,25 @@ class TestSectionManualWorkoutBudget:
snap = _snapshot(
manual_workout_budget=_manual_workout_budget(used_7d=2, exhausted=True)
)
_make_window(mock_tk, snap)
window = _make_window(mock_tk, snap)
calls = [
c
for c in mock_tk.Label.call_args_list
if "week" in c.kwargs.get("text", "")
]
assert any(c.kwargs.get("fg") == "#ff4444" for c in calls)
assert any(c.kwargs.get("fg") == window._colors.danger for c in calls)
def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None:
snap = _snapshot(
manual_workout_budget=_manual_workout_budget(used_7d=0, exhausted=False)
)
_make_window(mock_tk, snap)
window = _make_window(mock_tk, snap)
calls = [
c
for c in mock_tk.Label.call_args_list
if "week" in c.kwargs.get("text", "")
]
assert any(c.kwargs.get("fg") == "#cccccc" for c in calls)
assert any(c.kwargs.get("fg") == window._colors.muted for c in calls)
class TestManualWorkoutButtonVisibility:

View File

@ -145,23 +145,23 @@ class TestSectionLockExplanation:
class TestSectionSickBudget:
def test_exhausted_uses_warning_color(self, mock_tk: MagicMock) -> None:
snap = _snapshot(sick_budget=_sick_budget(used_7d=1, exhausted=True))
_make_window(mock_tk, snap)
window = _make_window(mock_tk, snap)
calls = [
c
for c in mock_tk.Label.call_args_list
if "week" in c.kwargs.get("text", "")
]
assert any(c.kwargs.get("fg") == "#ff4444" for c in calls)
assert any(c.kwargs.get("fg") == window._colors.danger for c in calls)
def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None:
snap = _snapshot(sick_budget=_sick_budget(used_7d=0, exhausted=False))
_make_window(mock_tk, snap)
window = _make_window(mock_tk, snap)
calls = [
c
for c in mock_tk.Label.call_args_list
if "week" in c.kwargs.get("text", "")
]
assert any(c.kwargs.get("fg") == "#cccccc" for c in calls)
assert any(c.kwargs.get("fg") == window._colors.muted for c in calls)
class TestSectionShutdown:

View File

@ -46,7 +46,7 @@ class TestPhoneCheckResultRendering:
if "Phone check" in c.kwargs.get("text", "")
]
assert calls
assert calls[-1].kwargs.get("fg") == "#00cc44"
assert calls[-1].kwargs.get("fg") == window._colors.success
def test_non_verified_result_shown_in_orange(self, mock_tk: MagicMock) -> None:
window = _make_window(mock_tk, _snapshot())
@ -58,7 +58,7 @@ class TestPhoneCheckResultRendering:
if "Phone check" in c.kwargs.get("text", "")
]
assert calls
assert calls[-1].kwargs.get("fg") == "#ff8844"
assert calls[-1].kwargs.get("fg") == window._colors.warning
class TestButtons:

View File

@ -144,7 +144,7 @@ class TestGetSickDayStatus:
)
text, color = locker._get_sick_day_status()
assert "already adjusted" in text
assert color == "#ffaa00"
assert color == locker._colors.warning
def test_adjustment_success(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
@ -159,7 +159,7 @@ class TestGetSickDayStatus:
)
text, color = locker._get_sick_day_status()
assert "earlier" in text
assert color == "#00aa00"
assert color == locker._colors.success
def test_adjustment_failure(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
@ -174,7 +174,7 @@ class TestGetSickDayStatus:
)
text, color = locker._get_sick_day_status()
assert "Could not adjust" in text
assert color == "#ff4444"
assert color == locker._colors.danger
class TestShowRetryAndSick:

View File

@ -151,7 +151,7 @@ class TestSetupVerifyWindow:
)
locker.root.geometry.assert_called_with("600x400")
locker.root.configure.assert_called_with(
bg="#1a1a1a",
bg=locker._colors.bg,
cursor="arrow",
)
locker.root.protocol.assert_called_with(