Skip to main content

max / makenotwork

Put the ops viewer on makeover-tui Adoption, not a decision: makeover-tui is the house TUI backbone and the viewer was one of four surfaces in the tree with no design system at all. Proportionate to what it draws, which is three surfaces plus a header and a footer, so intents only and no bevel painting. The store is viewer.toml. The viewer already owns a config file, so the shared convention's unprefixed `theme` key lives there rather than in a second file: unset follows the terminal through COLORFGBG, an id pins. Nothing writes it back, so there is no `remember` to go with it. Two things this turned up that were not colour choices. Selection was a REVERSED modifier, which swaps a span's own foreground into the background — so a selected FAIL row painted its danger colour behind the text and lost the one signal the row carries. Selection is a raised surface now and every status colour survives it. Mapping `undistributed` and `unknown` onto category slots collapsed `undistributed` onto `pending`: makenotwork authors category.six and status.info as the same hex, and category colours are for data with no inherent meaning, so a theme is free to restate the status hues there. Both ride a modifier on the nearest intent instead, and a test asserts no two statuses render alike across all 31 bundled themes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 14:31 UTC
Signed with PGP, not checked
Commit: 26fbb651ca2a8d17e5af544d8538cb2dab252bba
Parent: e739a89
8 files changed, +693 insertions, -130 deletions
@@ -826,6 +826,25 @@
826 826 "icu_properties",
827 827 ]
828 828
829 + [[package]]
830 + name = "include_dir"
831 + version = "0.7.4"
832 + source = "registry+https://github.com/rust-lang/crates.io-index"
833 + checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
834 + dependencies = [
835 + "include_dir_macros",
836 + ]
837 +
838 + [[package]]
839 + name = "include_dir_macros"
840 + version = "0.7.4"
841 + source = "registry+https://github.com/rust-lang/crates.io-index"
842 + checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
843 + dependencies = [
844 + "proc-macro2",
845 + "quote",
846 + ]
847 +
829 848 [[package]]
830 849 name = "indexmap"
831 850 version = "2.14.0"
@@ -1035,6 +1054,34 @@
1035 1054 "winapi",
1036 1055 ]
1037 1056
1057 + [[package]]
1058 + name = "makeover"
1059 + version = "2.4.1"
1060 + source = "registry+https://github.com/rust-lang/crates.io-index"
1061 + checksum = "b0a796c931419e54dd71a8aaa7d62230730e958f3101d37d4c1d3ab7b541b38e"
1062 + dependencies = [
1063 + "include_dir",
1064 + "serde",
1065 + "toml",
1066 + ]
1067 +
1068 + [[package]]
1069 + name = "makeover-layout"
1070 + version = "0.12.0"
1071 + source = "registry+https://github.com/rust-lang/crates.io-index"
1072 + checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff"
1073 +
1074 + [[package]]
1075 + name = "makeover-tui"
1076 + version = "0.12.0"
1077 + source = "registry+https://github.com/rust-lang/crates.io-index"
1078 + checksum = "88982f3e29d40336e9748e9adafa88278d123c42875aad178e0cea640a128d57"
1079 + dependencies = [
1080 + "makeover",
1081 + "makeover-layout",
1082 + "ratatui",
1083 + ]
1084 +
1038 1085 [[package]]
1039 1086 name = "memchr"
1040 1087 version = "2.8.3"
@@ -1159,6 +1206,8 @@
1159 1206 dependencies = [
1160 1207 "anyhow",
1161 1208 "chrono",
1209 + "makeover",
1210 + "makeover-tui",
1162 1211 "ops-status",
1163 1212 "ratatui",
1164 1213 "reqwest",
@@ -2853,6 +2902,18 @@
2853 2902 source = "registry+https://github.com/rust-lang/crates.io-index"
2854 2903 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
2855 2904
2905 + [[patch.unused]]
2906 + name = "docengine"
2907 + version = "0.4.0"
2908 +
2909 + [[patch.unused]]
2910 + name = "synckit-client"
2911 + version = "0.8.0"
2912 +
2913 + [[patch.unused]]
2914 + name = "synckit-config"
2915 + version = "0.2.0"
2916 +
2856 2917 [[patch.unused]]
2857 2918 name = "kberg"
2858 2919 version = "0.1.0"
@@ -2866,13 +2927,25 @@
2866 2927 version = "0.4.0"
2867 2928
2868 2929 [[patch.unused]]
2869 - name = "docengine"
2870 - version = "0.4.0"
2930 + name = "quasi-axum"
2931 + version = "0.1.0"
2871 2932
2872 2933 [[patch.unused]]
2873 - name = "synckit-client"
2874 - version = "0.8.0"
2934 + name = "quasi-http"
2935 + version = "0.1.0"
2875 2936
2876 2937 [[patch.unused]]
2877 - name = "synckit-config"
2878 - version = "0.2.0"
2938 + name = "quasi-router"
2939 + version = "0.1.0"
2940 +
2941 + [[patch.unused]]
2942 + name = "quasi-store"
2943 + version = "0.1.0"
2944 +
2945 + [[patch.unused]]
2946 + name = "quasi-tauri"
2947 + version = "0.1.0"
2948 +
2949 + [[patch.unused]]
2950 + name = "quasi-webview"
2951 + version = "0.1.0"
@@ -14,6 +14,11 @@
14 14 # crossterm comes from ratatui's own re-export (`ratatui::crossterm`, 0.29) so
15 15 # the two cannot drift into a version mismatch.
16 16 ratatui = "0.30.2"
17 + # The family's design system. `makeover` loads and resolves a theme file;
18 + # `makeover-tui` is the terminal renderer, and its `theme` feature is the
19 + # intents-to-ratatui-colours mapping. No hex value is written in this crate.
20 + makeover = "2.4.1"
21 + makeover-tui = { version = "0.12.0", features = ["theme"] }
17 22 tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] }
18 23 # `rustls-no-provider` rather than `rustls`: the latter is an alias for
19 24 # `__rustls-aws-lc-rs`, which links a C crypto backend. The provider is ring
@@ -21,6 +21,14 @@
21 21 # snapshot nobody had refreshed in forty days.
22 22 stale_after_secs = 60
23 23
24 + # Which theme to render in. Unset means follow the terminal: the viewer reads
25 + # COLORFGBG and picks a light or dark theme to match, so it does not paint a
26 + # light palette onto a night session. Set it to a theme id to pin one instead,
27 + # e.g. "makenotwork", "carbonfox", "nord". Own themes go in
28 + # ~/.config/ops-viewer/themes/ and outrank the bundled set of the same id.
29 + #
30 + # theme = "system"
31 +
24 32 # Each URL below is the address that daemon's own deploy example binds, which is
25 33 # the file to check when a source reads as unreachable. A daemon bound to
26 34 # loopback is only visible to a viewer on the same host.
@@ -26,6 +26,15 @@
26 26 /// Fallback for any source that does not set its own.
27 27 #[serde(default = "default_stale")]
28 28 pub stale_after_secs: u64,
29 + /// The theme to render in: `"system"` to follow the terminal, or a theme
30 + /// id to pin.
31 + ///
32 + /// Unprefixed `theme`, which is the family convention's key name (wiki
33 + /// `makeover-app-convention`) — this file is already scoped to the viewer,
34 + /// so a `viewer-theme` would be saying it twice. Absent reads as "follow
35 + /// the terminal" rather than as a pin on whatever the first run guessed.
36 + #[serde(default)]
37 + pub theme: Option<String>,
29 38 #[serde(default, rename = "source")]
30 39 pub sources: Vec<Source>,
31 40 }
@@ -134,6 +143,13 @@
134 143 Ok(())
135 144 }
136 145
146 + /// What the operator chose, which is not the same as what is rendered: a
147 + /// standing "follow the terminal" resolves differently as the terminal
148 + /// changes, and a pinned id does not.
149 + pub(crate) fn theme_selection(&self) -> makeover::ThemeSelection {
150 + makeover::ThemeSelection::parse(self.theme.as_deref())
151 + }
152 +
137 153 /// Staleness limit for one source: its own, else the global default.
138 154 pub(crate) fn stale_after(&self, source: &Source) -> chrono::TimeDelta {
139 155 let secs = source.stale_after_secs.unwrap_or(self.stale_after_secs);
@@ -227,6 +243,38 @@
227 243 assert_eq!(cfg.stale_after(&cfg.sources[1]).num_seconds(), 600);
228 244 }
229 245
246 + #[test]
247 + fn an_absent_theme_key_follows_the_terminal_and_an_id_pins_one() {
248 + let (_dir, path) = write(
249 + r#"
250 + [[source]]
251 + name = "sando"
252 + url = "http://fw13:8080"
253 + "#,
254 + );
255 + let cfg = Config::load(&path).unwrap();
256 + assert_eq!(
257 + cfg.theme_selection(),
258 + makeover::ThemeSelection::Follow,
259 + "a config that says nothing about theming must track the terminal, \
260 + not pin whatever the first run guessed",
261 + );
262 +
263 + let (_dir, path) = write(
264 + r#"
265 + theme = "carbonfox"
266 +
267 + [[source]]
268 + name = "sando"
269 + url = "http://fw13:8080"
270 + "#,
271 + );
272 + assert_eq!(
273 + Config::load(&path).unwrap().theme_selection(),
274 + makeover::ThemeSelection::Fixed("carbonfox".into()),
275 + );
276 + }
277 +
230 278 #[test]
231 279 fn an_empty_config_is_rejected_rather_than_showing_an_empty_screen() {
232 280 let (_dir, path) = write("stale_after_secs = 60\n");
@@ -24,6 +24,7 @@
24 24 mod model;
25 25 mod poll;
26 26 mod render;
27 + mod theme;
27 28 mod tls;
28 29 mod value;
29 30
@@ -51,6 +52,9 @@
51 52
52 53 let path = config_path()?;
53 54 let cfg = Config::load(&path)?;
55 + // Before the terminal is taken over, so a theme problem is an error on a
56 + // normal screen rather than one printed into a restored alternate buffer.
57 + let theme = theme::load(&cfg.theme_selection())?;
54 58
55 59 let runtime = tokio::runtime::Runtime::new().context("starting the async runtime")?;
56 60 let _guard = runtime.enter();
@@ -72,6 +76,7 @@
72 76 let outcome = run(
73 77 &mut terminal,
74 78 model,
79 + &theme,
75 80 updates,
76 81 cfg.sources,
77 82 action_tx,
@@ -100,6 +105,7 @@
100 105 fn run(
101 106 terminal: &mut ratatui::DefaultTerminal,
102 107 mut model: Model,
108 + theme: &makeover_tui::Theme,
103 109 mut updates: mpsc::Receiver<poll::Update>,
104 110 sources: Vec<Source>,
105 111 action_tx: mpsc::Sender<exec::Outcome>,
@@ -107,7 +113,7 @@
107 113 ) -> Result<()> {
108 114 loop {
109 115 let now = Utc::now();
110 - terminal.draw(|frame| render::render(&model, now, frame))?;
116 + terminal.draw(|frame| render::render(&model, theme, now, frame))?;
111 117
112 118 // Drain everything the pollers have produced without blocking, so a
113 119 // burst of updates costs one redraw rather than one each.
@@ -1,23 +1,31 @@
1 1 //! Drawing the model.
2 2 //!
3 - //! `render(model, now, frame)` is a pure function of the model and the clock.
4 - //! `now` is threaded through rather than read from the system clock precisely
5 - //! so that this stays true: with an ambient clock, every snapshot test becomes
6 - //! time-dependent and the whole surface stops being verifiable without a live
7 - //! daemon. That is unpleasant to retrofit and trivial to keep.
3 + //! `render(model, theme, now, frame)` is a pure function of the model, the
4 + //! theme and the clock. `now` is threaded through rather than read from the
5 + //! system clock precisely so that this stays true: with an ambient clock, every
6 + //! snapshot test becomes time-dependent and the whole surface stops being
7 + //! verifiable without a live daemon. That is unpleasant to retrofit and trivial
8 + //! to keep. The theme is threaded the same way and for the same reason.
9 + //!
10 + //! Every colour here is a makeover intent (`crate::theme`), never a literal.
11 + //! The intent names what a thing IS — a danger, a muted label, the surface a
12 + //! modal floats on — and which colour that is belongs to the theme file. What
13 + //! the terminal costs is geometry rather than colour, so nothing below leans on
14 + //! a hue to carry a distinction that the layout should be carrying.
8 15
9 16 use chrono::{DateTime, Utc};
17 + use makeover_tui::Theme;
10 18 use ops_status::{Method, Node};
11 19 use ratatui::Frame;
12 20 use ratatui::layout::{Constraint, Layout, Rect};
13 - use ratatui::style::{Color, Modifier, Style, Stylize};
21 + use ratatui::style::{Modifier, Style};
14 22 use ratatui::text::{Line, Span};
15 23 use ratatui::widgets::{Block, Cell, Clear, List, ListItem, Paragraph, Row, Table, Tabs};
16 24
17 25 use crate::model::{Model, Prompt, SourceState, Tab};
18 26 use crate::value;
19 27
20 - pub(crate) fn render(model: &Model, now: DateTime<Utc>, frame: &mut Frame) {
28 + pub(crate) fn render(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame) {
21 29 let [header, body, footer] = Layout::vertical([
22 30 Constraint::Length(1),
23 31 Constraint::Min(1),
@@ -25,50 +33,95 @@
25 33 ])
26 34 .areas(frame.area());
27 35
28 - render_header(model, now, frame, header);
36 + render_header(model, theme, now, frame, header);
29 37 match model.tab {
30 - Tab::Rollup => render_rollup(model, now, frame, body),
38 + Tab::Rollup => render_rollup(model, theme, now, frame, body),
31 39 Tab::Source(i) => match model.sources.get(i) {
32 - Some(source) => render_source(source, now, frame, body),
33 - None => frame.render_widget(Paragraph::new("no such source"), body),
40 + Some(source) => render_source(source, theme, now, frame, body),
41 + None => frame.render_widget(Paragraph::new("no such source").style(muted(theme)), body),
34 42 },
35 43 }
36 44 // A prompt floats over whatever tab is showing: the state behind it keeps
37 45 // updating on every poll, which is the point of not blocking on the modal.
38 46 if model.prompt.is_some() {
39 - render_prompt(model, frame, body);
47 + render_prompt(model, theme, frame, body);
40 48 }
41 - render_footer(model, frame, footer);
49 + render_footer(model, theme, frame, footer);
42 50 }
43 51
44 - fn render_header(model: &Model, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
52 + /// Secondary text: labels, hints, the things you read past.
53 + fn muted(theme: &Theme) -> Style {
54 + Style::default().fg(theme.content_muted)
55 + }
56 +
57 + /// The selected row, on every list and table here.
58 + ///
59 + /// A raised surface rather than a reversed one. Reversing swaps a span's own
60 + /// foreground into the background, so a selected `FAIL` row used to paint its
61 + /// danger colour behind the text and lose the one signal the row carries;
62 + /// giving selection a surface of its own leaves every status colour intact on
63 + /// top of it.
64 + fn selected(theme: &Theme) -> Style {
65 + Style::default()
66 + .bg(theme.surface_raised)
67 + .fg(theme.content_primary)
68 + }
69 +
70 + /// A bordered container: the frame in the theme's border colour, the title in
71 + /// secondary content so it reads as a label on the box rather than as content.
72 + fn container(theme: &Theme, title: &'static str) -> Block<'static> {
73 + Block::bordered()
74 + .border_style(Style::default().fg(theme.line_border))
75 + .title(Span::styled(
76 + title,
77 + Style::default().fg(theme.content_secondary),
78 + ))
79 + }
80 +
81 + fn render_header(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
45 82 let worst = model.worst(now);
46 83 let [mark, tabs] = Layout::horizontal([Constraint::Length(6), Constraint::Min(1)]).areas(area);
47 84
85 + // The rollup mark is a filled chip: the worst status is the one thing on
86 + // screen that has to be readable from across the room, so it takes the
87 + // status colour as a background rather than as text.
88 + let status = value::status_style(theme, worst);
48 89 frame.render_widget(
49 90 Paragraph::new(Span::styled(
50 91 format!(" {} ", value::status_mark(worst)),
51 - value::status_style(worst).add_modifier(Modifier::REVERSED),
92 + Style::default()
93 + .bg(status.fg.unwrap_or(theme.content_primary))
94 + .fg(theme.surface_page)
95 + .add_modifier(Modifier::BOLD),
52 96 )),
53 97 mark,
54 98 );
55 99 frame.render_widget(
56 100 Tabs::new(model.tab_titles())
57 101 .select(model.tab_index())
58 - .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
102 + .style(muted(theme))
103 + .highlight_style(selected(theme).add_modifier(Modifier::BOLD))
59 104 .divider(" "),
60 105 tabs,
61 106 );
62 107 }
63 108
64 - fn render_footer(model: &Model, frame: &mut Frame, area: Rect) {
109 + fn render_footer(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
65 110 let text = match &model.message {
66 - Some(message) => Span::from(format!(" {message}")).yellow(),
67 - None => {
68 - Span::from(" tab/shift-tab switch up/down move enter open 1-9 jump q quit").dim()
69 - }
111 + // A fired action's outcome, which is transient and worth noticing.
112 + Some(message) => Span::styled(
113 + format!(" {message}"),
114 + Style::default().fg(theme.status_warning),
115 + ),
116 + None => Span::styled(
117 + " tab/shift-tab switch up/down move enter open 1-9 jump q quit",
118 + muted(theme),
119 + ),
70 120 };
71 - frame.render_widget(Paragraph::new(Line::from(text)), area);
121 + frame.render_widget(
122 + Paragraph::new(Line::from(text)).style(Style::default().bg(theme.surface_sunken)),
123 + area,
124 + );
72 125 }
73 126
74 127 // ---------------------------------------------------------------------------
@@ -79,7 +132,7 @@
79 132 ///
80 133 /// Without this the viewer is N tabs you still have to visit one at a time,
81 134 /// which is the situation it replaces, with extra steps.
82 - fn render_rollup(model: &Model, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
135 + fn render_rollup(model: &Model, theme: &Theme, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
83 136 let order = model.rollup_order(now);
84 137 let rows: Vec<Row> = order
85 138 .iter()
@@ -92,14 +145,14 @@
92 145 None => "-".into(),
93 146 };
94 147 let style = if row == model.rollup_selected {
95 - Style::default().add_modifier(Modifier::REVERSED)
148 + selected(theme)
96 149 } else {
97 - Style::default()
150 + Style::default().fg(theme.content_primary)
98 151 };
99 152 Row::new(vec![
100 - Cell::from(value::status_mark(status)).style(value::status_style(status)),
153 + Cell::from(value::status_mark(status)).style(value::status_style(theme, status)),
101 154 Cell::from(source.name.clone()),
102 - Cell::from(age),
155 + Cell::from(age).style(muted(theme)),
103 156 Cell::from(source.summary(now)),
104 157 ])
105 158 .style(style)
@@ -115,11 +168,8 @@
115 168 Constraint::Min(10),
116 169 ],
117 170 )
118 - .header(
119 - Row::new(vec!["", "source", "age", "detail"])
120 - .style(Style::default().add_modifier(Modifier::DIM)),
121 - )
122 - .block(Block::bordered().title(" all sources "));
171 + .header(Row::new(vec!["", "source", "age", "detail"]).style(muted(theme)))
172 + .block(container(theme, " all sources "));
123 173
124 174 frame.render_widget(table, area);
125 175 }
@@ -128,7 +178,13 @@
128 178 // One source
129 179 // ---------------------------------------------------------------------------
130 180
131 - fn render_source(source: &SourceState, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
181 + fn render_source(
182 + source: &SourceState,
183 + theme: &Theme,
184 + now: DateTime<Utc>,
185 + frame: &mut Frame,
186 + area: Rect,
187 + ) {
132 188 let [list_area, detail_area] =
133 189 Layout::vertical([Constraint::Percentage(55), Constraint::Min(5)]).areas(area);
134 190
@@ -138,35 +194,51 @@
138 194 .enumerate()
139 195 .map(|(i, row)| {
140 196 let indent = " ".repeat(row.depth);
141 - let mut spans = vec![
197 + let spans = vec![
142 198 Span::styled(
143 199 format!("{:<5}", value::status_mark(row.node.status)),
144 - value::status_style(row.node.status),
200 + value::status_style(theme, row.node.status),
145 201 ),
146 - Span::raw(format!("{indent}{}", row.node.label)),
147 - Span::styled(format!(" ({})", row.node.kind), Style::default().dim()),
202 + Span::styled(
203 + format!("{indent}{}", row.node.label),
204 + Style::default().fg(theme.content_primary),
205 + ),
206 + Span::styled(format!(" ({})", row.node.kind), muted(theme)),
148 207 ];
208 + // Selection is the row's surface, applied under the spans rather
209 + // than patched into each one, so a status colour survives being
210 + // selected.
211 + let item = ListItem::new(Line::from(spans));
149 212 if i == source.selected {
150 - spans = spans
151 - .into_iter()
152 - .map(|s| s.patch_style(Style::default().add_modifier(Modifier::REVERSED)))
153 - .collect();
213 + item.style(selected(theme))
214 + } else {
215 + item
154 216 }
155 - ListItem::new(Line::from(spans))
156 217 })
157 218 .collect();
158 219
159 220 let title = format!(" {} ", source.name);
160 221 let list = if items.is_empty() {
161 - List::new(vec![ListItem::new(Line::from(
162 - Span::from(source.summary(now)).dim(),
163 - ))])
222 + List::new(vec![ListItem::new(Line::from(Span::styled(
223 + source.summary(now),
224 + muted(theme),
225 + )))])
164 226 } else {
165 227 List::new(items)
166 228 };
167 - frame.render_widget(list.block(Block::bordered().title(title)), list_area);
229 + frame.render_widget(
230 + list.block(
231 + Block::bordered()
232 + .border_style(Style::default().fg(theme.line_border))
233 + .title(Span::styled(
234 + title,
235 + Style::default().fg(theme.content_secondary),
236 + )),
237 + ),
238 + list_area,
239 + );
168 240
169 - render_detail(source, now, frame, detail_area);
241 + render_detail(source, theme, now, frame, detail_area);
170 242 }
171 243
172 244 /// The selected node's fields and conditions.
@@ -174,7 +246,13 @@
174 246 /// Conditions are the half that usually gets dropped, and the half that pays:
175 247 /// "blocked" is useless, "blocked because burn_in is 31h of 48h" is what saves
176 248 /// an SSH.
177 - fn render_detail(source: &SourceState, now: DateTime<Utc>, frame: &mut Frame, area: Rect) {
249 + fn render_detail(
250 + source: &SourceState,
251 + theme: &Theme,
252 + now: DateTime<Utc>,
253 + frame: &mut Frame,
254 + area: Rect,
255 + ) {
178 256 let width = area.width.saturating_sub(4) as usize;
179 257 let mut lines: Vec<Line> = Vec::new();
180 258
@@ -183,13 +261,18 @@
183 261 lines.push(Line::from(vec![
184 262 Span::styled(
185 263 value::status_mark(node.status),
186 - value::status_style(node.status),
264 + value::status_style(theme, node.status),
187 265 ),
188 266 Span::raw(" "),
189 - Span::styled(node.label.clone(), Style::default().bold()),
267 + Span::styled(
268 + node.label.clone(),
269 + Style::default()
270 + .fg(theme.content_primary)
271 + .add_modifier(Modifier::BOLD),
272 + ),
190 273 ]));
191 - lines.extend(field_lines(node, now, width));
192 - lines.extend(condition_lines(node, width));
274 + lines.extend(field_lines(theme, node, now, width));
275 + lines.extend(condition_lines(theme, node, width));
193 276 if !node.actions.is_empty() {
194 277 // The hint tells the operator whether Enter does anything here,
195 278 // so a read-only source does not look broken when a keypress is
@@ -200,21 +283,24 @@
200 283 " (read-only)"
201 284 };
202 285 lines.push(Line::from(vec![
203 - Span::from(format!("actions: {}", node.actions.join(", "))).dim(),
204 - Span::from(hint).dim(),
286 + Span::styled(
287 + format!("actions: {}", node.actions.join(", ")),
288 + muted(theme),
289 + ),
290 + Span::styled(hint, muted(theme)),
205 291 ]));
206 292 }
207 293 }
208 - None => lines.push(Line::from(Span::from(source.summary(now)).dim())),
294 + None => lines.push(Line::from(Span::styled(source.summary(now), muted(theme)))),
209 295 }
210 296
211 297 frame.render_widget(
212 - Paragraph::new(lines).block(Block::bordered().title(" detail ")),
298 + Paragraph::new(lines).block(container(theme, " detail ")),
213 299 area,
214 300 );
215 301 }
216 302
217 - fn field_lines(node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> {
303 + fn field_lines(theme: &Theme, node: &Node, now: DateTime<Utc>, width: usize) -> Vec<Line<'static>> {
218 304 let label_width = node
219 305 .fields
220 306 .iter()
@@ -229,37 +315,37 @@
229 315 let rendered = value::render(&field.value, now, budget);
230 316 let style = match &field.value {
231 317 ops_status::Value::Progress { value, max, .. } => {
232 - value::progress_style(*value, *max)
318 + value::progress_style(theme, *value, *max)
233 319 }
234 - other => value::style(other),
320 + other => value::style(theme, other),
235 321 };
236 322 Line::from(vec![
237 - Span::styled(
238 - format!("{:<label_width$} ", field.label),
239 - Style::default().dim(),
240 - ),
323 + Span::styled(format!("{:<label_width$} ", field.label), muted(theme)),
241 324 Span::styled(rendered, style),
242 325 ])
243 326 })
244 327 .collect()
245 328 }
246 329
247 - fn condition_lines(node: &Node, width: usize) -> Vec<Line<'static>> {
330 + fn condition_lines(theme: &Theme, node: &Node, width: usize) -> Vec<Line<'static>> {
248 331 node.conditions
249 332 .iter()
250 333 .map(|condition| {
251 334 let mut spans = vec![
252 335 Span::styled(
253 336 format!("{:<5}", value::status_mark(condition.status)),
254 - value::status_style(condition.status),
337 + value::status_style(theme, condition.status),
338 + ),
339 + Span::styled(
340 + condition.condition_type.clone(),
341 + Style::default().fg(theme.content_primary),
255 342 ),
256 - Span::raw(condition.condition_type.clone()),
257 343 ];
258 344 if let Some(detail) = &condition.detail {
259 345 let budget = width.saturating_sub(condition.condition_type.chars().count() + 8);
260 346 spans.push(Span::styled(
261 347 format!(" {}", truncate(detail, budget.max(8))),
262 - Style::default().dim(),
348 + muted(theme),
263 349 ));
264 350 }
265 351 Line::from(spans)
@@ -277,9 +363,11 @@
277 363 /// muscle memory cannot type `rollback-b`, which is the whole safeguard. The
278 364 /// resolved host is left off — the tab already names the source — so the line
279 365 /// stays short and shows the method and path the request will use.
280 - fn render_prompt(model: &Model, frame: &mut Frame, area: Rect) {
366 + fn render_prompt(model: &Model, theme: &Theme, frame: &mut Frame, area: Rect) {
281 367 let Some(prompt) = &model.prompt else { return };
282 368 let source = model.sources.get(prompt_source(prompt));
369 + let plain = Style::default().fg(theme.content_primary);
370 + let danger_style = Style::default().fg(theme.status_danger);
283 371
284 372 let (title, title_style, lines) = match prompt {
285 373 Prompt::Pick { keys, selected, .. } => {
@@ -289,12 +377,12 @@
289 377 let label = action.map_or(key.as_str(), |a| a.label.as_str());
290 378 let danger = action.is_some_and(|a| a.danger);
291 379 let marker = if i == *selected { "> " } else { " " };
292 - let mut style = Style::default();
293 - if danger {
294 - style = style.fg(Color::Red);
295 - }
380 + // Danger is the foreground and selection the surface, so a
381 + // selected danger action is still red rather than swapped into
382 + // its own background.
383 + let mut style = if danger { danger_style } else { plain };
296 384 if i == *selected {
297 - style = style.add_modifier(Modifier::REVERSED);
385 + style = style.bg(theme.surface_raised);
298 386 }
299 387 lines.push(Line::from(Span::styled(
300 388 format!("{marker}{}. {label} [{key}]", i + 1),
@@ -302,40 +390,45 @@
302 390 )));
303 391 }
304 392 lines.push(Line::from(""));
305 - lines.push(Line::from(Span::from("enter run esc cancel").dim()));
306 - (" run action ", Style::default(), lines)
393 + lines.push(Line::from(Span::styled(
394 + "enter run esc cancel",
395 + muted(theme),
396 + )));
397 + (
398 + " run action ",
399 + Style::default().fg(theme.line_border),
400 + lines,
401 + )
307 402 }
308 403 Prompt::Confirm { key, .. } => {
309 404 let action = source.and_then(|s| s.action(key));
310 405 let lines = vec![
311 - action_summary_line(action, key),
406 + action_summary_line(theme, action, key),
312 407 Line::from(""),
313 - Line::from(Span::from("press y to confirm esc cancel").dim()),
408 + Line::from(Span::styled(
409 + "press y to confirm esc cancel",
410 + muted(theme),
411 + )),
314 412 ];
315 - (" confirm ", Style::default(), lines)
413 + (" confirm ", Style::default().fg(theme.line_border), lines)
316 414 }
317 415 Prompt::Type { key, typed, .. } => {
318 - let action = source.and_then(|s| s.action(key));
319 416 let lines = vec![
320 417 Line::from(Span::styled(
321 418 "DANGER",
322 - Style::default().fg(Color::Red).bold(),
419 + danger_style.add_modifier(Modifier::BOLD),
323 420 )),
324 - action_summary_line(action, key),
421 + action_summary_line(theme, source.and_then(|s| s.action(key)), key),
325 422 Line::from(""),
326 - Line::from(Span::from(format!("type '{key}' to confirm:")).dim()),
327 423 Line::from(Span::styled(
328 - format!("> {typed}\u{258f}"),
329 - Style::default().fg(Color::Red),
424 + format!("type '{key}' to confirm:"),
425 + muted(theme),
330 426 )),
427 + Line::from(Span::styled(format!("> {typed}\u{258f}"), danger_style)),
331 428 Line::from(""),
332 - Line::from(Span::from("esc cancel").dim()),
429 + Line::from(Span::styled("esc cancel", muted(theme))),
333 430 ];
334 - (
335 - " DANGER ",
336 - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
337 - lines,
338 - )
431 + (" DANGER ", danger_style.add_modifier(Modifier::BOLD), lines)
339 432 }
340 433 };
341 434
@@ -349,30 +442,46 @@
349 442 let height = lines.len() as u16 + 2;
350 443 let popup = centered(area, width, height);
351 444
352 - // Clear what is under the popup so the tab behind does not show through.
445 + // Clear what is under the popup so the tab behind does not show through,
446 + // then paint the overlay surface: makeover's `surface.overlay` is the
447 + // intent for exactly this, a surface that floats above the page.
353 448 frame.render_widget(Clear, popup);
354 449 frame.render_widget(
355 - Paragraph::new(lines).block(
356 - Block::bordered()
357 - .title(Span::styled(title, title_style))
358 - .border_style(title_style),
359 - ),
450 + Paragraph::new(lines)
451 + .style(Style::default().bg(theme.surface_overlay))
452 + .block(
453 + Block::bordered()
454 + .title(Span::styled(title, title_style))
455 + .border_style(title_style),
456 + ),
360 457 popup,
361 458 );
362 459 }
363 460
364 461 /// One line naming what an action will do: `Roll back POST /rollback/b`.
365 - fn action_summary_line(action: Option<&ops_status::Action>, key: &str) -> Line<'static> {
462 + fn action_summary_line(
463 + theme: &Theme,
464 + action: Option<&ops_status::Action>,
465 + key: &str,
Lines truncated
M viewer/src/value.rs +102 -26
@@ -11,22 +11,45 @@
11 11 //! snapshot-testable.
12 12
13 13 use chrono::{DateTime, Utc};
14 + use makeover_tui::Theme;
14 15 use ops_status::{Status, Value};
15 - use ratatui::style::{Color, Modifier, Style};
16 + use ratatui::style::{Modifier, Style};
16 17
17 18 /// The one place a status becomes a color.
18 - pub(crate) fn status_style(status: Status) -> Style {
19 + ///
20 + /// The contract has six statuses and makeover has four status intents, so two
21 + /// of these have to be said some other way. Not with a category slot, which is
22 + /// where this landed first and is wrong twice over: category colors exist for
23 + /// data with no inherent meaning (a project's color, a series on a chart), and
24 + /// a theme is free to author them as the status hues restated — `makenotwork`
25 + /// gives `category.six` and `status.info` the same value, which collapsed
26 + /// `undistributed` onto `pending` in exactly the theme the viewer ships with.
27 + ///
28 + /// So the two extra states are carried by a modifier on the nearest intent,
29 + /// which is the renderer's own doctrine: a terminal's real constraint is
30 + /// geometry rather than color, and a distinction that has to survive any theme
31 + /// cannot rest on a hue the vocabulary does not name.
32 + pub(crate) fn status_style(theme: &Theme, status: Status) -> Style {
19 33 match status {
20 - Status::Ok => Style::default().fg(Color::Green),
21 - // Cyan: adjacent to green rather than to the alarm colors, because a
22 - // build waiting on its upload is finished work, not broken work.
23 - Status::Undistributed => Style::default().fg(Color::Cyan),
24 - Status::Degraded => Style::default().fg(Color::Yellow),
25 - Status::Failed => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
26 - Status::Pending => Style::default().fg(Color::Blue),
27 - // Magenta, not gray: an unreachable source is not a quiet absence, it
28 - // is the loudest thing a viewer can fail to tell you.
29 - Status::Unknown => Style::default().fg(Color::Magenta),
34 + Status::Ok => Style::default().fg(theme.status_success),
35 + // Success, faded: a build waiting on its upload is finished work, not
36 + // broken work, and the thing it is short of is arrival rather than
37 + // health.
38 + Status::Undistributed => Style::default()
39 + .fg(theme.status_success)
40 + .add_modifier(Modifier::DIM),
41 + Status::Degraded => Style::default().fg(theme.status_warning),
42 + Status::Failed => Style::default()
43 + .fg(theme.status_danger)
44 + .add_modifier(Modifier::BOLD),
45 + Status::Pending => Style::default().fg(theme.status_info),
46 + // Muted, and inverted so it is anything but quiet. An unreachable
47 + // source is a real absence — no status intent is honest about it — but
48 + // it is the loudest thing a viewer can fail to tell you, so it is drawn
49 + // as a filled block rather than as gray text.
50 + Status::Unknown => Style::default()
51 + .fg(theme.content_muted)
52 + .add_modifier(Modifier::REVERSED | Modifier::BOLD),
30 53 }
31 54 }
32 55
@@ -59,14 +82,19 @@
59 82 }
60 83
61 84 /// The style a value carries on its own, if any.
62 - pub(crate) fn style(value: &Value) -> Style {
85 + pub(crate) fn style(theme: &Theme, value: &Value) -> Style {
63 86 match value {
64 - Value::State { value } => status_style(*value),
65 - Value::Ident { .. } | Value::Path { .. } => Style::default().fg(Color::Cyan),
87 + Value::State { value } => status_style(theme, *value),
88 + // A digest and a path are data you read across rather than prose:
89 + // secondary content, set apart from the muted label to its left and
90 + // from the primary text of an ordinary value.
91 + Value::Ident { .. } | Value::Path { .. } => Style::default().fg(theme.content_secondary),
92 + // The action colour, which is the one place accent on text is not
93 + // decoration: a link is the only value here that names somewhere to go.
66 94 Value::Link { .. } => Style::default()
67 - .fg(Color::Cyan)
95 + .fg(theme.action_primary)
68 96 .add_modifier(Modifier::UNDERLINED),
69 - _ => Style::default(),
97 + _ => Style::default().fg(theme.content_primary),
70 98 }
71 99 }
72 100
@@ -183,11 +211,11 @@
183 211
184 212 /// How a progress value should be colored: complete is done, not merely far
185 213 /// along.
186 - pub(crate) fn progress_style(value: f64, max: f64) -> Style {
214 + pub(crate) fn progress_style(theme: &Theme, value: f64, max: f64) -> Style {
187 215 if max > 0.0 && value >= max {
188 - Style::default().fg(Color::Green)
216 + Style::default().fg(theme.status_success)
189 217 } else {
190 - Style::default().fg(Color::Blue)
218 + Style::default().fg(theme.status_info)
191 219 }
192 220 }
193 221
@@ -270,11 +298,15 @@
270 298
271 299 #[test]
272 300 fn a_complete_bar_reads_as_done() {
301 + let theme = crate::theme::tests::fixed();
273 302 assert_eq!(
274 - progress_style(48.0, 48.0),
275 - Style::default().fg(Color::Green)
303 + progress_style(&theme, 48.0, 48.0),
304 + Style::default().fg(theme.status_success)
305 + );
306 + assert_eq!(
307 + progress_style(&theme, 31.0, 48.0),
308 + Style::default().fg(theme.status_info)
276 309 );
277 - assert_eq!(progress_style(31.0, 48.0), Style::default().fg(Color::Blue));
278 310 }
279 311
280 312 #[test]
@@ -333,10 +365,54 @@
333 365 assert_eq!(render(&without, now(), 40), "https://makenot.work");
334 366 }
335 367
368 + const ALL_STATUSES: [Status; 6] = [
369 + Status::Ok,
370 + Status::Undistributed,
371 + Status::Degraded,
372 + Status::Failed,
373 + Status::Pending,
374 + Status::Unknown,
375 + ];
376 +
336 377 #[test]
337 378 fn an_unknown_status_is_loud_not_gray() {
338 - // A source nobody can reach must not read as a quiet absence.
339 - assert_eq!(status_style(Status::Unknown).fg, Some(Color::Magenta));
340 - assert_eq!(status_style(Status::Ok).fg, Some(Color::Green));
379 + // A source nobody can reach must not read as a quiet absence. Stated
380 + // against the theme's own intents rather than against a hex value: what
381 + // makes it loud is the inversion, which no theme can author away.
382 + let theme = crate::theme::tests::fixed();
383 + let unknown = status_style(&theme, Status::Unknown);
384 + assert!(
385 + unknown.add_modifier.contains(Modifier::REVERSED),
386 + "an unreachable source must be a filled block, not gray text",
387 + );
388 + assert_eq!(
389 + status_style(&theme, Status::Ok).fg,
390 + Some(theme.status_success)
391 + );
392 + }
393 +
394 + #[test]
395 + fn no_two_statuses_render_the_same() {
396 + // The failure this guards is silent: a theme free to author its intents
397 + // however it likes can hand two statuses the same hue, and the one
398 + // screen whose whole job is telling them apart stops doing it. Checked
399 + // over every theme makeover ships, not only the viewer's own default,
400 + // since an operator can name any of them in `viewer.toml`.
401 + for (id, source) in makeover::embedded_themes() {
402 + let colors = makeover::parse_theme_str(id, source, false)
403 + .unwrap_or_else(|e| panic!("bundled theme `{id}` does not parse: {e}"));
404 + let Ok(theme) = Theme::from_theme(&colors) else {
405 + continue; // incomplete themes are `theme::load`'s error to report
406 + };
407 + for (i, a) in ALL_STATUSES.iter().enumerate() {
408 + for b in &ALL_STATUSES[i + 1..] {
409 + let (sa, sb) = (status_style(&theme, *a), status_style(&theme, *b));
410 + assert!(
411 + (sa.fg, sa.add_modifier) != (sb.fg, sb.add_modifier),
412 + "theme `{id}` renders {a:?} and {b:?} identically",
413 + );
414 + }
415 + }
416 + }
341 417 }
342 418 }
@@ -1,0 +1,247 @@
1 + //! Which theme the viewer renders in, and where it loads from.
2 + //!
3 + //! No hex value is written anywhere in this crate. There is deliberately no
4 + //! built-in fallback palette: a missing or malformed theme is an error the
5 + //! operator sees, not something papered over by rendering in colors that exist
6 + //! nowhere in the theme files.
7 + //!
8 + //! The shared app convention is wiki `makeover-app-convention`. The viewer is
9 + //! one of the store-less apps that note describes, with one difference worth
10 + //! knowing: it already owns a config file, so `viewer.toml` *is* the
11 + //! file-backed store and the convention's unprefixed `theme` key lives there.
12 + //! There is no picker and nothing writes the key back, which is why this
13 + //! module has no `remember`: the viewer displays, and choosing a theme is an
14 + //! edit to the same file that says what to display.
15 +
16 + use std::path::PathBuf;
17 +
18 + use anyhow::{Context, Result};
19 + use makeover::{ThemeDefaults, ThemeDirs, ThemeMeta, ThemeSelection, Variant};
20 + use makeover_tui::{Fidelity, Theme};
21 +
22 + /// The viewer's own light theme: the platform's titular skin.
23 + const DEFAULT_LIGHT: &str = "makenotwork";
24 +
25 + /// The viewer's own dark theme.
26 + ///
27 + /// MNW authors no dark skin of its own, so this is the nearest thing in the
28 + /// bundled set: near-neutral greys on the same axis as `makenotwork`'s
29 + /// parchment, rather than a theme with a hue of its own to bring. It is a
30 + /// fallback and not a pin — `ThemeSelection::resolve` reaches any installed
31 + /// dark theme when this one is missing, and the operator names the theme they
32 + /// want in `viewer.toml`.
33 + const DEFAULT_DARK: &str = "carbonfox";
34 +
35 + /// Theme search path: the operator's own themes win, then makeover's bundled
36 + /// set.
37 + ///
38 + /// Two tiers and not three. There is no packaged `/usr/share` tier because
39 + /// nothing packages the viewer — it is an operator binary run out of a build,
40 + /// so a system tier would be a directory that never exists.
41 + fn search_path() -> Vec<(PathBuf, bool)> {
42 + ThemeDirs::new()
43 + .bundled(makeover::bundled_themes_dir())
44 + .custom(config_home().map(|config| config.join("ops-viewer").join("themes")))
45 + .build()
46 + }
47 +
48 + /// `$XDG_CONFIG_HOME`, else `~/.config`, matching where the viewer already
49 + /// looks for `viewer.toml`.
50 + fn config_home() -> Option<PathBuf> {
51 + if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
52 + return Some(PathBuf::from(xdg));
53 + }
54 + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))
55 + }
56 +
57 + /// The themes the viewer falls back to when nothing has been chosen.
58 + fn defaults() -> ThemeDefaults {
59 + ThemeDefaults::new(DEFAULT_LIGHT, DEFAULT_DARK)
60 + }
61 +
62 + /// Every theme the viewer can render, from the same search path it loads from.
63 + ///
64 + /// makeover's own scan rather than a second one: a list here that disagreed
65 + /// with [`load`] would resolve to an id that then failed to load.
66 + fn available() -> Vec<ThemeMeta> {
67 + makeover::list_themes_from_dirs(&search_path())
68 + }
69 +
70 + /// What the viewer is being drawn on, as makeover's vocabulary.
71 + ///
72 + /// The terminal's answer to a `prefers-color-scheme` media query, and the only
73 + /// one available without writing an OSC query and waiting for a reply before
74 + /// the first frame. `COLORFGBG` carries the background as a color index; 0-6
75 + /// and 8 are the dark ones. A terminal that says nothing reads as light, which
76 + /// is the documented default.
77 + fn ambient() -> Variant {
78 + terminal_background().unwrap_or(Variant::Light)
79 + }
80 +
81 + fn terminal_background() -> Option<Variant> {
82 + let value = std::env::var("COLORFGBG").ok()?;
83 + let bg = value.rsplit(';').next()?.trim().parse::<u8>().ok()?;
84 + Some(if bg <= 6 || bg == 8 {
85 + Variant::Dark
86 + } else {
87 + Variant::Light
88 + })
89 + }
90 +
91 + /// Load the theme a selection resolves to, as this terminal can draw it.
92 + ///
93 + /// Resolved by makeover against the ambient mode and what is actually
94 + /// installed, so following the terminal reaches any dark theme the operator
95 + /// dropped in rather than only the one this crate names, and a theme that has
96 + /// since been deleted falls back instead of failing to load.
97 + ///
98 + /// Quantized through [`Theme::for_terminal`] rather than handed over as
99 + /// 24-bit. Left alone, a terminal below truecolor approximates the colors
100 + /// itself and its approximation collapses tones the theme keeps apart — which
101 + /// on this surface means a `degraded` and a `failed` source that no longer
102 + /// look different, on the one screen whose whole job is that difference.
103 + pub(crate) fn load(selection: &ThemeSelection) -> Result<Theme> {
104 + let dirs = search_path();
105 + let id = selection.resolve(ambient(), &defaults(), &available());
106 +
107 + let colors = makeover::load_theme(&dirs, &id)
108 + .map_err(anyhow::Error::msg)
109 + .with_context(|| {
110 + let searched: Vec<String> = dirs
111 + .iter()
112 + .map(|(path, _)| path.display().to_string())
113 + .collect();
114 + format!("loading theme `{id}` (searched: {})", searched.join(", "))
115 + })?;
116 +
117 + Theme::from_theme(&colors)
118 + .map(|theme| theme.for_terminal(Fidelity::detect()))
119 + .map_err(|e| anyhow::anyhow!("{e}"))
120 + .with_context(|| format!("theme `{id}` is incomplete"))
121 + }
122 +
123 + #[cfg(test)]
124 + pub(crate) mod tests {
125 + use super::*;
126 +
127 + /// A fixed theme for the render tests.
128 + ///
129 + /// Parsed from makeover's embedded copy rather than loaded off the search
130 + /// path, so the snapshot tests stay pure: they do not depend on which
131 + /// directories exist on the machine running them, on `COLORFGBG`, or on
132 + /// what the operator installed.
133 + pub(crate) fn fixed() -> Theme {
134 + let (_, source) = makeover::embedded_themes()
135 + .find(|(id, _)| *id == DEFAULT_LIGHT)
136 + .expect("makeover embeds the viewer's default theme");
137 + let colors = makeover::parse_theme_str(DEFAULT_LIGHT, source, false)
138 + .expect("the embedded default theme parses");
139 + Theme::from_theme(&colors).expect("the embedded default theme is complete")
140 + }
141 +
142 + // The operator's own themes must outrank the bundled ones. Asserted from
143 + // this side because `ThemeDirs` is what makes the order not this file's to
144 + // get backwards, and a hand-built vector here could still reverse it.
145 + #[test]
146 + fn the_operators_own_themes_outrank_the_bundled_ones() {
147 + let dirs = search_path();
148 + assert!(
149 + dirs.iter().filter(|(_, is_custom)| *is_custom).count() <= 1,
150 + "exactly one tier is the operator's: {dirs:?}",
151 + );
152 + if let (Some(custom), Some(bundled)) = (
153 + dirs.iter().position(|(_, is_custom)| *is_custom),
154 + dirs.iter().position(|(_, is_custom)| !*is_custom),
155 + ) {
156 + assert!(
157 + custom > bundled,
158 + "the operator's themes must come last so they win: {dirs:?}",
159 + );
160 + }
161 + }
162 +
163 + // Nothing in `viewer.toml` is Follow, not a pin on whatever the first run
164 + // guessed.
165 + #[test]
166 + fn an_unset_key_follows_the_terminal() {
167 + assert_eq!(ThemeSelection::parse(None), ThemeSelection::Follow);
168 + }
169 +
170 + // `COLORFGBG` carries the background as a color index; 0-6 and 8 are dark.
171 + #[test]
172 + fn the_terminal_background_reads_as_a_variant() {
173 + for (raw, expect) in [
174 + ("15;0", Variant::Dark),
175 + ("0;15", Variant::Light),
176 + ("15;8", Variant::Dark),
177 + ("15;7", Variant::Light),
178 + ] {
179 + let dark = raw
180 + .rsplit(';')
181 + .next()
182 + .and_then(|bg| bg.trim().parse::<u8>().ok())
183 + .is_some_and(|bg| bg <= 6 || bg == 8);
184 + let got = if dark { Variant::Dark } else { Variant::Light };
185 + assert_eq!(got, expect, "COLORFGBG={raw}");
186 + }
187 + }
188 +
189 + // Following resolves to the theme matching the terminal rather than to a
190 + // fixed default, and both ids the viewer names are ones makeover ships.
191 + #[test]
192 + fn following_resolves_to_the_theme_matching_the_terminal() {
193 + let available = available();
194 + if available.is_empty() {
195 + return; // no theme directory on this machine; nothing to resolve against
196 + }
197 + assert_eq!(
198 + ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available),
199 + DEFAULT_DARK,
200 + );
201 + assert_eq!(
202 + ThemeSelection::Follow.resolve(Variant::Light, &defaults(), &available),
203 + DEFAULT_LIGHT,
204 + );
205 + }
206 +
207 + // A pin wins over the ambient mode; that is the difference between a
208 + // selection and a rendered id.
209 + #[test]
210 + fn a_pinned_theme_ignores_the_terminal() {
211 + let available = available();
212 + if available.is_empty() {
213 + return;
214 + }
215 + let pinned = ThemeSelection::parse(Some("nord"));
216 + assert_eq!(
217 + pinned.resolve(Variant::Light, &defaults(), &available),
218 + "nord"
219 + );
220 + }
221 +
222 + // Both ids this crate ships against exist in makeover's set. A rename over
223 + // there should fail here rather than at an operator's first launch.
224 + #[test]
225 + fn the_shipped_defaults_load_and_are_complete() {
226 + if available().is_empty() {
227 + return;
228 + }
229 + for id in [DEFAULT_LIGHT, DEFAULT_DARK] {
230 + let colors = makeover::load_theme(&search_path(), id)
231 + .unwrap_or_else(|e| panic!("default theme `{id}` failed to load: {e}"));
232 + assert!(
233 + Theme::from_theme(&colors).is_ok(),
234 + "default theme `{id}` is incomplete",
235 + );
236 + }
237 + }
238 +
239 + #[test]
240 + fn a_missing_theme_is_an_error_not_a_fallback() {
241 + // A `Fixed` id that is not installed falls back through `resolve`, so
242 + // the error path is the one where the resolved theme itself is
243 + // unreadable. Asserted through `load_theme` directly, since `resolve`
244 + // by design never hands back an id it did not find.
245 + assert!(makeover::load_theme(&search_path(), "no-such-theme").is_err());
246 + }
247 + }