Take gatelock 0.8.1: nested LockConfig

The grab axes are grab=GrabPolicy(kind=..., disable_vt=...,
preempt_weaker_holder=...) and every colour read goes through
config.palette; nothing about the lock's behaviour changed. Also carries
the pylint hook on 4.0.8 with the source-only scope config; the gate
stays at 8.0 until the remaining findings are cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHCbkJWXHjtTUaqvnHRAqA
This commit is contained in:
Krzysztof Rudnicki 2026-09-12 19:52:38 +02:00
parent fc9720fafe
commit 9b39196671
31 changed files with 278 additions and 211 deletions

View File

@ -98,7 +98,7 @@ repos:
- types-requests>=2.31.0
- repo: https://github.com/pylint-dev/pylint
rev: v3.3.2
rev: v4.0.8
hooks:
# Two hooks, because the three checks below are not defects in a test
# suite -- they are how pytest and unit-testing private mixins work --
@ -126,7 +126,7 @@ repos:
# direct URLs for one distribution, so any pin that drifts from
# the manifest becomes an unresolvable hook environment rather
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
- mcp>=2,<3
@ -146,7 +146,7 @@ repos:
# direct URLs for one distribution, so any pin that drifts from
# the manifest becomes an unresolvable hook environment rather
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
- mcp>=2,<3

View File

@ -53,7 +53,7 @@ ensure_runtime_deps() {
echo "WARNING: could not install $1 automatically" >&2
}
_pip_runtime_dep gatelock \
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.1#subdirectory=gatelock"
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock"
_pip_runtime_dep crdt_sync \
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.0#subdirectory=crdt-sync"
}

View File

@ -7,7 +7,7 @@ requires-python = ">=3.14"
# crdt-sync: shared CRDT merge scheme + GitHub-Contents-API sync transport,
# also used by diet_guard; here for phone-workout GitHub sync (Workstream C)
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays",
]
@ -178,6 +178,14 @@ persistent = true
jobs = 0
py-version = "3.14"
ignore = [".venv", "__pycache__"]
# Test suites intentionally use patterns (protected-access, fixture-arg
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
# basename of what pylint is *given*, and pre-commit hands it explicit file
# paths, so a directory name in `ignore` never fires under the hook.
ignore-paths = ["^(.*/)?tests/.*$"]
ignore-patterns = [".*\\.pyi$"]
[tool.pylint.messages_control]

View File

@ -5,8 +5,8 @@ codespell==2.4.3
coverage==7.16.0
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
mcp==2.1.1
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock
mcp==2.2.0
mypy==2.3.1
pre-commit==4.6.2
pylint==4.0.8

View File

