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
162 lines
5.1 KiB
Python
162 lines
5.1 KiB
Python
"""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"),
|
|
}
|