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:
@@ -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}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user