kitgit

tirbofish/kitgit · commit

7b3a10061fad22b3233815f639275ef1ca77197a

feat: add light/dark/system theme preference

Verified

Thribhu K <4tkbytes@pm.me> · 2026-07-28 15:12

view full diff

diff --git a/migrations/012_user_theme.sql b/migrations/012_user_theme.sql
new file mode 100644
index 0000000..7b57126
--- /dev/null
+++ b/migrations/012_user_theme.sql
@@ -0,0 +1,10 @@
+-- Per-user appearance preference: light | dark | system
+
+ALTER TABLE users
+    ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
+
+ALTER TABLE users
+    DROP CONSTRAINT IF EXISTS users_theme_check;
+
+ALTER TABLE users
+    ADD CONSTRAINT users_theme_check CHECK (theme IN ('light', 'dark', 'system'));
diff --git a/src/db/models.rs b/src/db/models.rs
index 874e00c..f98c3f6 100644
--- a/src/db/models.rs
+++ b/src/db/models.rs
@@ -17,10 +17,21 @@ pub struct User {
     pub is_suspended: bool,
     pub show_email: bool,
     pub vigilant_mode: bool,
+    /// `light`, `dark`, or `system` (follows prefers-color-scheme).
+    pub theme: String,
     pub created_at: DateTime<Utc>,
     pub updated_at: DateTime<Utc>,
 }
 
+impl User {
+    pub fn theme_pref(&self) -> &str {
+        match self.theme.as_str() {
+            "light" | "dark" => self.theme.as_str(),
+            _ => "system",
+        }
+    }
+}
+
 #[derive(Debug, Clone, FromRow)]
 pub struct UserMfa {
     pub user_id: Uuid,
diff --git a/src/db/queries.rs b/src/db/queries.rs
index a236a11..4f67ddd 100644
--- a/src/db/queries.rs
+++ b/src/db/queries.rs
@@ -2003,6 +2003,19 @@ pub async fn update_privacy(
     .await?)
 }
 
