Skip to main content

max / makenotwork

Put mnw-cli on makeover-tui Every colour is a makeover intent now, resolved through ThemeSelection, and no screen names one. 71 literals across 12 screens map onto the vocabulary. The port had to answer two questions the other tier-E surfaces do not, both following from mnw-cli being an SSH daemon: the terminal is at the far end of the connection, so nothing about it can be read from this process. Themes come from makeover's embedded set rather than a disk search path. bundled_themes_dir() resolves against makeover's CARGO_MANIFEST_DIR, which on a deployed host names a directory belonging to the build machine, and a remote creator cannot drop a theme file on the host to fill a tier anyway. Capability detection comes off the wire. pty_request keeps the client's TERM instead of discarding it, env_request records COLORTERM and COLORFGBG, and Fidelity::from_env reads those rather than Fidelity::detect() reading the daemon's own environment. Everything else a client sends is ignored: nothing here spawns a process, and an environment nobody reads is a place to park unbounded data. Also fixes two things the theme made visible. A selected row carried its selection in the foreground as well as the background, repainting the text of exactly the row being looked at, so a red failed upload and a green published item stopped differing there; it is a background alone now, matching got and viewer. And the page was never painted at all, which left a light theme's borders and labels sitting on whatever the creator's terminal happened to be. UserInfo takes theme_id with serde(default), so the CLI honours a creator's choice as soon as the server stores and sends one. Absent and unset both resolve to Follow, which is the behaviour today. every_rendered_colour_comes_from_the_theme renders a screen into a TestBackend and asserts every cell's fg and bg is a theme intent, so a literal added back fails here rather than reaching a creator whose theme has no red in it.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 14:47 UTC
Signed with PGP, not checked
Commit: e29be816c4061dbc2c291fb8fb38e07eb7b8959b
Parent: 76a2e57
19 files changed, +843 insertions, -126 deletions
M mnw-cli/Cargo.lock +131 -4
@@ -1530,6 +1530,25 @@
1530 1530 "icu_properties",
1531 1531 ]
1532 1532
1533 + [[package]]
1534 + name = "include_dir"
1535 + version = "0.7.4"
1536 + source = "registry+https://github.com/rust-lang/crates.io-index"
1537 + checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
1538 + dependencies = [
1539 + "include_dir_macros",
1540 + ]
1541 +
1542 + [[package]]
1543 + name = "include_dir_macros"
1544 + version = "0.7.4"
1545 + source = "registry+https://github.com/rust-lang/crates.io-index"
1546 + checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
1547 + dependencies = [
1548 + "proc-macro2",
1549 + "quote",
1550 + ]
1551 +
1533 1552 [[package]]
1534 1553 name = "indexmap"
1535 1554 version = "2.13.0"
@@ -1794,6 +1813,34 @@
1794 1813 "winapi",
1795 1814 ]
1796 1815
1816 + [[package]]
1817 + name = "makeover"
1818 + version = "2.4.1"
1819 + source = "registry+https://github.com/rust-lang/crates.io-index"
1820 + checksum = "b0a796c931419e54dd71a8aaa7d62230730e958f3101d37d4c1d3ab7b541b38e"
1821 + dependencies = [
1822 + "include_dir",
1823 + "serde",
1824 + "toml",
1825 + ]
1826 +
1827 + [[package]]
1828 + name = "makeover-layout"
1829 + version = "0.12.0"
1830 + source = "registry+https://github.com/rust-lang/crates.io-index"
1831 + checksum = "58edd16523115ed4c9ca6de016693300ac95cf1bb0bd8ccf7fd246213102a7ff"
1832 +
1833 + [[package]]
1834 + name = "makeover-tui"
1835 + version = "0.12.0"
1836 + source = "registry+https://github.com/rust-lang/crates.io-index"
1837 + checksum = "88982f3e29d40336e9748e9adafa88278d123c42875aad178e0cea640a128d57"
1838 + dependencies = [
1839 + "makeover",
1840 + "makeover-layout",
1841 + "ratatui",
1842 + ]
1843 +
1797 1844 [[package]]
1798 1845 name = "matchers"
1799 1846 version = "0.2.0"
@@ -1887,6 +1934,8 @@
1887 1934 "crossterm",
1888 1935 "hex",
1889 1936 "hmac",
1937 + "makeover",
1938 + "makeover-tui",
1890 1939 "rand 0.10.2",
1891 1940 "ratatui",
1892 1941 "reqwest",
@@ -3062,6 +3111,15 @@
3062 3111 "zmij",
3063 3112 ]
3064 3113
3114 + [[package]]
3115 + name = "serde_spanned"
3116 + version = "1.1.1"
3117 + source = "registry+https://github.com/rust-lang/crates.io-index"
3118 + checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
3119 + dependencies = [
3120 + "serde_core",
3121 + ]
3122 +
3065 3123 [[package]]
3066 3124 name = "serde_urlencoded"
3067 3125 version = "0.7.1"
@@ -3696,6 +3754,45 @@
3696 3754 "tokio",
3697 3755 ]
3698 3756
3757 + [[package]]
3758 + name = "toml"
3759 + version = "1.1.4+spec-1.1.0"
3760 + source = "registry+https://github.com/rust-lang/crates.io-index"
3761 + checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
3762 + dependencies = [
3763 + "indexmap",
3764 + "serde_core",
3765 + "serde_spanned",
3766 + "toml_datetime",
3767 + "toml_parser",
3768 + "toml_writer",
3769 + "winnow",
3770 + ]
3771 +
3772 + [[package]]
3773 + name = "toml_datetime"
3774 + version = "1.1.1+spec-1.1.0"
3775 + source = "registry+https://github.com/rust-lang/crates.io-index"
3776 + checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
3777 + dependencies = [
3778 + "serde_core",
3779 + ]
3780 +
3781 + [[package]]
3782 + name = "toml_parser"
3783 + version = "1.1.3+spec-1.1.0"
3784 + source = "registry+https://github.com/rust-lang/crates.io-index"
3785 + checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
3786 + dependencies = [
3787 + "winnow",
3788 + ]
3789 +
3790 + [[package]]
3791 + name = "toml_writer"
3792 + version = "1.1.2+spec-1.1.0"
3793 + source = "registry+https://github.com/rust-lang/crates.io-index"
3794 + checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
3795 +
3699 3796 [[package]]
3700 3797 name = "tower"
3701 3798 version = "0.5.3"
@@ -4414,6 +4511,12 @@
4414 4511 source = "registry+https://github.com/rust-lang/crates.io-index"
4415 4512 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
4416 4513
4514 + [[package]]
4515 + name = "winnow"
4516 + version = "1.0.4"
4517 + source = "registry+https://github.com/rust-lang/crates.io-index"
4518 + checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
4519 +
4417 4520 [[package]]
4418 4521 name = "wit-bindgen"
4419 4522 version = "0.51.0"
@@ -4653,6 +4756,34 @@
4653 4756 source = "registry+https://github.com/rust-lang/crates.io-index"
4654 4757 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
4655 4758
4759 + [[patch.unused]]
4760 + name = "quasi-axum"
4761 + version = "0.1.0"
4762 +
4763 + [[patch.unused]]
4764 + name = "quasi-http"
4765 + version = "0.1.0"
4766 +
4767 + [[patch.unused]]
4768 + name = "quasi-router"
4769 + version = "0.1.0"
4770 +
4771 + [[patch.unused]]
4772 + name = "quasi-store"
4773 + version = "0.1.0"
4774 +
4775 + [[patch.unused]]
4776 + name = "quasi-tauri"
4777 + version = "0.1.0"
4778 +
4779 + [[patch.unused]]
4780 + name = "quasi-webview"
4781 + version = "0.1.0"
4782 +
4783 + [[patch.unused]]
4784 + name = "synckit-config"
4785 + version = "0.2.0"
4786 +
4656 4787 [[patch.unused]]
4657 4788 name = "docengine"
4658 4789 version = "0.4.0"
@@ -4668,7 +4799,3 @@
4668 4799 [[patch.unused]]
4669 4800 name = "tagtree"
4670 4801 version = "0.4.0"
4671 -
4672 - [[patch.unused]]
4673 - name = "synckit-config"
4674 - version = "0.2.0"
@@ -11,6 +11,11 @@
11 11 russh-sftp = "2.1"
12 12 ratatui = "0.30"
13 13 crossterm = "0.29"
14 + # The family's design system. `makeover` holds the themes and resolves a
15 + # selection; `makeover-tui` is the terminal renderer, and its `theme` feature is
16 + # the intents-to-ratatui-colours mapping. No colour is named in this crate.
17 + makeover = "2.4.1"
18 + makeover-tui = { version = "0.12.0", features = ["theme"] }
14 19 tokio = { version = "1", features = ["full"] }
15 20 # `rustls-no-provider` rather than `rustls`: the latter is an alias for
16 21 # `__rustls-aws-lc-rs`, which links a C crypto backend. The provider is ring
@@ -53,6 +53,15 @@
53 53 /// window where a new CLI talks to a server that predates the field.
54 54 #[serde(default)]
55 55 pub settlement_currency: Currency,
56 + /// The theme this creator picked, as `makeover::ThemeSelection` encodes it:
57 + /// a theme id, or `"system"` to follow the terminal.
58 + ///
59 + /// `None` where the server did not send the field, which is every server
60 + /// today — it is not stored yet, and adding it is filed as its own task.
61 + /// Absent and unset are the same thing until then, and both follow the
62 + /// terminal, so this reads correctly on both sides of that change.
63 + #[serde(default)]
64 + pub theme_id: Option<String>,
56 65 }
57 66
58 67 /// A creator's project with item count and revenue.
@@ -32,6 +32,13 @@
32 32 /// Whether the client requested a PTY. The TUI needs one; without it a
33 33 /// shell session gets the help text instead of hanging on a dead terminal.
34 34 pty_requested: bool,
35 + /// What the client's terminal said about itself, for the theme.
36 + ///
37 + /// Collected here rather than read from this process's environment because
38 + /// the terminal being drawn on is at the other end of the connection; see
39 + /// `tui::theme`. `TERM` arrives on the PTY request, the rest on `env`
40 + /// requests if the client was configured to forward them.
41 + client_terminal: tui::theme::ClientTerminal,
35 42 /// TUI application handle for forwarding keypresses.
36 43 app: Option<tui::AppHandle>,
37 44 /// Channels stored between open and shell/subsystem request. Only SFTP
@@ -71,6 +78,7 @@
71 78 user: None,
72 79 term_size: (80, 24),
73 80 pty_requested: false,
81 + client_terminal: tui::theme::ClientTerminal::default(),
74 82 app: None,
75 83 channels: Arc::new(Mutex::new(HashMap::new())),
76 84 git_processes: HashMap::new(),
@@ -193,7 +201,7 @@
193 201 async fn pty_request(
194 202 &mut self,
195 203 _channel: ChannelId,
196 - _term: &str,
204 + term: &str,
197 205 col_width: u32,
198 206 row_height: u32,
199 207 _pix_width: u32,
@@ -203,7 +211,46 @@
203 211 ) -> Result<(), Self::Error> {
204 212 self.term_size = (col_width as u16, row_height as u16);
205 213 self.pty_requested = true;
206 - tracing::debug!(cols = col_width, rows = row_height, "PTY requested");
214 + // The one capability hint every client sends. `COLORTERM` would be the
215 + // better answer and openssh strips it, so on most sessions this is the
216 + // whole basis for the fidelity decision.
217 + self.client_terminal.term = term.to_string();
218 + tracing::debug!(cols = col_width, rows = row_height, term, "PTY requested");
219 + Ok(())
220 + }
221 +
222 + /// Record the environment variables that decide how the TUI is painted.
223 + ///
224 + /// Only the two that do, and only when the client sends them at all — most
225 + /// do not, since forwarding either takes a `SendEnv` line the creator wrote.
226 + /// Everything else is ignored rather than accumulated: this is not a shell,
227 + /// nothing here spawns a process, and an environment nobody reads is a place
228 + /// for a client to park unbounded data.
229 + ///
230 + /// Answered with `channel_success` either way. A client that asked politely
231 + /// and was refused has no way to tell whether the request was unsupported or
232 + /// the session is broken, and openssh's `-o SendEnv` warns on failure.
233 + async fn env_request(
234 + &mut self,
235 + channel: ChannelId,
236 + variable_name: &str,
237 + variable_value: &str,
238 + session: &mut Session,
239 + ) -> Result<(), Self::Error> {
240 + // Bounded before it is stored. Both values are short by their own
241 + // specifications (`truecolor`, `15;0`), and neither is worth a
242 + // per-session allocation a client chooses the size of.
243 + const MAX: usize = 64;
244 + match variable_name {
245 + "COLORTERM" if variable_value.len() <= MAX => {
246 + self.client_terminal.colorterm = variable_value.to_string();
247 + }
248 + "COLORFGBG" if variable_value.len() <= MAX => {
249 + self.client_terminal.colorfgbg = Some(variable_value.to_string());
250 + }
251 + _ => {}
252 + }
253 + session.channel_success(channel)?;
207 254 Ok(())
208 255 }
209 256
@@ -236,6 +283,28 @@
236 283 return Ok(());
237 284 }
238 285
286 + // Resolved before the terminal is put into its alternate screen, so a
287 + // theme that will not load reports itself on the creator's own prompt
288 + // rather than on a screen that is then torn down.
289 + let selection = tui::theme::selection(user.theme_id.as_deref());
290 + let theme = match tui::theme::load(&selection, &self.client_terminal) {
291 + Ok(theme) => theme,
292 + Err(e) => {
293 + // Every id this binary can resolve to is embedded in it and
294 + // covered by a test, so this is a packaging fault rather than
295 + // anything the creator chose wrongly. Say so instead of leaving
296 + // them looking for a setting to change.
297 + tracing::error!(user = %user.username, error = ?e, "theme failed to resolve");
298 + let handle = session.handle();
299 + let message = format!("mnw: cannot resolve a theme to render in ({e}).\r\n");
300 + let _ = handle.data(channel, bytes::Bytes::from(message)).await;
301 + let _ = handle.exit_status_request(channel, 1).await;
302 + let _ = handle.eof(channel).await;
303 + let _ = handle.close(channel).await;
304 + return Ok(());
305 + }
306 + };
307 +
239 308 tracing::info!(user = %user.username, "launching TUI");
240 309
241 310 let handle = session.handle();
@@ -248,6 +317,7 @@
248 317 match tui::launch(
249 318 terminal_handle,
250 319 user_clone,
320 + theme,
251 321 cols,
252 322 rows,
253 323 handle,
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Bar, BarChart, BarGroup, Block, Borders, Paragraph, Row};
8 8
@@ -35,7 +35,7 @@
35 35 let block = Block::default()
36 36 .title(title)
37 37 .borders(Borders::ALL)
38 - .border_style(Style::default().fg(Color::Gray));
38 + .border_style(Style::default().fg(app.theme.line_border));
39 39
40 40 let inner = block.inner(area);
41 41 frame.render_widget(block, area);
@@ -66,9 +66,9 @@
66 66 // Status line
67 67 if let Some(ref status) = app.analytics_status {
68 68 let style = if status.starts_with("Error") {
69 - Style::default().fg(Color::Red)
69 + Style::default().fg(app.theme.status_danger)
70 70 } else {
71 - Style::default().fg(Color::Green)
71 + Style::default().fg(app.theme.status_success)
72 72 };
73 73 let status_line = Paragraph::new(Line::from(vec![
74 74 Span::raw(" "),
@@ -92,7 +92,8 @@
92 92 Span::raw(" Back"),
93 93 ];
94 94
95 - let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
95 + let keys =
96 + Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
96 97 frame.render_widget(keys, chunks[5]);
97 98 }
98 99
@@ -135,7 +136,7 @@
135 136 for (i, (label, value, change)) in stats.iter().enumerate() {
136 137 let block = Block::default()
137 138 .borders(Borders::ALL)
138 - .border_style(Style::default().fg(Color::DarkGray));
139 + .border_style(Style::default().fg(app.theme.content_muted));
139 140 let inner = block.inner(stat_chunks[i]);
140 141 frame.render_widget(block, stat_chunks[i]);
141 142
@@ -144,11 +145,18 @@
144 145 format!(" {value}"),
145 146 Style::default().add_modifier(Modifier::BOLD),
146 147 ),
147 - Span::styled(format!(" {label}"), Style::default().fg(Color::DarkGray)),
148 + Span::styled(
149 + format!(" {label}"),
150 + Style::default().fg(app.theme.content_muted),
151 + ),
148 152 ];
149 153
150 154 if let Some(Some((text, positive))) = change {
151 - let color = if *positive { Color::Green } else { Color::Red };
155 + let color = if *positive {
156 + app.theme.status_success
157 + } else {
158 + app.theme.status_danger
159 + };
152 160 spans.push(Span::styled(format!(" {text}"), Style::default().fg(color)));
153 161 }
154 162
@@ -200,7 +208,7 @@
200 208 } else {
201 209 String::new()
202 210 })
203 - .style(Style::default().fg(Color::Cyan))
211 + .style(Style::default().fg(app.theme.action_primary))
204 212 })
205 213 .collect();
206 214
@@ -251,7 +259,14 @@
251 259
252 260 // See the home-screen revenue column on why 15 rather than 12.
253 261 let widths = [Constraint::Min(20), Constraint::Length(15)];
254 - widgets::render_table(frame, chunks[4], &[" Project", "Revenue"], &widths, rows);
262 + widgets::render_table(
263 + frame,
264 + &app.theme,
265 + chunks[4],
266 + &[" Project", "Revenue"],
267 + &widths,
268 + rows,
269 + );
255 270 }
256 271 }
257 272 }
@@ -297,7 +312,11 @@
297 312 tx.status.clone(),
298 313 date.to_string(),
299 314 ])
300 - .style(widgets::selected_style(i, Some(app.selected_index)))
315 + .style(widgets::selected_style(
316 + &app.theme,
317 + i,
318 + Some(app.selected_index),
319 + ))
301 320 })
302 321 .collect();
303 322
@@ -310,6 +329,7 @@
310 329
311 330 widgets::render_table(
312 331 frame,
332 + &app.theme,
313 333 chunks[1],
314 334 &[" Item", "Amount", "Status", "Date"],
315 335 &widths,
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
@@ -27,7 +27,7 @@
27 27 let block = Block::default()
28 28 .title(title)
29 29 .borders(Borders::ALL)
30 - .border_style(Style::default().fg(Color::Gray));
30 + .border_style(Style::default().fg(app.theme.line_border));
31 31
32 32 let inner = block.inner(area);
33 33 frame.render_widget(block, area);
@@ -68,9 +68,9 @@
68 68 // Status line
69 69 if let Some(ref status) = app.blog_status {
70 70 let style = if status.starts_with("Error") {
71 - Style::default().fg(Color::Red)
71 + Style::default().fg(app.theme.status_danger)
72 72 } else {
73 - Style::default().fg(Color::Green)
73 + Style::default().fg(app.theme.status_success)
74 74 };
75 75 let status_line = Paragraph::new(Line::from(vec![
76 76 Span::raw(" "),
@@ -102,7 +102,8 @@
102 102 Span::raw(" Back"),
103 103 ]);
104 104
105 - let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
105 + let keys =
106 + Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
106 107 frame.render_widget(keys, chunks[4]);
107 108 }
108 109
@@ -128,7 +129,11 @@
128 129 status,
129 130 date.to_string(),
130 131 ])
131 - .style(widgets::selected_style(i, Some(app.selected_index)))
132 + .style(widgets::selected_style(
133 + &app.theme,
134 + i,
135 + Some(app.selected_index),
136 + ))
132 137 })
133 138 .collect();
134 139
@@ -141,6 +146,7 @@
141 146
142 147 widgets::render_table(
143 148 frame,
149 + &app.theme,
144 150 area,
145 151 &[" Title", "Slug", "Status", "Created"],
146 152 &widths,
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
@@ -25,7 +25,7 @@
25 25 let block = Block::default()
26 26 .title(title)
27 27 .borders(Borders::ALL)
28 - .border_style(Style::default().fg(Color::Gray));
28 + .border_style(Style::default().fg(app.theme.line_border));
29 29
30 30 let inner = block.inner(area);
31 31 frame.render_widget(block, area);
@@ -71,7 +71,11 @@
71 71 status.to_string(),
72 72 c.item_count.to_string(),
73 73 ])
74 - .style(widgets::selected_style(i, Some(app.selected_index)))
74 + .style(widgets::selected_style(
75 + &app.theme,
76 + i,
77 + Some(app.selected_index),
78 + ))
75 79 })
76 80 .collect();
77 81
@@ -84,6 +88,7 @@
84 88
85 89 widgets::render_table(
86 90 frame,
91 + &app.theme,
87 92 chunks[2],
88 93 &[" Title", "Slug", "Status", "Items"],
89 94 &widths,
@@ -93,9 +98,9 @@
93 98
94 99 if let Some(ref status) = app.collections_status {
95 100 let style = if status.starts_with("Error") {
96 - Style::default().fg(Color::Red)
101 + Style::default().fg(app.theme.status_danger)
97 102 } else {
98 - Style::default().fg(Color::Green)
103 + Style::default().fg(app.theme.status_success)
99 104 };
100 105 let status_line = Paragraph::new(format!(" {status}")).style(style);
101 106 frame.render_widget(status_line, chunks[3]);
@@ -111,6 +116,7 @@
111 116 Span::raw(" Back"),
112 117 ];
113 118
114 - let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
119 + let keys =
120 + Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
115 121 frame.render_widget(keys, chunks[4]);
116 122 }
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
@@ -38,7 +38,7 @@
38 38 let block = Block::default()
39 39 .title(title)
40 40 .borders(Borders::ALL)
41 - .border_style(Style::default().fg(Color::Gray));
41 + .border_style(Style::default().fg(app.theme.line_border));
42 42
43 43 let inner = block.inner(area);
44 44 frame.render_widget(block, area);
@@ -99,7 +99,7 @@
99 99 Span::styled("[q]", Style::default().add_modifier(Modifier::BOLD)),
100 100 Span::raw(" Quit"),
101 101 ]))
102 - .style(Style::default().fg(Color::DarkGray));
102 + .style(Style::default().fg(app.theme.content_muted));
103 103 frame.render_widget(keys, chunks[5]);
104 104 }
105 105
@@ -133,7 +133,7 @@
133 133 for (i, (label, value)) in stat_items.iter().enumerate() {
134 134 let block = Block::default()
135 135 .borders(Borders::ALL)
136 - .border_style(Style::default().fg(Color::DarkGray));
136 + .border_style(Style::default().fg(app.theme.content_muted));
137 137 let inner = block.inner(stats_chunks[i]);
138 138 frame.render_widget(block, stats_chunks[i]);
139 139
@@ -142,7 +142,10 @@
142 142 format!(" {value}"),
143 143 Style::default().add_modifier(Modifier::BOLD),
144 144 ),
145 - Span::styled(format!(" {label}"), Style::default().fg(Color::DarkGray)),
145 + Span::styled(
146 + format!(" {label}"),
147 + Style::default().fg(app.theme.content_muted),
148 + ),
146 149 ]));
147 150 frame.render_widget(text, inner);
148 151 }
@@ -163,7 +166,11 @@
163 166 p.item_count.to_string(),
164 167 p.revenue().display_compact(app.currency()),
165 168 ])
166 - .style(widgets::selected_style(i, Some(app.selected_index)))
169 + .style(widgets::selected_style(
170 + &app.theme,
171 + i,
172 + Some(app.selected_index),
173 + ))
167 174 })
168 175 .collect();
169 176
@@ -179,6 +186,7 @@
179 186
180 187 widgets::render_table(
181 188 frame,
189 + &app.theme,
182 190 area,
183 191 &[" Title", "Type", "Status", "Items", "Revenue"],
184 192 &widths,
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
@@ -41,7 +41,7 @@
41 41 let block = Block::default()
42 42 .title(title)
43 43 .borders(Borders::ALL)
44 - .border_style(Style::default().fg(Color::Gray));
44 + .border_style(Style::default().fg(app.theme.line_border));
45 45
46 46 let inner = block.inner(area);
47 47 frame.render_widget(block, area);
@@ -87,9 +87,9 @@
87 87 // Status line
88 88 if let Some(ref status) = app.item_status {
89 89 let style = if status.starts_with("Error") {
90 - Style::default().fg(Color::Red)
90 + Style::default().fg(app.theme.status_danger)
91 91 } else {
92 - Style::default().fg(Color::Green)
92 + Style::default().fg(app.theme.status_success)
93 93 };
94 94 let status_line = Paragraph::new(Line::from(vec![
95 95 Span::raw(" "),
@@ -138,7 +138,8 @@
138 138 ]);
139 139 }
140 140
141 - let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
141 + let keys =
142 + Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
142 143 frame.render_widget(keys, chunks[6]);
143 144 }
144 145
@@ -160,7 +161,7 @@
160 161 let lines = vec![
161 162 Line::from(vec![
162 163 Span::raw(edit_marker(ItemEditField::Title)),
163 - Span::styled("Title: ", Style::default().fg(Color::DarkGray)),
164 + Span::styled("Title: ", Style::default().fg(app.theme.content_muted)),
164 165 if editing == Some(ItemEditField::Title) {
165 166 Span::styled(
166 167 format!("{}_", app.edit_buffer),
@@ -172,7 +173,10 @@
172 173 ]),
173 174 Line::from(vec![
174 175 Span::raw(edit_marker(ItemEditField::Description)),
175 - Span::styled("Description: ", Style::default().fg(Color::DarkGray)),
176 + Span::styled(
177 + "Description: ",
178 + Style::default().fg(app.theme.content_muted),
179 + ),
176 180 if editing == Some(ItemEditField::Description) {
177 181 Span::styled(
178 182 format!("{}_", app.edit_buffer),
@@ -184,7 +188,7 @@
184 188 ]),
185 189 Line::from(vec![
186 190 Span::raw(edit_marker(ItemEditField::Price)),
187 - Span::styled("Price: ", Style::default().fg(Color::DarkGray)),
191 + Span::styled("Price: ", Style::default().fg(app.theme.content_muted)),
188 192 if editing == Some(ItemEditField::Price) {
189 193 Span::styled(
190 194 // The symbol on the edit prompt is the creator's own: they are
@@ -198,18 +202,18 @@
198 202 ]),
199 203 Line::from(vec![
200 204 Span::raw(" "),
201 - Span::styled("Type: ", Style::default().fg(Color::DarkGray)),
205 + Span::styled("Type: ", Style::default().fg(app.theme.content_muted)),
202 206 Span::raw(format::format_item_type(&item.item_type)),
203 207 Span::raw(" "),
204 - Span::styled("Slug: ", Style::default().fg(Color::DarkGray)),
208 + Span::styled("Slug: ", Style::default().fg(app.theme.content_muted)),
205 209 Span::raw(item.slug.clone()),
206 210 ]),
207 211 Line::from(vec![
208 212 Span::raw(" "),
209 - Span::styled("Sales: ", Style::default().fg(Color::DarkGray)),
213 + Span::styled("Sales: ", Style::default().fg(app.theme.content_muted)),
210 214 Span::raw(item.sales_count.to_string()),
211 215 Span::raw(" "),
212 - Span::styled("Downloads: ", Style::default().fg(Color::DarkGray)),
216 + Span::styled("Downloads: ", Style::default().fg(app.theme.content_muted)),
213 217 Span::raw(item.download_count.to_string()),
214 218 if item.play_count > 0 {
215 219 Span::raw(format!(" Plays: {}", item.play_count))
@@ -219,19 +223,22 @@
219 223 ]),
220 224 Line::from(vec![
221 225 Span::raw(" "),
222 - Span::styled("Audio: ", Style::default().fg(Color::DarkGray)),
226 + Span::styled("Audio: ", Style::default().fg(app.theme.content_muted)),
223 227 Span::raw(if item.has_audio { "yes" } else { "no" }),
224 228 Span::raw(" "),
225 - Span::styled("Cover: ", Style::default().fg(Color::DarkGray)),
229 + Span::styled("Cover: ", Style::default().fg(app.theme.content_muted)),
226 230 Span::raw(if item.has_cover { "yes" } else { "no" }),
227 231 ]),
228 232 Line::from({
229 233 let mut spans = vec![
230 234 Span::raw(" "),
231 - Span::styled("Tags: ", Style::default().fg(Color::DarkGray)),
235 + Span::styled("Tags: ", Style::default().fg(app.theme.content_muted)),
232 236 ];
233 237 if app.item_tags.is_empty() {
234 - spans.push(Span::styled("(none)", Style::default().fg(Color::DarkGray)));
238 + spans.push(Span::styled(
239 + "(none)",
240 + Style::default().fg(app.theme.content_muted),
241 + ));
235 242 } else {
236 243 for (i, tag) in app.item_tags.iter().enumerate() {
237 244 if i > 0 {
@@ -287,6 +294,7 @@
287 294
288 295 widgets::render_table(
289 296 frame,
297 + &app.theme,
290 298 area,
291 299 &[" Version", "File", "Size", "Downloads", "Date"],
292 300 &widths,
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
@@ -27,7 +27,7 @@
27 27 let block = Block::default()
28 28 .title(title)
29 29 .borders(Borders::ALL)
30 - .border_style(Style::default().fg(Color::Gray));
30 + .border_style(Style::default().fg(app.theme.line_border));
31 31
32 32 let inner = block.inner(area);
33 33 frame.render_widget(block, area);
@@ -68,9 +68,9 @@
68 68 // Status line
69 69 if let Some(ref status) = app.keys_status {
70 70 let style = if status.starts_with("Error") {
71 - Style::default().fg(Color::Red)
71 + Style::default().fg(app.theme.status_danger)
72 72 } else {
73 - Style::default().fg(Color::Green)
73 + Style::default().fg(app.theme.status_success)
74 74 };
75 75 let status_line = Paragraph::new(Line::from(vec![
76 76 Span::raw(" "),
@@ -102,7 +102,8 @@
102 102 Span::raw(" Back"),
103 103 ]);
104 104
105 - let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
105 + let keys =
106 + Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
106 107 frame.render_widget(keys, chunks[4]);
107 108 }
108 109
@@ -125,7 +126,11 @@
125 126 activations,
126 127 date.to_string(),
127 128 ])
128 - .style(widgets::selected_style(i, Some(app.selected_index)))
129 + .style(widgets::selected_style(
130 + &app.theme,
131 + i,
132 + Some(app.selected_index),
133 + ))
129 134 })
130 135 .collect();
131 136
@@ -138,6 +143,7 @@
138 143
139 144 widgets::render_table(
140 145 frame,
146 + &app.theme,
141 147 area,
142 148 &[" Key", "Status", "Activations", "Created"],
143 149 &widths,
@@ -11,6 +11,7 @@
11 11 pub(crate) mod project;
12 12 pub(crate) mod promo;
13 13 pub(crate) mod settings;
14 + pub(crate) mod theme;
14 15 pub(crate) mod tiers;
15 16 pub(crate) mod upload;
16 17 pub(crate) mod widgets;
@@ -224,6 +225,13 @@
224 225 /// Application state shared across screens.
225 226 pub(crate) struct App {
226 227 pub user: UserInfo,
228 + /// The colours every screen draws in, resolved once per session from the
229 + /// creator's selection and their terminal's capabilities.
230 + ///
231 + /// Held for the life of the session rather than re-resolved per frame, and
232 + /// `Copy`, so a screen reads it without a clone. Re-theming mid-session
233 + /// would mean rebuilding this and is not something any surface offers yet.
234 + pub theme: makeover_tui::Theme,
227 235 pub projects: Vec<Project>,
228 236 pub stats: Option<CreatorStats>,
229 237 pub items: Vec<Item>,
@@ -294,9 +302,10 @@
294 302 self.user.settlement_currency
295 303 }
296 304
297 - fn new(user: UserInfo) -> Self {
305 + fn new(user: UserInfo, theme: makeover_tui::Theme) -> Self {
298 306 Self {
299 307 user,
308 + theme,
300 309 projects: Vec::new(),
301 310 stats: None,
302 311 items: Vec::new(),
@@ -469,6 +478,108 @@
469 478 mod tests {
470 479 use super::*;
471 480
481 + /// An app with no data loaded, on a known theme.
482 + fn app_on_a_fixed_theme() -> App {
483 + App::new(
484 + UserInfo {
485 + user_id: "u1".to_string(),
486 + username: "creator".to_string(),
487 + display_name: None,
488 + creator_tier: Some("small_files".to_string()),
489 + can_create_projects: true,
490 + suspended: false,
491 + actor_token: String::new(),
492 + settlement_currency: Currency::default(),
493 + theme_id: None,
494 + },
495 + theme::tests::fixed(),
496 + )
497 + }
498 +
499 + /// Every colour a rendered frame can put on screen, from one theme.
500 + ///
501 + /// `Color::Reset` is in the set because that is what an unstyled cell holds,
502 + /// and `paint_page` has already given the whole viewport the theme's own
503 + /// page colours underneath it.
504 + fn colors_of(theme: &makeover_tui::Theme) -> Vec<ratatui::style::Color> {
505 + let mut colors = vec![
506 + ratatui::style::Color::Reset,
507 + theme.surface_page,
508 + theme.surface_raised,
509 + theme.surface_sunken,
510 + theme.surface_overlay,
511 + theme.content_primary,
512 + theme.content_secondary,
513 + theme.content_muted,
514 + theme.action_primary,
515 + theme.status_danger,
516 + theme.status_success,
517 + theme.status_warning,
518 + theme.status_info,
519 + theme.line_border,
520 + theme.border_strong,
521 + theme.bevel_light,
522 + theme.bevel_dark,
523 + ];
524 + colors.extend(theme.surface_well);
525 + colors.extend(theme.category);
526 + colors
527 + }
528 +
529 + /// No screen may paint a colour that is not in the theme.
530 + ///
531 + /// This is the guard the port exists to make possible, and the terminal's
532 + /// equivalent of the frontend lint the web surfaces carry: a `Color::Red`
533 + /// added back to a screen in six months fails here rather than being noticed
534 + /// by a creator whose theme has no red in it. Asserted over the rendered
535 + /// buffer rather than by grepping the source, so it also catches a literal
536 + /// arriving through a widget's own default.
537 + #[test]
538 + fn every_rendered_colour_comes_from_the_theme() {
539 + let app = app_on_a_fixed_theme();
540 + let permitted = colors_of(&app.theme);
541 +
542 + let backend = ratatui::backend::TestBackend::new(120, 40);
543 + let mut terminal = Terminal::new(backend).expect("test backend");
544 + terminal
545 + .draw(|frame| {
546 + run::paint_page(frame, &app);
547 + home::render(frame, &app);
548 + })
549 + .expect("home renders");
550 +
551 + for cell in terminal.backend().buffer().content() {
552 + assert!(
553 + permitted.contains(&cell.fg),
554 + "foreground {:?} is in no theme intent",
555 + cell.fg,
556 + );
557 + assert!(
558 + permitted.contains(&cell.bg),
559 + "background {:?} is in no theme intent",
560 + cell.bg,
561 + );
562 + }
563 + }
564 +
565 + // The page is painted, not left to the terminal's default. A transparent
566 + // background is what the port set out to remove, and it looks identical to
567 + // a correct one on a terminal whose own colours happen to match.
568 + #[test]
569 + fn the_page_surface_is_painted_under_every_screen() {
570 + let app = app_on_a_fixed_theme();
571 +
572 + let backend = ratatui::backend::TestBackend::new(40, 10);
573 + let mut terminal = Terminal::new(backend).expect("test backend");
574 + terminal
575 + .draw(|frame| run::paint_page(frame, &app))
576 + .expect("the page paints");
577 +
578 + for cell in terminal.backend().buffer().content() {
579 + assert_eq!(cell.bg, app.theme.surface_page);
580 + }
581 + }
582 +
472 583 #[test]
473 584 fn parse_price_whole_dollars() {
474 585 assert_eq!(parse_price("5"), 500);
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
@@ -31,7 +31,7 @@
31 31 let block = Block::default()
32 32 .title(title)
33 33 .borders(Borders::ALL)
34 - .border_style(Style::default().fg(Color::Gray));
34 + .border_style(Style::default().fg(app.theme.line_border));
35 35
36 36 let inner = block.inner(area);
37 37 frame.render_widget(block, area);
@@ -54,7 +54,7 @@
54 54 let visibility = if project.is_public { "public" } else { "draft" };
55 55 let info = Paragraph::new(Line::from(vec![
56 56 Span::raw(" "),
57 - Span::styled(&project.slug, Style::default().fg(Color::DarkGray)),
57 + Span::styled(&project.slug, Style::default().fg(app.theme.content_muted)),
58 58 Span::raw(" "),
59 59 Span::raw(format::format_project_type(&project.project_type)),
60 60 Span::raw(" "),
@@ -63,7 +63,7 @@
63 63 // The detail line has the room for the full breakdown, so a project
64 64 // spanning two currencies shows both here rather than a `+1` marker.
65 65 Span::raw(project.revenue().display(app.currency())),
66 - Span::styled(" revenue", Style::default().fg(Color::DarkGray)),
66 + Span::styled(" revenue", Style::default().fg(app.theme.content_muted)),
67 67 ]));
68 68 frame.render_widget(info, chunks[1]);
69 69
@@ -104,13 +104,13 @@
104 104 }
105 105 _ => String::new(),
106 106 };
107 - let confirm = Paragraph::new(msg).style(Style::default().fg(Color::Yellow));
107 + let confirm = Paragraph::new(msg).style(Style::default().fg(app.theme.status_warning));
108 108 frame.render_widget(confirm, chunks[5]);
109 109 } else if let Some(ref status) = app.item_status {
110 110 let style = if status.starts_with("Error") {
111 - Style::default().fg(Color::Red)
111 + Style::default().fg(app.theme.status_danger)
112 112 } else {
113 - Style::default().fg(Color::Green)
113 + Style::default().fg(app.theme.status_success)
114 114 };
115 115 let status_line = Paragraph::new(format!(" {status}")).style(style);
116 116 frame.render_widget(status_line, chunks[5]);
@@ -142,7 +142,7 @@
142 142 Span::styled(
143 143 format!("{sel_count} selected"),
144 144 Style::default()
145 - .fg(Color::Cyan)
145 + .fg(app.theme.action_primary)
146 146 .add_modifier(Modifier::BOLD),
147 147 ),
148 148 Span::raw(" "),
@@ -160,7 +160,8 @@
160 160 Span::raw(" Back"),
161 161 ]);
162 162
163 - let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
163 + let keys =
164 + Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
164 165 frame.render_widget(keys, chunks[6]);
165 166 }
166 167
@@ -187,7 +188,11 @@
187 188 format::format_price(item.price_cents, app.currency()),
188 189 visibility.to_string(),
189 190 ])
190 - .style(widgets::selected_style(i, Some(app.selected_index)))
191 + .style(widgets::selected_style(
192 + &app.theme,
193 + i,
194 + Some(app.selected_index),
195 + ))
191 196 })
192 197 .collect();
193 198
@@ -200,6 +205,7 @@
200 205
201 206 widgets::render_table(
202 207 frame,
208 + &app.theme,
203 209 area,
204 210 &[" Title", "Type", "Price", "Status"],
205 211 &widths,
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
@@ -27,7 +27,7 @@
27 27 let block = Block::default()
28 28 .title(title)
29 29 .borders(Borders::ALL)
30 - .border_style(Style::default().fg(Color::Gray));
30 + .border_style(Style::default().fg(app.theme.line_border));
31 31
32 32 let inner = block.inner(area);
33 33 frame.render_widget(block, area);
@@ -68,9 +68,9 @@
68 68 // Status line
69 69 if let Some(ref status) = app.promo_status {
70 70 let style = if status.starts_with("Error") {
71 - Style::default().fg(Color::Red)
71 + Style::default().fg(app.theme.status_danger)
72 72 } else {
73 - Style::default().fg(Color::Green)
73 + Style::default().fg(app.theme.status_success)
74 74 };
75 75 let status_line = Paragraph::new(Line::from(vec![
76 76 Span::raw(" "),
@@ -111,7 +111,8 @@
111 111 ]);
112 112 }
113 113
114 - let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
114 + let keys =
115 + Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
115 116 frame.render_widget(keys, chunks[4]);
116 117 }
117 118
@@ -142,7 +143,11 @@
142 143 scope.to_string(),
143 144 uses,
144 145 ])
145 - .style(widgets::selected_style(i, Some(app.selected_index)))
146 + .style(widgets::selected_style(
147 + &app.theme,
148 + i,
149 + Some(app.selected_index),
150 + ))
146 151 })
147 152 .collect();
148 153
@@ -155,6 +160,7 @@
155 160
156 161 widgets::render_table(
157 162 frame,
163 + &app.theme,
158 164 area,
159 165 &[" Code", "Discount", "Scope", "Uses"],
160 166 &widths,
@@ -11,11 +11,34 @@
11 11 project, promo, settings, tiers, upload,
12 12 };
13 13
14 + /// Fill the whole viewport with the theme's page surface and body text colour.
15 + ///
16 + /// Painted under every screen rather than left to the terminal's own default.
17 + /// Left transparent, a light theme lands its parchment borders and muted labels
18 + /// on whatever background the creator's terminal happens to have, which on a
19 + /// dark one is the pairing the theme was authored to avoid. It also gives every
20 + /// unstyled `Span` in the screens below a colour that came from the theme, so a
21 + /// span with no `Style` is correct rather than merely inherited.
22 + pub(super) fn paint_page(frame: &mut ratatui::Frame, app: &App) {
23 + use ratatui::style::Style;
24 + use ratatui::widgets::Block;
25 +
26 + frame.render_widget(
27 + Block::default().style(
28 + Style::default()
29 + .bg(app.theme.surface_page)
30 + .fg(app.theme.content_primary),
31 + ),
32 + frame.area(),
33 + );
34 + }
35 +
14 36 /// Launch the TUI event loop in a background task.
15 37 #[allow(clippy::too_many_arguments)]
16 38 pub(crate) fn launch(
17 39 writer: TerminalHandle,
18 40 user: UserInfo,
41 + theme: makeover_tui::Theme,
19 42 cols: u16,
20 43 rows: u16,
21 44 session_handle: russh::server::Handle,
@@ -46,7 +69,7 @@
46 69 });
47 70
48 71 tokio::spawn(async move {
49 - let mut app = App::new(user);
72 + let mut app = App::new(user, theme);
50 73 let mut screen = Screen::Home;
51 74 let staging_dir = staging_dir;
52 75
@@ -59,7 +82,10 @@
59 82 }
60 83
61 84 // Initial render (loading state)
62 - if let Err(e) = terminal.draw(|frame| home::render(frame, &app)) {
85 + if let Err(e) = terminal.draw(|frame| {
86 + paint_page(frame, &app);
87 + home::render(frame, &app);
88 + }) {
63 89 tracing::error!(error = ?e, "TUI: initial render failed");
64 90 cleanup(&mut terminal);
65 91 return;
@@ -355,24 +381,27 @@
355 381 }
356 382
357 383 // Re-render after every event
358 - if let Err(e) = terminal.draw(|frame| match &screen {
359 - Screen::Home => home::render(frame, &app),
360 - Screen::Project(idx) => {
361 - if let Some(p) = app.projects.get(*idx) {
362 - project::render(frame, &app, p);
363 - } else {
364 - home::render(frame, &app);
384 + if let Err(e) = terminal.draw(|frame| {
385 + paint_page(frame, &app);
386 + match &screen {
387 + Screen::Home => home::render(frame, &app),
388 + Screen::Project(idx) => {
389 + if let Some(p) = app.projects.get(*idx) {
390 + project::render(frame, &app, p);
391 + } else {
392 + home::render(frame, &app);
393 + }
365 394 }
395 + Screen::Upload => upload::render(frame, &app),
396 + Screen::Item(..) => item::render(frame, &app),
397 + Screen::Blog(..) => blog::render(frame, &app),
398 + Screen::Promo => promo::render(frame, &app),
399 + Screen::Keys(..) => keys::render(frame, &app),
400 + Screen::Analytics => analytics::render(frame, &app),
401 + Screen::Settings => settings::render(frame, &app),
402 + Screen::Collections => collections::render(frame, &app),
403 + Screen::Tiers(..) => tiers::render(frame, &app),
366 404 }
367 - Screen::Upload => upload::render(frame, &app),
368 - Screen::Item(..) => item::render(frame, &app),
369 - Screen::Blog(..) => blog::render(frame, &app),
370 - Screen::Promo => promo::render(frame, &app),
371 - Screen::Keys(..) => keys::render(frame, &app),
372 - Screen::Analytics => analytics::render(frame, &app),
373 - Screen::Settings => settings::render(frame, &app),
374 - Screen::Collections => collections::render(frame, &app),
375 - Screen::Tiers(..) => tiers::render(frame, &app),
376 405 }) {
377 406 tracing::error!(error = ?e, "render failed");
378 407 cleanup(&mut terminal);
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Row};
8 8
@@ -28,7 +28,7 @@
28 28 let block = Block::default()
29 29 .title(title)
30 30 .borders(Borders::ALL)
31 - .border_style(Style::default().fg(Color::Gray));
31 + .border_style(Style::default().fg(app.theme.line_border));
32 32
33 33 let inner = block.inner(area);
34 34 frame.render_widget(block, area);
@@ -75,7 +75,7 @@
75 75 Line::from(vec![Span::raw(" Display: "), Span::raw(display_name)]),
76 76 Line::from(vec![
77 77 Span::raw(" Tier: "),
78 - Span::styled(tier, Style::default().fg(Color::Cyan)),
78 + Span::styled(tier, Style::default().fg(app.theme.action_primary)),
79 79 ]),
80 80 ];
81 81 let profile = Paragraph::new(profile_lines);
@@ -103,11 +103,11 @@
103 103 );
104 104
105 105 let color = if pct > 90 {
106 - Color::Red
106 + app.theme.status_danger
107 107 } else if pct > 70 {
108 - Color::Yellow
108 + app.theme.status_warning
109 109 } else {
110 - Color::Green
110 + app.theme.status_success
111 111 };
112 112
113 113 let gauge = Gauge::default()
@@ -154,7 +154,11 @@
154 154 format!("{}...", fp),
155 155 date.to_string(),
156 156 ])
157 - .style(widgets::selected_style(i, Some(app.selected_index)))
157 + .style(widgets::selected_style(
158 + &app.theme,
159 + i,
160 + Some(app.selected_index),
161 + ))
158 162 })
159 163 .collect();
160 164
@@ -166,6 +170,7 @@
166 170
167 171 widgets::render_table(
168 172 frame,
173 + &app.theme,
169 174 chunks[8],
170 175 &[" Label", "Fingerprint", "Added"],
171 176 &widths,
@@ -176,9 +181,9 @@
176 181 // Status line
177 182 if let Some(ref status) = app.settings_status {
178 183 let style = if status.starts_with("Error") {
179 - Style::default().fg(Color::Red)
184 + Style::default().fg(app.theme.status_danger)
180 185 } else {
181 - Style::default().fg(Color::Green)
186 + Style::default().fg(app.theme.status_success)
182 187 };
183 188 let status_line = Paragraph::new(Line::from(vec![
184 189 Span::raw(" "),
@@ -199,6 +204,6 @@
199 204 ];
200 205
201 206 let keys_bar =
202 - Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
207 + Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
203 208 frame.render_widget(keys_bar, chunks[10]);
204 209 }
@@ -2,7 +2,7 @@
2 2
3 3 use ratatui::Frame;
4 4 use ratatui::layout::{Constraint, Layout};
5 - use ratatui::style::{Color, Modifier, Style};
5 + use ratatui::style::{Modifier, Style};
6 6 use ratatui::text::{Line, Span};
7 7 use ratatui::widgets::{Block, Borders, Paragraph, Row};
8 8
@@ -34,7 +34,7 @@
34 34 let block = Block::default()
35 35 .title(title)
36 36 .borders(Borders::ALL)
37 - .border_style(Style::default().fg(Color::Gray));
37 + .border_style(Style::default().fg(app.theme.line_border));
38 38
39 39 let inner = block.inner(area);
40 40 frame.render_widget(block, area);
@@ -84,7 +84,11 @@
84 84 status.to_string(),
85 85 desc,
86 86 ])
87 - .style(widgets::selected_style(i, Some(app.selected_index)))
87 + .style(widgets::selected_style(
88 + &app.theme,
89 + i,
90 + Some(app.selected_index),
91 + ))
88 92 })
89 93 .collect();
90 94
@@ -97,6 +101,7 @@
97 101
98 102 widgets::render_table(
99 103 frame,
104 + &app.theme,
100 105 chunks[2],
101 106 &[" Name", "Price", "Status", "Description"],
102 107 &widths,
@@ -106,9 +111,9 @@
106 111
107 112 if let Some(ref status) = app.tiers_status {
108 113 let style = if status.starts_with("Error") {
109 - Style::default().fg(Color::Red)
114 + Style::default().fg(app.theme.status_danger)
110 115 } else {
111 - Style::default().fg(Color::Green)
116 + Style::default().fg(app.theme.status_success)
112 117 };
113 118 let status_line = Paragraph::new(format!(" {status}")).style(style);
114 119 frame.render_widget(status_line, chunks[3]);
@@ -122,6 +127,7 @@
122 127 Span::raw(" Back"),
123 128 ];
124 129
125 - let keys = Paragraph::new(Line::from(key_spans)).style(Style::default().fg(Color::DarkGray));
130 + let keys =
131 + Paragraph::new(Line::from(key_spans)).style(Style::default().fg(app.theme.content_muted));
126 132 frame.render_widget(keys, chunks[4]);
127 133 }