Persist accessible colour and motion preferences (issue #80)

Implement Amber/Nord/High Contrast theme presets with XDG user-scoped
persistence and reduced motion toggle. Covers TPH-10 and preference
integration with TPH-2.

- preferences.py: safe load/save with XDG_CONFIG_HOME/fenris/preferences.json
- themes.py: three Textual Theme objects with graph colour roles
- TUI: t cycles presets, m toggles reduced motion, both persist across restart
- Status composition receives reduced_motion from preferences
- 56 new tests covering persistence, themes, TUI integration, CLI isolation
- All 590 existing tests continue to pass
This commit is contained in:
xavierk
2026-09-14 16:31:36 +05:30
parent 30122c5e6d
commit 79478653fa
7 changed files with 1276 additions and 7 deletions
+47 -6
View File
@@ -59,6 +59,8 @@ from .status_composition import (
render_status_tui,
STATUS_POLL_INTERVAL_S,
)
from .preferences import load_preferences, save_preferences
from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
# ---------------------------------------------------------------------------
@@ -954,6 +956,8 @@ class FenrisTuiApp(App):
Binding("r", "resume", "Resume", show=False),
Binding("c", "collect", "Collect Now", show=False),
Binding("d", "disclose", "Disclosures", show=False),
Binding("t", "toggle_theme", "Theme", show=False),
Binding("m", "toggle_motion", "Motion", show=False),
Binding("q", "quit", "Quit", show=False),
]
@@ -965,6 +969,11 @@ class FenrisTuiApp(App):
refresh_interval_s: float = CADENCE_DEFAULT_S,
**kwargs,
) -> None:
# Load user preferences before super().__init__ so theme is available
self._prefs = load_preferences()
self._current_theme_name = self._prefs["theme"]
self._reduced_motion = self._prefs["reduced_motion"]
super().__init__(**kwargs)
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
self.config_path = config_path
@@ -974,6 +983,10 @@ class FenrisTuiApp(App):
self._conn: Optional[sqlite3.Connection] = None
self._clock_now = datetime.now(timezone.utc)
# Register all Fenris themes
for theme in THEMES.values():
self.register_theme(theme)
def compose(self) -> ComposeResult:
with VerticalScroll(id="dashboard-scroll"):
with Container(id="main-grid"):
@@ -985,7 +998,11 @@ class FenrisTuiApp(App):
yield Static("q QUIT TUI", id="quit-rail")
def on_mount(self) -> None:
"""Set border titles and render initial state."""
"""Set border titles, apply theme, and render initial state."""
# Apply loaded theme (issue #80)
# Preference uses underscores (high_contrast); Textual theme uses dashes (fenris-high-contrast)
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
self.query_one("#headline-band").border_title = "headline"
self.query_one("#usage-history").border_title = "usage history"
self.query_one("#drive-health").border_title = "drive"
@@ -1057,7 +1074,7 @@ class FenrisTuiApp(App):
self.query_one("#service-strip").update(
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
"[bold]CONTINUITY[/bold] %s\n"
"p pause · r resume · c collect · d disclosures"
"p pause · r resume · c collect · t theme · m motion · d disclosures"
% monitoring_continuity({"boot_enabled": False})
)
else:
@@ -1069,7 +1086,7 @@ class FenrisTuiApp(App):
self.query_one("#usage-history").set_data([])
self.query_one("#drive-health").update("")
self.query_one("#service-strip").update(
"p pause · r resume · c collect · d disclosures"
"p pause · r resume · c collect · t theme · m motion · d disclosures"
)
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
@@ -1147,14 +1164,14 @@ class FenrisTuiApp(App):
conn, svc, self._clock_now,
store_fault=None,
newer_schema=None,
reduced_motion=False,
reduced_motion=self._reduced_motion,
)
# Render using the shared composition with TUI styling
status_text = render_status_tui(comp)
# Add TUI-only actions
self.query_one("#service-strip").update(
"%s\n"
"p pause · r resume · c collect · d disclosures"
"p pause · r resume · c collect · t theme · m motion · d disclosures"
% status_text
)
self._render_paused_banner(comp)
@@ -1162,7 +1179,7 @@ class FenrisTuiApp(App):
self._hide_paused_banner()
self.query_one("#service-strip").update(
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
"p pause · r resume · c collect · d disclosures"
"p pause · r resume · c collect · t theme · m motion · d disclosures"
)
def _render_paused_banner(self, comp) -> None:
@@ -1266,6 +1283,30 @@ class FenrisTuiApp(App):
# --- Actions ---
def action_toggle_theme(self) -> None:
"""Cycle through Amber → Nord → High Contrast presets (issue #80)."""
names = sorted(THEME_NAMES)
try:
idx = names.index(self._current_theme_name)
except ValueError:
idx = 0
self._current_theme_name = names[(idx + 1) % len(names)]
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
save_preferences(
theme=self._current_theme_name,
reduced_motion=self._reduced_motion,
)
self._refresh()
def action_toggle_motion(self) -> None:
"""Toggle reduced motion preference (issue #80)."""
self._reduced_motion = not self._reduced_motion
save_preferences(
theme=self._current_theme_name,
reduced_motion=self._reduced_motion,
)
self._refresh()
def action_pause(self) -> None:
"""Pause monitoring — asks for confirmation (spec §7.4, LC-6)."""
self.push_screen(ConfirmPause(), callback=self._pause_confirmed)