+pub async fn update_user_theme(pool: &PgPool, id: Uuid, theme: &str) -> Result<User> {
+    Ok(sqlx::query_as::<_, User>(
+        r#"
+        UPDATE users SET theme = $2, updated_at = now()
+        WHERE id = $1 RETURNING *
+        "#,
+    )
+    .bind(id)
+    .bind(theme)
+    .fetch_one(pool)
+    .await?)
+}
+
 pub async fn list_user_emails(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserEmail>> {
     Ok(sqlx::query_as::<_, UserEmail>(
         "SELECT * FROM user_emails WHERE user_id = $1 ORDER BY is_primary DESC, email",
@@ -2281,6 +2294,7 @@ pub async fn export_user_data(pool: &PgPool, user_id: Uuid) -> Result<serde_json
             "bio": user.bio,
             "show_email": user.show_email,
             "vigilant_mode": user.vigilant_mode,
+            "theme": user.theme,
             "created_at": user.created_at,
         },
         "emails": emails,
diff --git a/src/web/mod.rs b/src/web/mod.rs
index c4b4cbb..ae451b7 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -111,6 +111,10 @@ pub fn app_router(state: AppState) -> Router {
             post(routes_extra::account_privacy),
         )
         .route(
+            "/settings/account/theme",
+            post(routes_extra::account_theme),
+        )
+        .route(
             "/settings/account/emails",
             post(routes_extra::account_add_email),
         )
diff --git a/src/web/routes_extra.rs b/src/web/routes_extra.rs
index 712ab5f..733fce3 100644
--- a/src/web/routes_extra.rs
+++ b/src/web/routes_extra.rs
@@ -665,6 +665,25 @@ pub async fn account_privacy(
 }
 
 #[derive(Deserialize)]
+pub struct ThemeForm {
+    pub theme: String,
+}
+
+pub async fn account_theme(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<ThemeForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    let theme = match form.theme.trim() {
+        "light" | "dark" | "system" => form.theme.trim(),
+        _ => return Err(AppError::bad("theme must be light, dark, or system")),
+    };
+    queries::update_user_theme(&state.pool, user.id, theme).await?;
+    Ok(redirect_see_other("/settings/account"))
+}
+
+#[derive(Deserialize)]
 pub struct EmailForm {
     pub email: String,
 }
diff --git a/static/brand.css b/static/brand.css
index d50db11..890e5d7 100644
--- a/static/brand.css
+++ b/static/brand.css
@@ -1,7 +1,8 @@
 /* kitgit — brand theme
    ink on paper. no chrome. */
 
-:root {
+:root,
+[data-theme="light"] {
   --kg-bg: #ffffff;
   --kg-bg-raised: #f7f7f7;
   --kg-fg: #0a0a0a;
@@ -34,6 +35,7 @@
   color-scheme: light;
 }
 
+[data-theme="dark"],
 html.kg-dark {
   --kg-bg: #0a0a0a;
   --kg-bg-raised: #141414;
@@ -43,6 +45,7 @@ html.kg-dark {
   --kg-line: #262626;
   --kg-line-strong: #f5f5f5;
   --kg-link: #f5f5f5;
+  --kg-focus: #f5f5f5;
   --kg-danger: #ff4d5a;
   --kg-danger-bg: #2a1214;
   --kg-invert-bg: #f5f5f5;
@@ -51,6 +54,26 @@ html.kg-dark {
   color-scheme: dark;
 }
 
+@media (prefers-color-scheme: dark) {
+  [data-theme="system"] {
+    --kg-bg: #0a0a0a;
+    --kg-bg-raised: #141414;
+    --kg-fg: #f5f5f5;
+    --kg-muted: #8a8a8a;
+    --kg-faint: #5c5c5c;
+    --kg-line: #262626;
+    --kg-line-strong: #f5f5f5;
+    --kg-link: #f5f5f5;
+    --kg-focus: #f5f5f5;
+    --kg-danger: #ff4d5a;
+    --kg-danger-bg: #2a1214;
+    --kg-invert-bg: #f5f5f5;
+    --kg-invert-fg: #0a0a0a;
+    --kg-invert-muted: #525252;
+    color-scheme: dark;
+  }
+}
+
 *,
 *::before,
 *::after {
@@ -1316,35 +1339,67 @@ a:hover {
 .kg-codeview .entity.other.attribute-name { color: #8a4b08; }
 .kg-codeview .punctuation { color: #3d3d3d; }
 
-html.kg-dark .kg-codeview .comment,
-html.kg-dark .kg-codeview .punctuation.definition.comment { color: #8a8a8a; }
-html.kg-dark .kg-codeview .string,
-html.kg-dark .kg-codeview .string.quoted,
-html.kg-dark .kg-codeview .constant.character.escape { color: #5ec49a; }
-html.kg-dark .kg-codeview .constant.numeric,
-html.kg-dark .kg-codeview .constant.language,
-html.kg-dark .kg-codeview .constant.character { color: #e0a35a; }
-html.kg-dark .kg-codeview .keyword,
-html.kg-dark .kg-codeview .keyword.control,
-html.kg-dark .kg-codeview .keyword.operator,
-html.kg-dark .kg-codeview .storage,
-html.kg-dark .kg-codeview .storage.type,
-html.kg-dark .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; }
-html.kg-dark .kg-codeview .entity.name.function,
-html.kg-dark .kg-codeview .support.function { color: #7eb0f0; }
-html.kg-dark .kg-codeview .entity.name.class,
-html.kg-dark .kg-codeview .entity.name.struct,
-html.kg-dark .kg-codeview .entity.name.type,
-html.kg-dark .kg-codeview .entity.name.namespace,
-html.kg-dark .kg-codeview .support.type,
-html.kg-dark .kg-codeview .support.class { color: #c9a0ff; }
-html.kg-dark .kg-codeview .variable,
-html.kg-dark .kg-codeview .variable.parameter,
-html.kg-dark .kg-codeview .variable.other { color: #f5f5f5; }
-html.kg-dark .kg-codeview .meta.annotation,
-html.kg-dark .kg-codeview .entity.name.tag { color: #ff8a8a; }
-html.kg-dark .kg-codeview .entity.other.attribute-name { color: #e0a35a; }
-html.kg-dark .kg-codeview .punctuation { color: #b0b0b0; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .comment,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .punctuation.definition.comment { color: #8a8a8a; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .string,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .string.quoted,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.character.escape { color: #5ec49a; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.numeric,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.language,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.character { color: #e0a35a; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .keyword,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .keyword.control,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .keyword.operator,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .storage,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .storage.type,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.function,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .support.function { color: #7eb0f0; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.class,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.struct,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.type,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.namespace,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .support.type,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .support.class { color: #c9a0ff; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .variable,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .variable.parameter,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .variable.other { color: #f5f5f5; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .meta.annotation,
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.tag { color: #ff8a8a; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.other.attribute-name { color: #e0a35a; }
+:is([data-theme="dark"], html.kg-dark) .kg-codeview .punctuation { color: #b0b0b0; }
+
+@media (prefers-color-scheme: dark) {
+  [data-theme="system"] .kg-codeview .comment,
+  [data-theme="system"] .kg-codeview .punctuation.definition.comment { color: #8a8a8a; }
+  [data-theme="system"] .kg-codeview .string,
+  [data-theme="system"] .kg-codeview .string.quoted,
+  [data-theme="system"] .kg-codeview .constant.character.escape { color: #5ec49a; }
+  [data-theme="system"] .kg-codeview .constant.numeric,
+  [data-theme="system"] .kg-codeview .constant.language,
+  [data-theme="system"] .kg-codeview .constant.character { color: #e0a35a; }
+  [data-theme="system"] .kg-codeview .keyword,
+  [data-theme="system"] .kg-codeview .keyword.control,
+  [data-theme="system"] .kg-codeview .keyword.operator,
+  [data-theme="system"] .kg-codeview .storage,
+  [data-theme="system"] .kg-codeview .storage.type,
+  [data-theme="system"] .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; }
+  [data-theme="system"] .kg-codeview .entity.name.function,
+  [data-theme="system"] .kg-codeview .support.function { color: #7eb0f0; }
+  [data-theme="system"] .kg-codeview .entity.name.class,
+  [data-theme="system"] .kg-codeview .entity.name.struct,
+  [data-theme="system"] .kg-codeview .entity.name.type,
+  [data-theme="system"] .kg-codeview .entity.name.namespace,
+  [data-theme="system"] .kg-codeview .support.type,
+  [data-theme="system"] .kg-codeview .support.class { color: #c9a0ff; }
+  [data-theme="system"] .kg-codeview .variable,
+  [data-theme="system"] .kg-codeview .variable.parameter,
+  [data-theme="system"] .kg-codeview .variable.other { color: #f5f5f5; }
+  [data-theme="system"] .kg-codeview .meta.annotation,
+  [data-theme="system"] .kg-codeview .entity.name.tag { color: #ff8a8a; }
+  [data-theme="system"] .kg-codeview .entity.other.attribute-name { color: #e0a35a; }
+  [data-theme="system"] .kg-codeview .punctuation { color: #b0b0b0; }
+}
 
 .kg-readme {
   border: 1px solid var(--kg-line);
diff --git a/templates/account_settings.html b/templates/account_settings.html
index 248b87f..436ad9c 100644
--- a/templates/account_settings.html
+++ b/templates/account_settings.html
@@ -20,6 +20,28 @@
   {% endif %}
 
   <div class="kg-settings-section" style="margin-top:1rem;border-top:0;padding-top:0">
+    <span class="kg-kicker">appearance</span>
+    <p class="kg-muted">Ink on paper by day, charcoal by night — or follow your system.</p>
+    <form class="kg-form" method="post" action="/settings/account/theme">
+      <label class="row">
+        <input type="radio" name="theme" value="light"{% if user.theme == "light" %} checked{% endif %} />
+        light
+      </label>
+      <label class="row">
+        <input type="radio" name="theme" value="dark"{% if user.theme == "dark" %} checked{% endif %} />
+        dark
+      </label>
+      <label class="row">
+        <input type="radio" name="theme" value="system"{% if user.theme != "light" && user.theme != "dark" %} checked{% endif %} />
+        system
+      </label>
+      <div class="kg-actions">
+        <button class="kg-btn" type="submit">save theme</button>
+      </div>
+    </form>
+  </div>
+
+  <div class="kg-settings-section">
     <span class="kg-kicker">username</span>
     <form class="kg-form" method="post" action="/settings/account/username">
       <label>
diff --git a/templates/layout.html b/templates/layout.html
index f589a61..169ef58 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html lang="en">
+<html lang="en" data-theme="{% if let Some(u) = viewer %}{{ u.theme_pref() }}{% else %}system{% endif %}">
 <head>
   <meta charset="utf-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1" />