aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-29 12:29:38 +0200
committerDanilo M. <danix@danix.xyz>2026-09-29 12:29:38 +0200
commit0ca89376ea0a04ad88ae80b9b602033f3beb8eb6 (patch)
tree1c20a959647fb31689e97b343f1cabd11084c3ce
parent5b133dbd86aaf54b0c728128f304c8dd8ebea9d1 (diff)
downloadunified-desktop-theme-0ca89376ea0a04ad88ae80b9b602033f3beb8eb6.tar.gz
unified-desktop-theme-0ca89376ea0a04ad88ae80b9b602033f3beb8eb6.zip
feat(palette): theme btop, Joplin and Feishin
btop gets a generated udt.theme and only the color_theme line of btop.conf is rewritten. Joplin gets userchrome.css overriding the --joplin-* custom properties most of its UI is built on, with !important since Joplin's own :root block is injected later; a userchrome.css udt did not write is left alone. Feishin 1.17 watches a Themes folder for JSON themes, so UDT.json extends its built-in Catppuccin Mocha with the scheme's roles and reaches a running Feishin live once selected. All three use existing roles, so no scheme file changes. udt-appthemes installs them next to Obsidian and Typora; the recap hint names btop and Joplin among the apps that need a restart. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--.gitignore3
-rw-r--r--AGENTS.md26
-rwxr-xr-xbin/udt-appthemes67
-rwxr-xr-xbin/udt-palette151
-rwxr-xr-xinstall.sh2
5 files changed, 241 insertions, 8 deletions
diff --git a/.gitignore b/.gitignore
index 4dce151..23e734a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,6 @@ __pycache__/
/templates/homepage/custom.css
/templates/sublime/udt.sublime-color-scheme
/templates/sublime/udt.sublime-theme
+/templates/btop/udt.theme
+/templates/joplin/userchrome.css
+/templates/feishin/UDT.json
diff --git a/AGENTS.md b/AGENTS.md
index 11add0a..90f4bdc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -7,7 +7,7 @@ Guidance for agents working in this repository.
One visual identity for a Hyprland desktop: Catppuccin Macchiato, Noto Sans for
UI text, Inconsolata Nerd Font Mono for monospace. It covers rofi, Hyprland
window borders, conky, Qt5/Qt6, GTK3/GTK4, waybar, kitty, Sublime Text,
-hyprlock and neovim.
+hyprlock, neovim, btop, Joplin and Feishin.
The repo holds the canonical copies. `./install.sh` symlinks them into place, so
the working config cannot drift from what is committed. Everything outside
@@ -75,8 +75,9 @@ The generated set is `rofi/udt/palette.rasi`,
`templates/sublime/udt.sublime-color-scheme`,
`templates/sublime/udt.sublime-theme`, `templates/gtk/gtk{3,4}.css`,
`templates/gtk/index.theme`, `templates/gtk/assets/*.svg`,
-`templates/hyprlock/hyprlock-palette.conf`, `templates/nvim/udt.lua` and
-`bin/udt_colors.py`.
+`templates/hyprlock/hyprlock-palette.conf`, `templates/nvim/udt.lua`,
+`templates/btop/udt.theme`, `templates/joplin/userchrome.css`,
+`templates/feishin/UDT.json` and `bin/udt_colors.py`.
Sublime is two outputs, not one. The **colour scheme** is the editor pane and
tracks the wallpaper accent. The **theme** is the UI chrome, and is an override
@@ -123,15 +124,26 @@ alone left kcalc white. `install.sh` installs a generated `udt.colors` into
**Qt apps only read a theme at startup**, so a running one keeps the old
colours until restarted. `install.sh` cannot signal them and does not try.
-Obsidian and Typora are installed by `bin/udt-appthemes`, not by a copy.
+Obsidian, Typora, btop, Joplin and Feishin are installed by
+`bin/udt-appthemes`, not by a plain copy.
Obsidian gets a **snippet**, not a theme, so it layers over whatever theme a
vault uses; enabling it means appending to `enabledCssSnippets` in that vault's
`appearance.json`, never rewriting the list, or the vault loses the snippets it
-already had. Every vault under `~/Documents/Obsidian` is found by looking for
-`.obsidian` directories, nested ones included. Typora's theme is derived from
+already had. Vaults come from Obsidian's own registry, `obsidian.json`, not a
+directory scan, which found abandoned vaults. Typora's theme is derived from
its Dracula theme, whose 350 rules all route through `:root` variables, so only
that block is templated.
+btop gets `~/.config/btop/themes/udt.theme` and only the `color_theme` line of
+`btop.conf` is rewritten; btop writes that file back on exit, so restart a
+running one after a switch. Joplin gets `userchrome.css`, which overrides the
+`--joplin-*` custom properties its UI is built on, with `!important` because
+Joplin's own `:root` block lands after it; parts still styled from its JS theme
+object keep the base theme, so that must stay a dark one. A `userchrome.css`
+udt did not write is left alone. Feishin gets `Themes/UDT.json`, extending its
+built-in Catppuccin Mocha; the folder is watched, so after selecting UDT once
+in its settings, a scheme switch reaches a running Feishin live.
+
Signal, Discord and Spotify cannot be consumers: they ship closed bundles with
no user-CSS hook, and patching a signed app bundle breaks on every update.
@@ -332,3 +344,5 @@ The table is what it does, and what to run if you change a config by hand.
| conky | restart it; no hot-reload |
| neovim | `:colorscheme udt`, or restart it |
| Qt / GTK apps | restart the app |
+| btop, Joplin | restart the app |
+| Feishin | nothing; it watches its Themes folder |
diff --git a/bin/udt-appthemes b/bin/udt-appthemes
index 37d3e63..5504427 100755
--- a/bin/udt-appthemes
+++ b/bin/udt-appthemes
@@ -2,7 +2,8 @@
# udt-appthemes: install the generated app themes that need more than a copy.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
# Licensed under the GNU General Public License v2 only.
-"""Install the Obsidian snippet into every vault, and the Typora theme.
+"""Install the app themes that need more than a copy: Obsidian, Typora,
+btop, Joplin and Feishin.
Separate from install.sh because enabling an Obsidian snippet means editing
JSON that the user also owns: appearance.json lists every enabled snippet, so
@@ -19,6 +20,9 @@ REPO = Path(__file__).resolve().parent.parent
SNIPPET = "udt"
OBSIDIAN_REGISTRY = Path.home() / ".config" / "obsidian" / "obsidian.json"
TYPORA_THEMES = Path.home() / ".config" / "Typora" / "themes"
+BTOP = Path.home() / ".config" / "btop"
+JOPLIN = Path.home() / ".config" / "joplin-desktop"
+FEISHIN = Path.home() / ".config" / "feishin"
def vaults():
@@ -80,6 +84,54 @@ def install_typora(source):
return True
+def install_btop(source):
+ """Install the theme and point btop.conf's color_theme at it.
+
+ Only that one line is rewritten: btop.conf is btop's own, and it writes
+ the file back on exit, so a btop left running across a scheme switch
+ restores its old color_theme. Restart it after installing.
+ """
+ if not BTOP.is_dir():
+ return False
+ (BTOP / "themes").mkdir(exist_ok=True)
+ shutil.copyfile(source, BTOP / "themes" / "udt.theme")
+ conf = BTOP / "btop.conf"
+ try:
+ lines = conf.read_text().splitlines()
+ except OSError:
+ lines = []
+ want = 'color_theme = "udt"'
+ lines = [want if line.startswith("color_theme") else line for line in lines]
+ if want not in lines:
+ lines.append(want)
+ conf.write_text("\n".join(lines) + "\n")
+ return True
+
+
+def install_joplin(source):
+ """Write userchrome.css, unless the user already keeps their own there.
+
+ Joplin has one userchrome.css per profile and no include, so a file this
+ script did not write is left alone rather than replaced.
+ """
+ if not JOPLIN.is_dir():
+ return None
+ dest = JOPLIN / "userchrome.css"
+ if dest.exists() and "Generated by udt-palette" not in dest.read_text():
+ return False
+ shutil.copyfile(source, dest)
+ return True
+
+
+def install_feishin(source):
+ """Drop the theme into Feishin's Themes folder, which it watches live."""
+ if not FEISHIN.is_dir():
+ return False
+ (FEISHIN / "Themes").mkdir(exist_ok=True)
+ shutil.copyfile(source, FEISHIN / "Themes" / "UDT.json")
+ return True
+
+
def main():
obsidian = REPO / "templates" / "obsidian" / "udt.css"
typora = REPO / "templates" / "typora" / "udt.css"
@@ -95,6 +147,19 @@ def main():
if install_typora(typora):
print("typora: udt.css installed (select it in Themes)")
+ templates = REPO / "templates"
+ if install_btop(templates / "btop" / "udt.theme"):
+ print("btop: udt theme set (restart btop)")
+
+ joplin = install_joplin(templates / "joplin" / "userchrome.css")
+ if joplin:
+ print("joplin: userchrome.css installed (restart Joplin)")
+ elif joplin is False:
+ print("joplin: userchrome.css is your own, left alone")
+
+ if install_feishin(templates / "feishin" / "UDT.json"):
+ print("feishin: UDT theme installed (select it once in Settings, it then follows live)")
+
return 0
diff --git a/bin/udt-palette b/bin/udt-palette
index 00e3092..cdeb571 100755
--- a/bin/udt-palette
+++ b/bin/udt-palette
@@ -13,6 +13,7 @@ what each is for, and everything else here is generated. Editing a generated
file is pointless: the next install.sh overwrites it.
"""
+import json
import re
import sys
from pathlib import Path
@@ -795,6 +796,153 @@ def load(selector_path, scheme=None):
# What gets written where. Templates are read from the repo, everything else is
# generated whole.
+def gen_btop(res, scheme, palette):
+ """btop: a .theme file, installed as ~/.config/btop/themes/udt.theme.
+
+ btop has no include or variables, so every key is written. The graphs are
+ three-stop gradients; they run through the state colours so a meter reads
+ the same way a waybar pill does, calm to critical.
+ """
+ def c(role):
+ return hex6(res[role])
+
+ def grad(name, start, mid, end):
+ return (f'theme[{name}_start]="{c(start)}"\n'
+ f'theme[{name}_mid]="{c(mid)}"\n'
+ f'theme[{name}_end]="{c(end)}"\n')
+
+ return (f"# {BANNER.format(scheme=scheme)}\n\n"
+ f'theme[main_bg]="{c("bg")}"\n'
+ f'theme[main_fg]="{c("fg")}"\n'
+ f'theme[title]="{c("fg")}"\n'
+ f'theme[hi_fg]="{c("accent")}"\n'
+ f'theme[selected_bg]="{c("surface_alt")}"\n'
+ f'theme[selected_fg]="{c("fg")}"\n'
+ f'theme[inactive_fg]="{c("fg_faint")}"\n'
+ f'theme[graph_text]="{c("fg_dim")}"\n'
+ f'theme[meter_bg]="{c("surface")}"\n'
+ f'theme[proc_misc]="{c("info")}"\n'
+ f'theme[cpu_box]="{c("accent")}"\n'
+ f'theme[mem_box]="{c("success")}"\n'
+ f'theme[net_box]="{c("peach")}"\n'
+ f'theme[proc_box]="{c("info")}"\n'
+ f'theme[div_line]="{c("border")}"\n'
+ + grad("temp", "success", "warning", "critical")
+ + grad("cpu", "success", "warning", "critical")
+ + grad("free", "surface_alt", "success", "success")
+ + grad("cached", "surface_alt", "info", "info")
+ + grad("available", "surface_alt", "peach", "peach")
+ + grad("used", "surface_alt", "critical", "critical")
+ + grad("download", "surface_alt", "info", "accent")
+ + grad("upload", "surface_alt", "peach", "warning")
+ + grad("process", "info", "accent", "critical"))
+
+
+def gen_joplin(res, scheme, palette):
+ """Joplin: userchrome.css, overriding the --joplin-* theme variables.
+
+ Joplin turns its theme object into :root custom properties and most of the
+ desktop UI reads those, so overriding them recolours it without restyling
+ a single component. !important because Joplin's own :root block is
+ injected after this file. Parts still styled from the JS theme object keep
+ the base theme's colours, so the base theme should stay a dark one.
+ """
+ def c(role):
+ return hex6(res[role])
+
+ pairs = [
+ ("background-color", c("bg")),
+ ("background-color-transparent", hex8((*res["bg"][:3], 90))),
+ ("odd-background-color", c("bg_alt")),
+ ("color", c("fg")),
+ ("color-error", c("critical")),
+ ("color-correct", c("success")),
+ ("color-warn", c("warning")),
+ ("color-warn-url", c("warning")),
+ ("color-faded", c("fg_faint")),
+ ("divider-color", c("border")),
+ ("selected-color", c("surface_alt")),
+ ("url-color", c("info")),
+ ("color-error-selected", c("critical")),
+ ("background-color2", c("bg_deep")),
+ ("color2", c("fg")),
+ ("selected-color2", c("surface_alt")),
+ ("color-error2", c("critical")),
+ ("color-warn2", c("warning")),
+ ("color-warn3", c("warning")),
+ ("background-color3", c("bg_alt")),
+ ("background-color-hover3", c("surface")),
+ ("background-color-active3", c("surface_alt")),
+ ("color3", c("fg")),
+ ("background-color4", c("bg")),
+ ("color4", c("accent")),
+ ("background-color5", c("surface")),
+ ("color5", c("fg")),
+ ("background-color-hover2", c("surface")),
+ ("color-hover2", c("fg")),
+ ("color-active2", c("fg")),
+ ("color-faded2", c("fg_dim")),
+ ("raised-background-color", c("surface")),
+ ("raised-color", c("fg")),
+ ("warning-background-color", c("surface_alt")),
+ ("destructive-color", c("critical")),
+ ("table-background-color", c("bg_alt")),
+ ("code-background-color", c("bg_deep")),
+ ("code-border-color", c("border")),
+ ("code-color", c("fg")),
+ ("header-background-color", c("bg_alt")),
+ ("text-selection-color", c("accent")),
+ ("search-marker-background-color", c("warning")),
+ ("search-marker-color", c("bg")),
+ ("focus-outline-color", c("accent")),
+ ("focus-outline-color-dimmed", hex8((*res["accent"][:3], 50))),
+ ("border-color4", c("border")),
+ ("scrollbar-thumb-color", c("fg_faint")),
+ ("scrollbar-thumb-color-hover", c("fg_dim")),
+ ("scrollbar-thumb-color2", c("fg_faint")),
+ ("scrollbar-thumb-color-hover2", c("fg_dim")),
+ ]
+ body = "\n".join(f" --joplin-{k}: {v} !important;" for k, v in pairs)
+ return f"/* {BANNER.format(scheme=scheme)} */\n\n:root {{\n{body}\n}}\n"
+
+
+def gen_feishin(res, scheme, palette):
+ """Feishin: a custom theme JSON for its watched Themes folder.
+
+ It extends the built-in Catppuccin Mocha, so everything not restated here
+ (spacing, radii, the Mantine mapping) comes from a dark theme Feishin
+ already ships. Feishin validates every colour and drops what it cannot
+ parse, so plain hex only.
+ """
+ def c(role):
+ return hex6(res[role])
+
+ r, g, b, _ = res["bg"]
+ theme = {
+ "extends": "catppuccinMocha",
+ "mode": "dark",
+ "app": {
+ "overlay-header": f"linear-gradient(transparent 0%, rgb({r} {g} {b} / 85%) 100%), var(--theme-background-noise)",
+ "overlay-subheader": f"linear-gradient(180deg, rgb({r} {g} {b} / 5%) 0%, var(--theme-colors-background) 100%), var(--theme-background-noise)",
+ },
+ "colors": {
+ "background": c("bg"),
+ "background-alternate": c("bg_deep"),
+ "foreground": c("fg"),
+ "foreground-muted": c("fg_dim"),
+ "primary": c("accent"),
+ "state-error": c("critical"),
+ "state-info": c("info"),
+ "state-success": c("success"),
+ "state-warning": c("warning"),
+ "surface": c("bg_alt"),
+ "surface-foreground": c("fg"),
+ },
+ }
+ # JSON has no comments, so the banner goes in a key Feishin ignores.
+ return json.dumps({"_comment": BANNER.format(scheme=scheme), **theme}, indent=4) + "\n"
+
+
TARGETS = [
("rofi/udt/palette.rasi", gen_rofi, None),
("templates/waybar/theme.css", gen_waybar, None),
@@ -817,6 +965,9 @@ TARGETS = [
("templates/gtk/gtk3.css", gen_gtk, "templates/gtk/gtk3.css.in"),
("templates/gtk/gtk4.css", gen_gtk, "templates/gtk/gtk4.css.in"),
("templates/gtk/index.theme", gen_gtk, "templates/gtk/index.theme.in"),
+ ("templates/btop/udt.theme", gen_btop, None),
+ ("templates/joplin/userchrome.css", gen_joplin, None),
+ ("templates/feishin/UDT.json", gen_feishin, None),
]
# The GTK widget assets, one target each. Discovered rather than listed: there
diff --git a/install.sh b/install.sh
index 80d5f13..7b6ca28 100755
--- a/install.sh
+++ b/install.sh
@@ -374,4 +374,4 @@ echo
echo
[ -n "$reloaded" ] && echo "reloaded:$reloaded"
-echo "Qt and GTK apps only reread a theme when they restart; neovim needs :colorscheme udt."
+echo "Qt, GTK, btop and Joplin only reread a theme when they restart; neovim needs :colorscheme udt."