"""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 = {"chalktone", "amber", "nord", "high_contrast"} DEFAULT_THEME = "chalktone" 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 (Chalktone 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}