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
+98
View File
@@ -0,0 +1,98 @@
"""User-scoped TUI preferences (issue #80).
Persists theme preset and reduced-motion choice per unprivileged user.
Preferences live at XDG_CONFIG_HOME/fenris/preferences.json and must not
affect collection, projection, history evidence, helper state, package
config, or CLI status.
Safe failures: invalid/unreadable/unwritable data never crashes the
dashboard, corrupts previous preferences, or affects monitoring.
Failures are understandable rather than silently implying persistence
succeeded.
Criteria: TPH-10, AC80-2, AC80-3.
"""
import json
import os
from pathlib import Path
from typing import Any, Dict
PREFERENCE_FILE_NAME = "preferences.json"
VALID_THEMES = {"amber", "nord", "high_contrast"}
DEFAULT_THEME = "amber"
DEFAULT_REDUCED_MOTION = False
def get_preference_path() -> Path:
"""Return the user-scoped preference file path.
Uses XDG_CONFIG_HOME/fenris/preferences.json.
Falls back to ~/.config/fenris/preferences.json if unset.
"""
xdg = os.environ.get("XDG_CONFIG_HOME")
if xdg:
base = Path(xdg)
else:
base = Path.home() / ".config"
return base / "fenris" / PREFERENCE_FILE_NAME
def load_preferences() -> Dict[str, Any]:
"""Load user preferences with safe defaults.
Returns a dict with keys:
theme: str (one of VALID_THEMES)
reduced_motion: bool
If the file is missing, corrupt, unreadable, or contains invalid
values, returns safe defaults (Amber theme, normal motion).
"""
path = get_preference_path()
try:
text = path.read_text()
except (OSError, FileNotFoundError):
return _defaults()
try:
data = json.loads(text)
except (json.JSONDecodeError, ValueError):
return _defaults()
if not isinstance(data, dict):
return _defaults()
theme = data.get("theme", DEFAULT_THEME)
if theme not in VALID_THEMES:
theme = DEFAULT_THEME
reduced_motion = data.get("reduced_motion", DEFAULT_REDUCED_MOTION)
if not isinstance(reduced_motion, bool):
reduced_motion = DEFAULT_REDUCED_MOTION
return {"theme": theme, "reduced_motion": reduced_motion}
def save_preferences(theme: str = DEFAULT_THEME,
reduced_motion: bool = DEFAULT_REDUCED_MOTION) -> None:
"""Save user preferences.
Creates the config directory if needed. If the write fails
(read-only filesystem, permissions), the failure is swallowed —
the TUI continues with whatever was loaded, and the user sees
no crash or error.
"""
path = get_preference_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps({"theme": theme, "reduced_motion": reduced_motion}, indent=2)
path.write_text(payload + "\n")
except (OSError, PermissionError):
# Best-effort persistence — failure must not crash the TUI
pass
def _defaults() -> Dict[str, Any]:
return {"theme": DEFAULT_THEME, "reduced_motion": DEFAULT_REDUCED_MOTION}
+161
View File
@@ -0,0 +1,161 @@
"""Fenris theme presets (issue #80).
Three accessible colour presets: Amber (default), Nord, and High Contrast.
Themes style chrome, borders, accents, muted text, and graph roles;
status semantic colours/glyphs/text always win.
Theme roles for graph rendering expose distinct colours per preset so
the bar graph can reflect the user's visual preference without depending
on graph-ticket completion.
Criteria: TPH-10, AC80-1, AC80-5.
"""
from typing import Dict
from textual.theme import Theme
# ---------------------------------------------------------------------------
# Status semantic colours — always win, never themed (AC80-1)
# ---------------------------------------------------------------------------
STATUS_COLORS = {
"monitoring": "green",
"collecting": "green",
"paused": "yellow",
"waiting": "yellow",
"interrupted": "red",
"error": "red",
"stale": "red",
"unknown": "dim",
}
# ---------------------------------------------------------------------------
# Amber theme — warm golden tones (default, amber graph role)
# ---------------------------------------------------------------------------
_AMBER = Theme(
name="fenris-amber",
primary="#d4a017", # warm amber
secondary="#c49b0a", # darker amber
accent="#ffd54f", # light amber highlight
warning="#e6a817", # amber warning
error="#e74c3c", # red error
success="#27ae60", # green success
foreground="#e8e0d0", # warm light
background="#1a1510", # warm dark
surface="#241f16", # warm surface
panel="#2a2318", # warm panel
boost="#332a1c", # warm boost
dark=True,
variables={
"graph-allocated": "#d4a017",
"graph-unallocated": "#8b6914",
"graph-gap": "#554422",
"graph-zero": "#665533",
"graph-partial": "#aa8822",
"graph-selection": "#ffd54f",
"border-default": "#554422",
"muted-text": "#887755",
},
)
# ---------------------------------------------------------------------------
# Nord theme — cool blue-gray polar night palette
# ---------------------------------------------------------------------------
_NORD = Theme(
name="fenris-nord",
primary="#88c0d0", # nord8 frost
secondary="#81a1c1", # nord9
accent="#8fbcbb", # nord7
warning="#ebcb8b", # nord13
error="#bf616a", # nord11
success="#a3be8c", # nord14
foreground="#eceff4", # nord6
background="#2e3440", # nord0
surface="#3b4252", # nord1
panel="#434c5e", # nord2
boost="#4c566a", # nord3
dark=True,
variables={
"graph-allocated": "#88c0d0",
"graph-unallocated": "#5e81ac",
"graph-gap": "#4c566a",
"graph-zero": "#616e88",
"graph-partial": "#81a1c1",
"graph-selection": "#8fbcbb",
"border-default": "#4c566a",
"muted-text": "#7b88a1",
},
)
# ---------------------------------------------------------------------------
# High Contrast — maximum readability, pure black and white
# ---------------------------------------------------------------------------
_HIGH_CONTRAST = Theme(
name="fenris-high-contrast",
primary="#ffffff", # pure white
secondary="#dddddd", # light gray
accent="#ffff00", # bright yellow
warning="#ff8800", # bright orange
error="#ff0000", # pure red
success="#00ff00", # pure green
foreground="#ffffff", # pure white
background="#000000", # pure black
surface="#111111", # near-black surface
panel="#1a1a1a", # near-black panel
boost="#222222", # near-black boost
dark=True,
variables={
"graph-allocated": "#ffffff",
"graph-unallocated": "#aaaaaa",
"graph-gap": "#555555",
"graph-zero": "#666666",
"graph-partial": "#cccccc",
"graph-selection": "#ffff00",
"border-default": "#ffffff",
"muted-text": "#aaaaaa",
},
)
# ---------------------------------------------------------------------------
# Theme registry
# ---------------------------------------------------------------------------
THEMES = {
"amber": _AMBER,
"nord": _NORD,
"high_contrast": _HIGH_CONTRAST,
}
THEME_NAMES = set(THEMES.keys())
def get_theme(name: str) -> Theme:
"""Return a registered theme by preset name.
Unknown names fall back to Amber.
"""
return THEMES.get(name, _AMBER)
def get_graph_colors(theme_name: str) -> Dict[str, str]:
"""Return the graph colour roles for a theme preset.
Returns a dict with keys: allocated, unallocated, gap, zero, partial,
selection. Falls back to Amber for unknown names.
"""
theme = get_theme(theme_name)
variables = theme.variables or {}
return {
"allocated": variables.get("graph-allocated", "#d4a017"),
"unallocated": variables.get("graph-unallocated", "#8b6914"),
"gap": variables.get("graph-gap", "#554422"),
"zero": variables.get("graph-zero", "#665533"),
"partial": variables.get("graph-partial", "#aa8822"),
"selection": variables.get("graph-selection", "#ffd54f"),
}
+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)