Skip to main content

max / makenotwork

Carry the creator's console theme to the CLI on ssh-key-lookup A new users.console_theme column, distinct from theme_id: theme_id is the palette visitors see on a public profile and cannot encode "system", which a terminal following its ambient mode needs. The selection is stored explicitly rather than cleared to NULL, so NULL keeps meaning "never chose". The picker sits on the SSH Keys settings tab, next to the keys that reach the console it themes, and the internal git lookup carries the choice out to the CLI.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 17:12 UTC
Signed with PGP, not checked
Commit: 9dfe85ed0028532e32c9a4bf0725cbda9ae0217a
Parent: 2d79ea2
14 files changed, +273 insertions, -5 deletions
@@ -17,7 +17,9 @@
17 17 use std::collections::BTreeMap;
18 18 use std::sync::LazyLock;
19 19
20 - use makeover::{ThemeMeta, embedded_themes, intent_css_vars, parse_theme_str, resolve};
20 + use makeover::{
21 + ThemeMeta, ThemeSelection, embedded_themes, intent_css_vars, parse_theme_str, resolve,
22 + };
21 23
22 24 /// The platform default theme id, the stock parchment look. Reproduces the
23 25 /// historical `:root` exactly, so an unset (`None`) choice renders unchanged.
@@ -100,6 +102,25 @@
100 102 }
101 103 }
102 104
105 + /// Validate and normalize a submitted console-theme selection.
106 + ///
107 + /// Unlike [`normalize_theme_id`] this yields a `makeover::ThemeSelection`
108 + /// string rather than an optional id, because "follow the terminal" is a choice
109 + /// the row holds and not an absence: a creator who picks it after pinning a
110 + /// theme is saying something, and clearing the column back to `NULL` would lose
111 + /// the difference between that and never having chosen.
112 + ///
113 + /// Absent or empty input is [`makeover::FOLLOW`], matching
114 + /// `ThemeSelection::parse`. Any other value must name a bundled theme, since an
115 + /// id the CLI's embedded set does not carry cannot be honoured there either.
116 + pub fn normalize_console_theme(raw: Option<&str>) -> Result<String, String> {
117 + match ThemeSelection::parse(raw) {
118 + ThemeSelection::Follow => Ok(makeover::FOLLOW.to_string()),
119 + ThemeSelection::Fixed(id) if is_valid_theme(&id) => Ok(id),
120 + ThemeSelection::Fixed(id) => Err(id),
121 + }
122 + }
123 +
103 124 /// One `<option>` for a theme `<select>`: id, display name, and whether it is
104 125 /// the creator's current choice.
105 126 #[derive(Debug, Clone)]
@@ -127,6 +148,31 @@
127 148 .collect()
128 149 }
129 150
151 + /// Build the console picker options: "follow the terminal" first, then every
152 + /// bundled theme.
153 + ///
154 + /// Follow leads because it is what an unconfigured console does and what the
155 + /// house convention makes the default. `stored` is the raw column value, so an
156 + /// unset row and a stored `"system"` both land on it, as does a pinned theme
157 + /// that has since left the bundled set.
158 + pub fn console_theme_options(stored: Option<&str>) -> Vec<ThemeOption> {
159 + let current = match ThemeSelection::parse(stored) {
160 + ThemeSelection::Fixed(id) if is_valid_theme(&id) => id,
161 + _ => makeover::FOLLOW.to_string(),
162 + };
163 + let mut options = vec![ThemeOption {
164 + id: makeover::FOLLOW.to_string(),
165 + name: "Follow the terminal".to_string(),
166 + selected: current == makeover::FOLLOW,
167 + }];
168 + options.extend(list_themes().into_iter().map(|m| ThemeOption {
169 + id: m.id.clone(),
170 + name: m.name.clone(),
171 + selected: m.id == current,
172 + }));
173 + options
174 + }
175 +
130 176 #[cfg(test)]
131 177 mod tests {
132 178 use super::*;
@@ -170,6 +216,39 @@
170 216 assert_ne!(theme_css(Some("nord")), theme_css(None));
171 217 }
172 218
219 + #[test]
220 + fn console_selection_normalizes_to_a_theme_selection_string() {
221 + assert_eq!(normalize_console_theme(None).unwrap(), makeover::FOLLOW);
222 + assert_eq!(normalize_console_theme(Some("")).unwrap(), makeover::FOLLOW);
223 + assert_eq!(
224 + normalize_console_theme(Some("system")).unwrap(),
225 + makeover::FOLLOW
226 + );
227 + assert_eq!(normalize_console_theme(Some(" nord ")).unwrap(), "nord");
228 + assert_eq!(
229 + normalize_console_theme(Some("no-such-theme")),
230 + Err("no-such-theme".to_string())
231 + );
232 + }
233 +
234 + #[test]
235 + fn console_picker_leads_with_follow_and_marks_one_option() {
236 + for stored in [None, Some(""), Some("system"), Some("no-such-theme")] {
237 + let options = console_theme_options(stored);
238 + assert_eq!(options[0].id, makeover::FOLLOW);
239 + assert!(
240 + options[0].selected,
241 + "unset/unknown must fall back to follow, got {stored:?}"
242 + );
243 + assert_eq!(options.iter().filter(|o| o.selected).count(), 1);
244 + }
245 +
246 + let options = console_theme_options(Some("nord"));
247 + assert!(!options[0].selected);
248 + assert_eq!(options.iter().filter(|o| o.selected).count(), 1);
249 + assert!(options.iter().any(|o| o.id == "nord" && o.selected));
250 + }
251 +
173 252 #[test]
174 253 fn picker_list_is_nonempty_and_includes_default() {
175 254 let themes = list_themes();
@@ -102,7 +102,7 @@
102 102 SELECT u.id AS user_id, u.username, u.display_name, u.email,
103 103 u.creator_tier, u.can_create_projects,
104 104 (u.suspended_at IS NOT NULL) AS suspended,
105 - u.settlement_currency
105 + u.settlement_currency, u.console_theme
106 106 FROM ssh_keys sk
107 107 JOIN users u ON u.id = sk.user_id
108 108 WHERE sk.fingerprint = $1
@@ -191,6 +191,19 @@
191 191 Ok(())
192 192 }
193 193
194 + /// Set a user's SSH console theme. Takes a `makeover::ThemeSelection` string
195 + /// (a bundled theme id, or `"system"`), validated before this call.
196 + #[tracing::instrument(skip_all)]
197 + pub async fn update_user_console_theme(pool: &PgPool, id: UserId, selection: &str) -> Result<()> {
198 + sqlx::query("UPDATE users SET console_theme = $2, updated_at = NOW() WHERE id = $1")
199 + .bind(id)
200 + .bind(selection)
201 + .execute(pool)
202 + .await?;
203 +
204 + Ok(())
205 + }
206 +
194 207 /// Replace a user's password hash and invalidate outstanding JWTs.
195 208 #[tracing::instrument(skip_all)]
196 209 pub async fn update_user_password(pool: &PgPool, id: UserId, password_hash: &str) -> Result<()> {
@@ -712,6 +712,11 @@
712 712 #[template(path = "partials/tabs/user_ssh_keys_tab.html")]
713 713 pub struct UserSshKeysTabTemplate {
714 714 pub username: String,
715 + /// Choices for the console theme picker: "follow the terminal" first, then
716 + /// every bundled theme. Lives on this tab rather than the profile one
717 + /// because it themes the SSH console the tab's keys grant access to, not
718 + /// the public profile.
719 + pub theme_options: Vec<crate::theming::ThemeOption>,
715 720 }
716 721
717 722 /// Git access-token list partial for HTMX updates. `new_token` carries a
@@ -42,6 +42,58 @@
42 42 );
43 43 }
44 44
45 + #[tokio::test]
46 + async fn lookup_carries_the_console_theme_selection() {
47 + let db = TestDb::new().await;
48 + let user = seed_user(&db.pool, "ssh_console_theme").await;
49 +
50 + ssh_keys::add_key(&db.pool, user, "ssh-ed25519 AAAA", "SHA256:theme", "laptop")
51 + .await
52 + .unwrap();
53 +
54 + // Never chosen. Absent here becomes an absent `theme_id` on the wire, which
55 + // the CLI reads as `ThemeSelection::Follow` — today's behaviour, and what a
56 + // CLI talking to a server that predates the column already does.
57 + let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme")
58 + .await
59 + .unwrap()
60 + .expect("known fingerprint resolves");
61 + assert_eq!(found.console_theme, None);
62 +
63 + // Following the terminal is a stored choice, not an absence: the row has to
64 + // be able to say it, or a creator who pins a theme can never go back.
65 + makenotwork::db::users::update_user_console_theme(&db.pool, user, makeover::FOLLOW)
66 + .await
67 + .unwrap();
68 + let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme")
69 + .await
70 + .unwrap()
71 + .unwrap();
72 + assert_eq!(found.console_theme.as_deref(), Some(makeover::FOLLOW));
73 +
74 + // A pinned theme reaches the lookup verbatim. This is the whole point of the
75 + // column: the CLI has no other source for the creator's choice.
76 + makenotwork::db::users::update_user_console_theme(&db.pool, user, "nord")
77 + .await
78 + .unwrap();
79 + let found = ssh_keys::lookup_user_by_fingerprint(&db.pool, "SHA256:theme")
80 + .await
81 + .unwrap()
82 + .unwrap();
83 + assert_eq!(found.console_theme.as_deref(), Some("nord"));
84 +
85 + // The public-profile theme is a different question and must not move with it.
86 + assert_eq!(
87 + makenotwork::db::users::get_user_by_id(&db.pool, user)
88 + .await
89 + .unwrap()
90 + .unwrap()
91 + .theme_id,
92 + None,
93 + "the console choice must not write the profile palette visitors see"
94 + );
95 + }
96 +
45 97 #[tokio::test]
46 98 async fn duplicate_fingerprint_for_same_user_is_rejected() {
47 99 let db = TestDb::new().await;
@@ -55,6 +55,12 @@
55 55 /// because the CLI needs it before any dashboard call returns: a screen that
56 56 /// waits for revenue data to learn the symbol renders the wrong one first.
57 57 pub settlement_currency: SettlementCurrency,
58 + /// The creator's SSH console theme as a `makeover::ThemeSelection` string,
59 + /// or `None` where they have never chosen. Carried on the login lookup for
60 + /// the same reason as the currency: the CLI paints its first frame before
61 + /// any dashboard call returns, and a console that repaints once the theme
62 + /// arrives has already shown the wrong one.
63 + pub console_theme: Option<String>,
58 64 }
59 65
60 66 /// An OAuth2 authorization code for PKCE flow.