@ -39,15 +39,15 @@ def build_heat_skip_content(
*both* edges if it ever outgrows the display -- and it has to be in the
fit check for that reason.
"""
outer = tk.Frame(parent, bg=_COLORS.bg)
outer = tk.Frame(parent, bg=_COLORS.palette.bg)
outer.place(relx=0.5, rely=0.5, anchor="center")
tk.Label(
outer,
text="\u2600 Too hot to workout?",
font=_COLORS.font("body", bold=True, family=_FONT),
bg=_COLORS.bg,
fg=_COLORS.warning,
bg=_COLORS.palette.bg,
fg=_COLORS.palette.warning,
).pack(pady=(0, _COLORS.space("sm")))
tk.Label(
@ -57,28 +57,28 @@ def build_heat_skip_content(
f" (threshold: {HEAT_SKIP_TEMP_THRESHOLD}\u00b0C)"
),
font=_COLORS.font("body", family=_FONT),
bg=_COLORS.bg,
fg=_COLORS.muted,
bg=_COLORS.palette.bg,
fg=_COLORS.palette.muted,
).pack(pady=_COLORS.space("xs"))
tk.Label(
outer,
text="Skip today's workout due to extreme heat?",
font=_COLORS.font("body", family=_FONT),
bg=_COLORS.bg,
fg=_COLORS.muted,
bg=_COLORS.palette.bg,
fg=_COLORS.palette.muted,
).pack(pady=(_COLORS.space("xs"), 0))
btn_frame = tk.Frame(outer, bg=_COLORS.bg)
btn_frame = tk.Frame(outer, bg=_COLORS.palette.bg)
btn_frame.pack(pady=_COLORS.space("lg"))
skip_button = tk.Button(
btn_frame,
text="Skip workout",
command=on_skip,
bg=_COLORS.warning,
fg=_COLORS.on_fill,
activebackground=_COLORS.warning,
bg=_COLORS.palette.warning,
fg=_COLORS.palette.on_fill,
activebackground=_COLORS.palette.warning,
font=_COLORS.font("label", family=_FONT),
padx=_COLORS.space("md"),
pady=_COLORS.space("sm"),
@ -91,9 +91,9 @@ def build_heat_skip_content(
btn_frame,
text="No, I'll workout",
command=on_decline,
bg=_COLORS.field_bg,
fg=_COLORS.fg,
activebackground=_COLORS.field_bg,
bg=_COLORS.palette.field_bg,
fg=_COLORS.palette.fg,
activebackground=_COLORS.palette.field_bg,
font=_COLORS.font("label", family=_FONT),
padx=_COLORS.space("md"),
pady=_COLORS.space("sm"),
@ -131,7 +131,7 @@ class HeatSkipMixin:
# This window is destroyed before the main GateRoot is created.
root = tk.Tk()
root.title("Extreme Heat")
root.configure(bg=_COLORS.bg)
root.configure(bg=_COLORS.palette.bg)
root.attributes("-fullscreen", True)
root.attributes("-topmost", True)
root.grab_set()

View File

@ -42,21 +42,21 @@ class ManualWorkoutDialogMixin(
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=self._colors.accent, pad="xs")
self._label("Log Manual Workout", color=self._colors.palette.accent, pad="xs")
if _manual_workout.is_budget_exhausted(self.log_file):
self._text(
"Manual-workout budget exhausted for this window.",
color=self._colors.danger,
color=self._colors.palette.danger,
)
self._text(
_manual_workout.budget_summary(self.log_file),
color=self._colors.muted,
color=self._colors.palette.muted,
)
row = self._button_row()
self._button(
row,
"BACK",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
@ -64,7 +64,7 @@ class ManualWorkoutDialogMixin(
self._text(
_manual_workout.budget_summary(self.log_file),
role="label",
color=self._colors.accent,
color=self._colors.palette.accent,
pad="xs",
)
self._build_manual_workout_form()
@ -106,8 +106,8 @@ class ManualWorkoutDialogMixin(
# 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=self._colors.bg)
self._mw_other_frame = tk.Frame(form, bg=self._colors.bg)
self._mw_tt_frame = tk.Frame(form, bg=self._colors.palette.bg)
self._mw_other_frame = tk.Frame(form, bg=self._colors.palette.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")
@ -151,20 +151,20 @@ class ManualWorkoutDialogMixin(
)
self._mw_error_label = self._text(
"", role="label", color=self._colors.danger, pad="xs"
"", role="label", color=self._colors.palette.danger, pad="xs"
)
button_row = self._button_row()
self._button(
button_row,
"SUBMIT",
bg=self._colors.accent,
bg=self._colors.palette.accent,
command=self._submit_manual_workout_form,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
button_row,
"BACK",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=self._on_manual_workout_cancelled,
width=12,
).pack(side="left", padx=self._colors.space("sm"))

View File

@ -48,13 +48,13 @@ class ManualWorkoutSportFieldsMixin:
self._mw_sport_var = tk.StringVar(
value=_manual_workout.SPORT_LABELS[_manual_workout.SPORT_TABLE_TENNIS]
)
row = tk.Frame(parent, bg=self._colors.bg)
row = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
row,
text="Sport:",
font=self._colors.font("body"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(side="left", padx=self._colors.space("xs"))
for label in _manual_workout.SPORT_LABELS.values():
tk.Radiobutton(
@ -65,14 +65,14 @@ class ManualWorkoutSportFieldsMixin:
# Radiobutton's command takes no argument -- bind the label.
command=lambda chosen=label: self._on_mw_sport_changed(chosen),
font=self._colors.font("body"),
fg=self._colors.fg,
bg=self._colors.bg,
activeforeground=self._colors.fg,
activebackground=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
activeforeground=self._colors.palette.fg,
activebackground=self._colors.palette.bg,
# Without selectcolor the indicator is a white blob on the dark
# overlay; the OptionMenu was the one unstyled widget in this
# form, which likely added to it reading as dead.
selectcolor=self._colors.field_bg,
selectcolor=self._colors.palette.field_bg,
highlightthickness=0,
).pack(side="left", padx=self._colors.space("xs"))
self._mw_grid(parent, row, full=True)

View File

@ -72,8 +72,8 @@ class ManualWorkoutFormWidgetsMixin:
parent,
text=title,
font=self._colors.font("body", bold=True),
fg=self._colors.accent,
bg=self._colors.bg,
fg=self._colors.palette.accent,
bg=self._colors.palette.bg,
anchor="w",
)
self._mw_grid(parent, label, full=True)
@ -95,7 +95,7 @@ class ManualWorkoutFormWidgetsMixin:
one field per form should ask.
"""
var = tk.StringVar()
cell = tk.Frame(parent, bg=self._colors.bg)
cell = tk.Frame(parent, bg=self._colors.palette.bg)
# Label beside the field, not above it. Stacked, each of the ten cells
# cost two lines plus the gap between them, and the form measured
# 1320px against a 768px panel; side-by-side each cell is one line
@ -105,17 +105,17 @@ class ManualWorkoutFormWidgetsMixin:
cell,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
anchor="w",
).pack(side="left", padx=(0, self._colors.space("xs")))
entry = tk.Entry(
cell,
textvariable=var,
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
bg=self._colors.palette.field_bg,
fg=self._colors.palette.fg,
insertbackground=self._colors.palette.fg,
**self._colors.focus_kwargs(),
)
entry.pack(side="left", fill="x", expand=True)
@ -130,13 +130,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=self._colors.bg)
row = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
row,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(side="left", padx=self._colors.space("xs"))
tk.Spinbox(
row,
@ -157,22 +157,22 @@ class ManualWorkoutFormWidgetsMixin:
whole rows of a form that has to fit a 768px panel, and two of them
sit side by side just as legibly.
"""
cell = tk.Frame(parent, bg=self._colors.bg)
cell = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
cell,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
anchor="w",
).pack(fill="x")
text_widget = tk.Text(
cell,
height=2,
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
bg=self._colors.palette.field_bg,
fg=self._colors.palette.fg,
insertbackground=self._colors.palette.fg,
**self._colors.focus_kwargs(),
)
text_widget.pack(pady=self._colors.space("xs"), fill="x")
@ -187,7 +187,7 @@ class ManualWorkoutFormWidgetsMixin:
def _mw_rpe_row(self, parent: tk.Widget) -> None:
"""Add the RPE (rate of perceived exertion) spinbox row."""
row = tk.Frame(parent, bg=self._colors.bg)
row = tk.Frame(parent, bg=self._colors.palette.bg)
tk.Label(
row,
text=(
@ -195,8 +195,8 @@ class ManualWorkoutFormWidgetsMixin:
f"({MANUAL_WORKOUT_RPE_MIN}-{MANUAL_WORKOUT_RPE_MAX}):"
),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(side="left", padx=self._colors.space("xs"))
tk.Spinbox(
row,
@ -234,7 +234,7 @@ class ManualWorkoutFormWidgetsMixin:
Still built on the primary surface only -- an independently scrolled
copy per monitor would show two different parts of one form.
"""
form = tk.Frame(self.container.first, bg=self._colors.bg)
form = tk.Frame(self.container.first, bg=self._colors.palette.bg)
# No outer gap: the budget line above and the first section heading
# below already separate the grid, and on a 1024x600 panel this form
# fits by single-digit pixels.

View File

@ -31,7 +31,7 @@ class SickCommitmentMixin:
self._label(
"Commit to working out tomorrow?",
role="display",
color=self._colors.warning,
color=self._colors.palette.warning,
pad="md",
)
# ~80 chars at font_size 16 would render as one unbroken line past the
@ -46,20 +46,20 @@ class SickCommitmentMixin:
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
self._commitment_timer_label = self._text(
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
color=self._colors.muted,
color=self._colors.palette.muted,
)
row = self._button_row()
self._button(
row,
"YES",
bg=self._colors.success,
bg=self._colors.palette.success,
command=lambda: self._answer_commitment(commit=True),
width=12,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
row,
"NO",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=lambda: self._answer_commitment(commit=False),
width=12,
).pack(side="left", padx=self._colors.space("sm"))

View File

@ -35,34 +35,39 @@ class SickDialogMixin(SickCommitmentMixin):
history = _sick_tracker.load_history()
self._sick_history_cache: SickHistory = history
self.clear_container()
self._label("Sick Day Request", color=self._colors.warning, pad="sm")
self._text(_sick_tracker.budget_summary(history), color=self._colors.warning)
self._label("Sick Day Request", color=self._colors.palette.warning, pad="sm")
self._text(
_sick_tracker.budget_summary(history), color=self._colors.palette.warning
)
recent = _sick_tracker.format_recent_justifications(history)
if recent:
self._text(
"Recent sick days:", role="label", color=self._colors.muted, pad="sm"
"Recent sick days:",
role="label",
color=self._colors.palette.muted,
pad="sm",
)
self._text(recent, role="label", color=self._colors.muted, pad="sm")
self._text(recent, role="label", color=self._colors.palette.muted, pad="sm")
had_commitment = _sick_tracker.had_commitment_for_today(history)
if had_commitment:
self._text(
"⚠ Yesterday you committed to working out today.",
role="body",
color=self._colors.danger,
color=self._colors.palette.danger,
)
self._text(
"Breaking the commitment costs 2 sick-budget days.",
role="label",
color=self._colors.danger,
color=self._colors.palette.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 = self.container.child_frame(bg=self._colors.bg)
form = self.container.child_frame(bg=self._colors.palette.bg)
form.pack(pady=self._colors.space("sm"))
self._sick_symptom_var = tk.StringVar()
@ -70,7 +75,9 @@ class SickDialogMixin(SickCommitmentMixin):
self._sick_severity_var = tk.IntVar(value=5)
self._sick_text_widget = self._add_form_widgets(form)
self._sick_error_label = self._text("", color=self._colors.danger, pad="sm")
self._sick_error_label = self._text(
"", color=self._colors.palette.danger, pad="sm"
)
button_row = self._button_row()
# Starts disabled during the forced-read delay -- field_bg (our
@ -79,7 +86,7 @@ class SickDialogMixin(SickCommitmentMixin):
self._sick_submit_button = self._button(
button_row,
"SUBMIT",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=self._submit_sick_justification,
width=12,
)
@ -87,7 +94,7 @@ class SickDialogMixin(SickCommitmentMixin):
self._button(
button_row,
"BACK",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=self._start_phone_check,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
@ -110,14 +117,14 @@ class SickDialogMixin(SickCommitmentMixin):
label="When did it start? (e.g. last night):",
variable=self._sick_onset_var,
)
sev_row = parent.child_frame(bg=self._colors.bg)
sev_row = parent.child_frame(bg=self._colors.palette.bg)
sev_row.pack(pady=self._colors.space("sm"))
sev_row.child_widgets(
tk.Label,
text="Severity (1-10):",
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(side="left", padx=self._colors.space("xs"))
sev_row.child_widgets(
tk.Spinbox,
@ -132,8 +139,8 @@ class SickDialogMixin(SickCommitmentMixin):
tk.Label,
text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"),
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
).pack(pady=self._colors.space("sm"))
text_widgets = TextGroup(
list(
@ -142,9 +149,9 @@ class SickDialogMixin(SickCommitmentMixin):
width=60,
height=6,
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
bg=self._colors.palette.field_bg,
fg=self._colors.palette.fg,
insertbackground=self._colors.palette.fg,
**self._colors.focus_kwargs(),
)
)

View File

@ -40,12 +40,16 @@ class StatusSectionsMixin:
self._text(
f"{mark} Today ({day.label}): {entry_str}",
role="body",
color=self._colors.success if day.counted else self._colors.warning,
color=self._colors.palette.success
if day.counted
else self._colors.palette.warning,
)
if day.source:
# Secondary provenance note on an already-shown entry -- a
# deliberately caption-sized exception to the 16px floor.
self._text(day.source, role="caption", color=self._colors.muted, pad="xs")
self._text(
day.source, role="caption", color=self._colors.palette.muted, pad="xs"
)
def _section_week(self, parent: tk.Widget, week: WeeklySummary) -> None:
"""Render this ISO week's per-day breakdown and totals."""
@ -61,20 +65,20 @@ class StatusSectionsMixin:
self._text(
f"{mark} {day.label}: {entry_str}",
role="body",
color=self._colors.muted,
color=self._colors.palette.muted,
pad="xs",
)
if week.remaining > 0:
self._text(
f"Need {week.remaining} more this week.",
role="body",
color=self._colors.warning,
color=self._colors.palette.warning,
)
elif week.extra > 0:
self._text(
f"{week.extra} above the weekly minimum!",
role="body",
color=self._colors.success,
color=self._colors.palette.success,
)
def _section_lock_explanation(
@ -86,13 +90,15 @@ class StatusSectionsMixin:
self._text(
expl.reason,
role="body",
color=self._colors.danger if expl.fired else self._colors.success,
color=self._colors.palette.danger
if expl.fired
else self._colors.palette.success,
)
if expl.auto_upgrade.would_attempt:
self._text(
f"Pending auto-upgrade: {expl.auto_upgrade.reason}",
role="label",
color=self._colors.warning,
color=self._colors.palette.warning,
)
def _section_sick_budget(self, parent: tk.Widget, sick: SickBudgetStatus) -> None:
@ -103,7 +109,9 @@ class StatusSectionsMixin:
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}",
role="body",
color=self._colors.danger if sick.exhausted else self._colors.muted,
color=self._colors.palette.danger
if sick.exhausted
else self._colors.palette.muted,
)
def _section_manual_workout_budget(
@ -116,7 +124,9 @@ class StatusSectionsMixin:
f"{manual.used_7d}/{manual.budget_7d} week · "
f"{manual.used_30d}/{manual.budget_30d} month",
role="body",
color=self._colors.danger if manual.exhausted else self._colors.muted,
color=self._colors.palette.danger
if manual.exhausted
else self._colors.palette.muted,
)
def _section_sync_backend(self, parent: tk.Widget) -> None:
@ -134,7 +144,9 @@ class StatusSectionsMixin:
self._text(
format_sync_line(status),
role="body",
color=self._colors.muted if status.healthy else self._colors.danger,
color=self._colors.palette.muted
if status.healthy
else self._colors.palette.danger,
)
def _section_shutdown(
@ -154,7 +166,7 @@ class StatusSectionsMixin:
self._text(
"Live shutdown config unavailable.",
role="body",
color=self._colors.warning,
color=self._colors.palette.warning,
)
# Rest-of-week/next-week/explanation are speculative annotations, not
# the section's primary content -- a deliberate caption-sized
@ -164,7 +176,9 @@ class StatusSectionsMixin:
f"{d.label} {d.hour:02d}:00" for d in shutdown.rest_of_week
)
self._text(
f"Rest of week: {rest_line}", role="caption", color=self._colors.muted
f"Rest of week: {rest_line}",
role="caption",
color=self._colors.palette.muted,
)
next_line = ", ".join(
f"{d.label} {d.hour:02d}:00" for d in shutdown.next_week_preview
@ -172,6 +186,8 @@ class StatusSectionsMixin:
self._text(
f"Next week (speculative): {next_line}",
role="caption",
color=self._colors.muted,
color=self._colors.palette.muted,
)
self._text(
shutdown.explanation, role="caption", color=self._colors.palette.muted
)
self._text(shutdown.explanation, role="caption", color=self._colors.muted)

View File

@ -40,7 +40,9 @@ class TemperatureStatusMixin:
result = self._temp_result
if result is None:
self._text(
"Checking Warsaw temperature…", role="caption", color=self._colors.muted
"Checking Warsaw temperature…",
role="caption",
color=self._colors.palette.muted,
)
return
if result.timed_out:
@ -48,14 +50,14 @@ class TemperatureStatusMixin:
f"Warsaw temperature check timed out after ~"
f"{int(HARD_TIMEOUT_SECONDS)}s — network may be down.",
role="caption",
color=self._colors.warning,
color=self._colors.palette.warning,
)
return
if result.temp_celsius is None:
self._text(
"Warsaw temperature check failed (network/API error).",
role="caption",
color=self._colors.warning,
color=self._colors.palette.warning,
)
return
hot = result.temp_celsius >= HEAT_SKIP_TEMP_THRESHOLD
@ -63,13 +65,13 @@ class TemperatureStatusMixin:
f"Warsaw: {result.temp_celsius:.0f}°C (heat-skip threshold "
f"{HEAT_SKIP_TEMP_THRESHOLD}°C)",
role="caption",
color=self._colors.warning if hot else self._colors.muted,
color=self._colors.palette.warning if hot else self._colors.palette.muted,
)
if hot:
self._text(
"Would trigger heat-skip today.",
role="caption",
color=self._colors.danger,
color=self._colors.palette.danger,
)
def _start_temperature_check(self) -> None:

View File

@ -30,7 +30,10 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin):
"""
self.clear_container()
self._label(
"Checking phone...", role="display", color=self._colors.warning, pad="lg"
"Checking phone...",
role="display",
color=self._colors.palette.warning,
pad="lg",
)
self._text("Looking for today's workout in StrongLifts...", role="body")
@ -54,52 +57,58 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin):
"""Show TRY AGAIN and (if budget allows) I'm sick after a failed check."""
self.clear_container()
self._label(
"No Workout Found", role="display", color=self._colors.danger, pad="md"
"No Workout Found",
role="display",
color=self._colors.palette.danger,
pad="md",
)
self._text(message, color=self._colors.warning)
self._text(message, color=self._colors.palette.warning)
# Never accuse the user of skipping a workout the machine simply could
# not see. On 2026-08-24 this screen said "No Workout Found" after a
# 1h57m session, because Firebase was unreadable and the GitHub mirror
# had been stale for nine days -- neither of which was shown here.
diagnosis = explain_findings(collect_source_findings())
if diagnosis:
self._text(diagnosis, color=self._colors.muted)
self._text(diagnosis, color=self._colors.palette.muted)
history = _sick_tracker.load_history()
self._text(_sick_tracker.budget_summary(history), color=self._colors.muted)
self._text(
_manual_workout.budget_summary(self.log_file), color=self._colors.muted
_sick_tracker.budget_summary(history), color=self._colors.palette.muted
)
self._text(
_manual_workout.budget_summary(self.log_file),
color=self._colors.palette.muted,
)
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg=self._colors.accent,
bg=self._colors.palette.accent,
command=self._start_phone_check,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
if _sick_tracker.is_budget_exhausted(history):
self._text(
"Sick budget exhausted. No 'I'm sick' option available.",
color=self._colors.danger,
color=self._colors.palette.danger,
)
else:
self._button(
frame,
"I'm sick",
bg=self._colors.warning,
bg=self._colors.palette.warning,
command=self.ask_if_sick,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
if _manual_workout.is_budget_exhausted(self.log_file):
self._text(
"Manual-workout budget exhausted. No manual-log option available.",
color=self._colors.danger,
color=self._colors.palette.danger,
)
else:
self._button(
frame,
"Log Manual Workout",
bg=self._colors.accent,
bg=self._colors.palette.accent,
command=self._show_manual_workout_form,
width=16,
).pack(side="left", padx=self._colors.space("sm"))
@ -112,11 +121,13 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin):
"✓ Manual Workout Logged!",
role="display",
scale=1.3,
color=self._colors.success,
color=self._colors.palette.success,
pad="lg",
)
self._text(entry.get("source", ""), role="subtitle", color=self._colors.success)
self._text("Unlocking...", role="body", color=self._colors.muted)
self._text(
entry.get("source", ""), role="subtitle", color=self._colors.palette.success
)
self._text("Unlocking...", role="body", color=self._colors.palette.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
@ -134,11 +145,11 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin):
"✓ Workout Verified!",
role="display",
scale=1.3,
color=self._colors.success,
color=self._colors.palette.success,
pad="lg",
)
self._text(message, role="subtitle", color=self._colors.success)
self._text("Unlocking...", role="body", color=self._colors.muted)
self._text(message, role="subtitle", color=self._colors.palette.success)
self._text("Unlocking...", role="body", color=self._colors.palette.muted)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
elif status == "too_short":
@ -175,7 +186,10 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin):
"""
self.clear_container()
self._label(
"Checking RunnerUp...", role="display", color=self._colors.warning, pad="lg"
"Checking RunnerUp...",
role="display",
color=self._colors.palette.warning,
pad="lg",
)
self._text("Looking for today's run in RunnerUp...", role="body")
executor = ThreadPoolExecutor(max_workers=1)
@ -196,11 +210,13 @@ class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin):
"✓ Run Verified!",
role="display",
scale=1.3,
color=self._colors.success,
color=self._colors.palette.success,
pad="lg",
)
self._text(message, role="subtitle", color=self._colors.success)
self._text("Unlocking...", role="body", color=self._colors.muted)
self._text(message, role="subtitle", color=self._colors.palette.success)
self._text(
"Unlocking...", role="body", color=self._colors.palette.muted
)
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
else:

View File

@ -33,7 +33,7 @@ class LockoutFlowMixin:
f"Go work out!\nLocked for {self.lockout_time} seconds",
role="display",
scale=1.5,
color=self._colors.danger,
color=self._colors.palette.danger,
pad="lg",
)
self.countdown_label = self._label(
@ -82,10 +82,10 @@ class LockoutFlowMixin:
self._label(
"Cannot Verify Workout",
role="display",
color=self._colors.warning,
color=self._colors.palette.warning,
pad="md",
)
self._text(message, color=self._colors.warning)
self._text(message, color=self._colors.palette.warning)
self._text(
"Connect phone via ADB to skip this wait,\n"
"or wait for the penalty timer.\n\n"

View File

@ -24,7 +24,7 @@ class UIFlowsRelaxedMixin:
self._label(
"Verifying Workout",
role="display",
color=self._colors.warning,
color=self._colors.palette.warning,
pad="lg",
)
self._text(
@ -61,15 +61,15 @@ class UIFlowsRelaxedMixin:
"✓ Workout Verified!",
role="display",
scale=1.3,
color=self._colors.success,
color=self._colors.palette.success,
pad="lg",
)
self._text(message, role="subtitle", color=self._colors.success)
self._text(message, role="subtitle", color=self._colors.palette.success)
if adjusted:
self._text(
"Shutdown time moved later!",
role="subtitle",
color=self._colors.warning,
color=self._colors.palette.warning,
)
self.root.after(2000, self.close)
else:
@ -81,22 +81,22 @@ class UIFlowsRelaxedMixin:
self._label(
"Workout Not Found",
role="display",
color=self._colors.danger,
color=self._colors.palette.danger,
pad="md",
)
self._text(message, color=self._colors.warning)
self._text(message, color=self._colors.palette.warning)
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg=self._colors.accent,
bg=self._colors.palette.accent,
command=self._start_verify_workout_check,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Close",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=self.close,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
@ -116,28 +116,28 @@ class UIFlowsRelaxedMixin:
self._label(
"Optional Day (Tue / Wed / Thu)",
role="title",
color=self._colors.warning,
color=self._colors.palette.warning,
pad="md",
)
self._text(
f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n"
"No penalty for skipping today.",
role="subtitle",
color=self._colors.muted,
color=self._colors.palette.muted,
pad="sm",
)
frame = self._button_row()
self._button(
frame,
"Skip — No Penalty",
bg=self._colors.success,
bg=self._colors.palette.success,
command=self._skip_relaxed_day,
width=18,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Log Stronglift Workout",
bg=self._colors.accent,
bg=self._colors.palette.accent,
command=self._start_relaxed_phone_check,
width=20,
).pack(side="left", padx=self._colors.space("sm"))
@ -158,7 +158,10 @@ class UIFlowsRelaxedMixin:
"""Run Stronglift check in relaxed mode (no screen grab, no sick option)."""
self.clear_container()
self._label(
"Checking phone...", role="display", color=self._colors.warning, pad="lg"
"Checking phone...",
role="display",
color=self._colors.palette.warning,
pad="lg",
)
self._text("Looking for today's workout in StrongLifts...", role="body")
executor = ThreadPoolExecutor(max_workers=1)
@ -192,21 +195,26 @@ class UIFlowsRelaxedMixin:
"""Show retry and skip-close when workout not found in relaxed mode."""
self.clear_container()
self._label(
"No Workout Found", role="display", color=self._colors.danger, pad="md"
"No Workout Found",
role="display",
color=self._colors.palette.danger,
pad="md",
)
self._text(
f"❌ {message}\n\nReason: {status}", color=self._colors.palette.warning
)
self._text(f"❌ {message}\n\nReason: {status}", color=self._colors.warning)
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg=self._colors.accent,
bg=self._colors.palette.accent,
command=self._start_relaxed_phone_check,
width=12,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Close (Skip)",
bg=self._colors.success,
bg=self._colors.palette.success,
command=self.close,
width=14,
).pack(side="left", padx=self._colors.space("sm"))

View File

@ -20,14 +20,14 @@ class SickDayFlowMixin:
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", self._colors.warning
return "Shutdown time already adjusted today", self._colors.palette.warning
if self._adjust_shutdown_time_earlier():
return (
"Shutdown time moved 1.5 hours earlier ✓\n(Will revert tomorrow)"
), self._colors.success
), self._colors.palette.success
return (
"Could not adjust shutdown time (check permissions)",
self._colors.danger,
self._colors.palette.danger,
)
def _proceed_to_sick_countdown(self) -> None:
@ -54,7 +54,7 @@ class SickDayFlowMixin:
countdown: int,
) -> None:
"""Display sick day UI labels and countdown."""
self._label("Sick Day Mode", color=self._colors.warning, pad="md")
self._label("Sick Day Mode", color=self._colors.palette.warning, pad="md")
self._text(status_text, color=status_color)
minutes = countdown // 60
self._text(

View File

@ -54,14 +54,14 @@ class UIFormFieldsMixin:
nothing focused the user's typing goes nowhere and there is no other
window on screen to make that obvious.
"""
row = parent.child_frame(bg=self._colors.bg)
row = parent.child_frame(bg=self._colors.palette.bg)
row.pack(pady=self._colors.space("sm"), fill="x")
row.child_widgets(
tk.Label,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
anchor="w",
).pack(side="top", anchor="w")
entries = row.child_widgets(
@ -69,9 +69,9 @@ class UIFormFieldsMixin:
textvariable=variable,
width=50,
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
bg=self._colors.palette.field_bg,
fg=self._colors.palette.fg,
insertbackground=self._colors.palette.fg,
**self._colors.focus_kwargs(),
)
entries.pack(side="top", anchor="w", pady=self._colors.space("xs"))
@ -96,14 +96,14 @@ class UIFormFieldsMixin:
not advertised anywhere. On a lock surface that means a user who tabs
into it can never reach the submit button.
"""
row = parent.child_frame(bg=self._colors.bg)
row = parent.child_frame(bg=self._colors.palette.bg)
row.pack(pady=self._colors.space("sm"), fill="x")
row.child_widgets(
tk.Label,
text=label,
font=self._colors.font("label"),
fg=self._colors.fg,
bg=self._colors.bg,
fg=self._colors.palette.fg,
bg=self._colors.palette.bg,
anchor="w",
).pack(side="top", anchor="w")
boxes = row.child_widgets(
@ -112,9 +112,9 @@ class UIFormFieldsMixin:
height=height,
wrap="word",
font=self._colors.font("label"),
bg=self._colors.field_bg,
fg=self._colors.fg,
insertbackground=self._colors.fg,
bg=self._colors.palette.field_bg,
fg=self._colors.palette.fg,
insertbackground=self._colors.palette.fg,
**self._colors.focus_kwargs(),
)
boxes.pack(side="top", anchor="w", pady=self._colors.space("xs"))

View File

@ -69,8 +69,8 @@ class UIWidgetsMixin(UIFormFieldsMixin):
tk.Label,
text=text,
font=self._colors.font(role, bold=True, scale=scale),
fg=color or self._colors.fg,
bg=self._colors.bg,
fg=color or self._colors.palette.fg,
bg=self._colors.palette.bg,
)
label.pack(pady=self._colors.space(pad))
return label
@ -92,8 +92,8 @@ class UIWidgetsMixin(UIFormFieldsMixin):
tk.Label,
text=text,
font=self._colors.font(role, scale=scale),
fg=color or self._colors.fg,
bg=self._colors.bg,
fg=color or self._colors.palette.fg,
bg=self._colors.palette.bg,
)
label.pack(pady=self._colors.space(pad))
return label
@ -109,12 +109,12 @@ class UIWidgetsMixin(UIFormFieldsMixin):
the bug by omission.
"""
fills = {
self._colors.accent,
self._colors.success,
self._colors.warning,
self._colors.danger,
self._colors.palette.accent,
self._colors.palette.success,
self._colors.palette.warning,
self._colors.palette.danger,
}
return self._colors.on_fill if bg in fills else self._colors.fg
return self._colors.palette.on_fill if bg in fills else self._colors.palette.fg
def _button(
self,
@ -174,6 +174,6 @@ class UIWidgetsMixin(UIFormFieldsMixin):
on every screen: the pixels it spends are the ones the manual-workout
form has left over on a 1024x600 panel.
"""
frame = self.container.child_frame(bg=self._colors.bg)
frame = self.container.child_frame(bg=self._colors.palette.bg)
frame.pack(pady=self._colors.space("sm"))
return frame

View File

@ -31,35 +31,35 @@ class UnlockViewMixin:
"Great job! 💪",
role="display",
scale=1.5,
color=self._colors.success,
color=self._colors.palette.success,
pad="lg",
)
if credit.shutdown_adjusted:
self._text(
"Shutdown time +2h later! 🎁",
role="title",
color=self._colors.warning,
color=self._colors.palette.warning,
)
if credit.extra_bonus_delta > 0:
self._text(
f"Extra workout today! +{credit.extra_bonus_delta}h tonight",
role="subtitle",
color=self._colors.warning,
color=self._colors.palette.warning,
)
if credit.new_debt is not None:
self._text(
f"Workout debt: {credit.new_debt}",
role="subtitle",
color=self._colors.warning
color=self._colors.palette.warning
if credit.new_debt > 0
else self._colors.muted,
else self._colors.palette.muted,
)
streak = current_streak(EXTRA_BENEFITS_FILE)
if streak >= 1:
self._text(
f"🔥 {streak}-week streak (5+ workouts each)",
role="label",
color=self._colors.muted,
color=self._colors.palette.muted,
)
self._text("Screen Unlocked!", role="display", pad="md")
if self.workout_data.get("type") in _COMMITTABLE:

View File

@ -210,8 +210,8 @@ class WindowSetupMixin:
self.root,
text="✕ Close Demo",
font=self._colors.font("caption"),
bg=self._colors.danger,
fg=self._colors.on_fill,
bg=self._colors.palette.danger,
fg=self._colors.palette.on_fill,
command=self.close,
cursor="hand2",
)
@ -238,7 +238,7 @@ class WindowSetupMixin:
still does not fit scrolls, because ``_ensure_container`` wraps these
windows in a viewport too.
"""
self.root.configure(bg=self._colors.bg, cursor="arrow")
self.root.configure(bg=self._colors.palette.bg, cursor="arrow")
self.root.protocol("WM_DELETE_WINDOW", self.close)
# "" hands sizing back to the geometry manager instead of pinning it.
self.root.geometry("")

View File

@ -15,6 +15,7 @@ from typing import TYPE_CHECKING
from gatelock import (
GateRoot,
GrabPolicy,
LockConfig,
LockWindow,
)
@ -184,14 +185,17 @@ class ScreenLocker(
# 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,
# Opt in: a weaker holder (diet_guard, leetcode_guard) that already
# owns the grab must stand down rather than block enforcement
# forever -- the 2026-08-21 lockout was diet_guard holding it for
# hours. Safe in this direction only; gatelock never signals a
# holder that outranks us, so wake_alarm can still take the screen.
preempt_weaker_holder=not demo_mode,
grab=GrabPolicy(
kind="local" if demo_mode else "global",
disable_vt=not demo_mode,
# Opt in: a weaker holder (diet_guard, leetcode_guard) that
# already owns the grab must stand down rather than block
# enforcement forever -- the 2026-08-21 lockout was diet_guard
# holding it for hours. Safe in this direction only; gatelock
# never signals a holder that outranks us, so wake_alarm can
# still take the screen.
preempt_weaker_holder=not demo_mode,
),
)
# Built before setup(), which calls build_surface per live output.
self.container = FrameGroup([])

View File

@ -103,7 +103,7 @@ class StatusWindow(
# but it shares UIWidgetsMixin's factory with the locker, which now
# builds through a group. Filling the window rather than centring, so
# this is built here instead of via FrameGroup.single().
frame = tk.Frame(root, bg=self._colors.bg)
frame = tk.Frame(root, bg=self._colors.palette.bg)
frame.pack(fill="both", expand=True)
self.container = FrameGroup([frame])
self._start_temperature_check()
@ -127,11 +127,15 @@ class StatusWindow(
if self._phone_check_result is not None:
status, message = self._phone_check_result
color = (
self._colors.success if status == "verified" else self._colors.warning
self._colors.palette.success
if status == "verified"
else self._colors.palette.warning
)
self._text(f"Phone check ({status}): {message}", role="body", color=color)
if self._credit_message is not None:
self._text(self._credit_message, role="body", color=self._colors.success)
self._text(
self._credit_message, role="body", color=self._colors.palette.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.
@ -139,7 +143,7 @@ class StatusWindow(
self._button(
frame,
"Check Phone",
bg=self._colors.accent,
bg=self._colors.palette.accent,
command=self._on_check_phone_clicked,
width=14,
).pack(side="left", padx=self._colors.space("sm"))
@ -147,21 +151,21 @@ class StatusWindow(
self._button(
frame,
"Log Manual Workout",
bg=self._colors.accent,
bg=self._colors.palette.accent,
command=self._show_manual_workout_form,
width=16,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Refresh",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=self._on_refresh_clicked,
width=10,
).pack(side="left", padx=self._colors.space("sm"))
self._button(
frame,
"Close",
bg=self._colors.field_bg,
bg=self._colors.palette.field_bg,
command=self.root.destroy,
width=8,
).pack(side="left", padx=self._colors.space("sm"))
@ -219,7 +223,7 @@ def main(argv: list[str] | None = None) -> None:
root = tk.Tk()
root.title("Workout Status")
root.configure(bg=_STATUS_COLORS.bg)
root.configure(bg=_STATUS_COLORS.palette.bg)
root.minsize(560, 200)
def refresh() -> None:

View File

@ -184,7 +184,7 @@ class TestMultiLineField:
) -> None:
"""Both halves fan out, like every other widget helper here."""
locker = create_locker(mock_tk, tmp_path)
parent = locker.container.child_frame(bg=locker._colors.bg)
parent = locker.container.child_frame(bg=locker._colors.palette.bg)
mock_tk.Text.reset_mock()
boxes = locker._add_label_text(parent, label="Why?", height=3)
@ -202,7 +202,7 @@ class TestMultiLineField:
advertised way out -- on this lock, that means never reaching SUBMIT.
"""
locker = create_locker(mock_tk, tmp_path)
parent = locker.container.child_frame(bg=locker._colors.bg)
parent = locker.container.child_frame(bg=locker._colors.palette.bg)
boxes = locker._add_label_text(parent, label="Why?")
@ -213,7 +213,7 @@ class TestMultiLineField:
def test_paste_is_disabled(self, mock_tk: MagicMock, tmp_path: Path) -> None:
"""The justification has to be typed, like every other field here."""
locker = create_locker(mock_tk, tmp_path)
parent = locker.container.child_frame(bg=locker._colors.bg)
parent = locker.container.child_frame(bg=locker._colors.palette.bg)
boxes = locker._add_label_text(parent, label="Why?")

View File

@ -32,7 +32,7 @@ class TestSectionManualWorkoutBudget:
for c in mock_tk.Label.call_args_list
if "week" in c.kwargs.get("text", "")
]
assert any(c.kwargs.get("fg") == window._colors.danger for c in calls)
assert any(c.kwargs.get("fg") == window._colors.palette.danger for c in calls)
def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None:
snap = _snapshot(
@ -44,7 +44,7 @@ class TestSectionManualWorkoutBudget:
for c in mock_tk.Label.call_args_list
if "week" in c.kwargs.get("text", "")
]
assert any(c.kwargs.get("fg") == window._colors.muted for c in calls)
assert any(c.kwargs.get("fg") == window._colors.palette.muted for c in calls)
class TestManualWorkoutButtonVisibility:

View File

@ -168,7 +168,7 @@ class TestSectionSickBudget:
for c in mock_tk.Label.call_args_list
if "week" in c.kwargs.get("text", "")
]
assert any(c.kwargs.get("fg") == window._colors.danger for c in calls)
assert any(c.kwargs.get("fg") == window._colors.palette.danger for c in calls)
def test_not_exhausted_uses_normal_color(self, mock_tk: MagicMock) -> None:
"""Not exhausted uses normal color."""
@ -179,7 +179,7 @@ class TestSectionSickBudget:
for c in mock_tk.Label.call_args_list
if "week" in c.kwargs.get("text", "")
]
assert any(c.kwargs.get("fg") == window._colors.muted for c in calls)
assert any(c.kwargs.get("fg") == window._colors.palette.muted for c in calls)
class TestSectionShutdown:

View File

@ -41,7 +41,7 @@ class TestPhoneCheckResultRendering:
if "Phone check" in c.kwargs.get("text", "")
]
assert calls
assert calls[-1].kwargs.get("fg") == window._colors.success
assert calls[-1].kwargs.get("fg") == window._colors.palette.success
def test_non_verified_result_shown_in_orange(self, mock_tk: MagicMock) -> None:
"""Non verified result shown in orange."""
@ -54,7 +54,7 @@ class TestPhoneCheckResultRendering:
if "Phone check" in c.kwargs.get("text", "")
]
assert calls
assert calls[-1].kwargs.get("fg") == window._colors.warning
assert calls[-1].kwargs.get("fg") == window._colors.palette.warning
class TestButtons:

View File

@ -55,7 +55,9 @@ class TestLockerFansOut:
row = locker._button_row()
mock_tk.Button.reset_mock()
locker._button(row, "OK", bg=locker._colors.accent, command=lambda: None)
locker._button(
row, "OK", bg=locker._colors.palette.accent, command=lambda: None
)
assert mock_tk.Button.call_count == len(TWO_OUTPUTS)

View File

@ -147,7 +147,7 @@ class TestGetSickDayStatus:
)
text, color = locker._get_sick_day_status()
assert "already adjusted" in text
assert color == locker._colors.warning
assert color == locker._colors.palette.warning
def test_adjustment_success(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
@ -162,7 +162,7 @@ class TestGetSickDayStatus:
)
text, color = locker._get_sick_day_status()
assert "earlier" in text
assert color == locker._colors.success
assert color == locker._colors.palette.success
def test_adjustment_failure(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
@ -177,7 +177,7 @@ class TestGetSickDayStatus:
)
text, color = locker._get_sick_day_status()
assert "Could not adjust" in text
assert color == locker._colors.danger
assert color == locker._colors.palette.danger
class TestShowRetryAndSick:

View File

@ -161,7 +161,7 @@ class TestSetupVerifyWindow:
locker.root.minsize.assert_called_once()
locker.root.maxsize.assert_called_once()
locker.root.configure.assert_called_with(
bg=locker._colors.bg,
bg=locker._colors.palette.bg,
cursor="arrow",
)
locker.root.protocol.assert_called_with(

View File

@ -143,7 +143,7 @@ class FormHost(UIWidgetsMixin, ManualWorkoutDialogMixin):
self.log_file = Path(self._log_dir.name) / "log.json"
self.log_file.write_text("{}")
self._colors = LockConfig()
self.container = FrameGroup.single(root, bg=self._colors.bg)
self.container = FrameGroup.single(root, bg=self._colors.palette.bg)
def _on_manual_workout_saved(self, entry: dict[str, object]) -> None:
"""Unused here; the form is never submitted."""

View File

@ -43,7 +43,7 @@ import tkinter as tk
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from gatelock import LockConfig, LockWindow
from gatelock import GrabPolicy, LockConfig, LockWindow
from screen_locker._workout_app import ProcessHooks, workout_app_binary
from screen_locker._workout_handoff import lock_grab_handoff
@ -130,7 +130,7 @@ def main() -> int:
root = tk.Tk()
root.title("Workout handoff demo")
# Local grab + VT switching enabled: every escape hatch stays open.
config = LockConfig(mode="hard", grab="local", disable_vt=False)
config = LockConfig(mode="hard", grab=GrabPolicy(kind="local", disable_vt=False))
hooks = _DemoHooks()
lock = LockWindow(root, config, hooks)
hooks.lock = lock