Dark/Light-Mode Switcher for Arch Linux + i3wm

Table of Contents 📑
- Changelog
- Architectural Overview
- Quickstart (Copy & Paste)
- Recommended Approach: Set up Step-by-Step
- Theme Choice: Dracula ↔ Solarized Light
- Migrating from a hand-themed setup
- Step 1: Install Prerequisites
- Step 2: Create Directory Structure
- Step 3: Prepare Qt Apps (KeePassXC, nheko)
- Step 4: Split i3 Config
- Step 5: Integrate Polybar
- Step 6: Chromium Flags
- Step 7: The Dark-Mode Hook Script
- Step 8: The Light-Mode Hook Script
- Step 9: Initial State and Persistence on Login
- Step 10: Obtain Theme Files
- Caveats and Honest Expectations
- Tips
- Extension Ideas
- Summary
Welcome to this detailed guide showing you how to set up an effective Dark/Light-Mode switcher for your Arch Linux system with i3wm. In a world where most applications have their own theming logic, we will build an orchestration solution that controls many components simultaneously to create a consistent experience.
We will integrate GTK, Qt, terminal, and even CLI applications into our theme switcher, all controlled by a single keypress.
Changelog
| Date | Change |
|---|---|
| 2026-06-10 | Design and Robustness Pass: WCAG-checked contrast fixes for NeoMutt and ikhal: neutral selection/status/search pairs, mail-state rows with adaptive backgrounds via a reverse trick, per-calendar ANSI accents and a clearer color hierarchy in ikhal — accent slots in a shared semantic palette are foreground-only. Unified the Dracula ANSI palette across OSC, Xresources, and Alacritty. i3 focus switched to a signal-red border with default_border pixel 3, urgent moved to orange. Polybar restart hardened (enable-ipc plus verified fallback), fixing intermittently vanishing bars after a toggle. Claude Code’s truecolor theme is switched per mode; nheko stays on theme=system (its built-in light theme hardcodes a dark Element-style sidebar, #233649 — only system derives it from the Qt palette) and is stopped/restarted automatically per toggle because it persists settings on exit and mixes on live palette switches — the fresh window reappears in place on its old workspace via i3 layout-restore swallow placeholders (restart_app_in_place); tray applets are restarted toggle-proof, the light mode uses an icon-theme variant with dark panel icons (Papirus ships white panel icons by design), and the Nextcloud and KeePassXC tray icons switched to mode-independent colorful variants. NeoMutt’s cursor row moved to its own background pair (markers must never share one), and sonicradio settled on one constant theme whose built-in dark/light profiles are both high-contrast and survive its config write-back. Root-caused a class of “GUI relaunch crashes silently”: darkman.service starts before the display manager exports DISPLAY/XAUTHORITY — the X-environment import in Step 9 is now documented as required, plus a fallback in the hooks. |
| 2026-06-09 | TUI Live-Reload Strategies: NeoMutt and ikhal converted to semantic 16-color palettes with SIGWINCH redraw, so running sessions repaint live when the hooks change the terminal palette via OSC; jellyfin-tui keeps a stable Mode Switcher theme rewritten in the live-watched config.yaml, plus an after-exit preference rewrite and high-contrast palettes. Fixes from testing real sessions: NeoMutt DirectColor and regex pitfalls, Dunst live reload, Rofi theme switching, Xresources -load instead of -merge, targeted urxvt/Alacritty OSC recoloring, Polybar tray preservation, and stronger Light-mode contrast. |
| 2026-06-04 | Technical Review: Hardened portal, Alacritty, Qt, Polybar, Chromium, and hook-script guidance after implementation review. |
| 2026-05-04 | Initial Version: Comprehensive guide for Dark/Light-Mode switching on Arch Linux and i3wm. |
Architectural Overview
i3wm itself does not provide global theme management. Styling is handled by various components such as GTK, Qt, Xresources, and individual applications. Our solution is based on an orchestrator architecture that uses darkman as the central control unit.
darkman performs two main tasks:
- It executes your customized hook scripts, which adapt specific applications and environments.
- It propagates the dark mode status via the XDG-Desktop-Portal to modern applications (e.g., Firefox, Thunderbird). The portal signal is live; whether the visible app chrome also repaints live depends on the application.
$mod+Shift+d → darkman toggle → Hook Scripts → Individual Apps
│
└──► XDG-Portal (Firefox, Thunderbird, etc. via Signal)Quickstart (Copy & Paste)
If you want the shortest path to the core setup — GTK config + XDG portal + i3 + Alacritty controlled by $mod+Shift+d — use this Quickstart as the bootstrap. It prepares the required packages, portal configuration, theme directories, terminal theme files, and i3 binding. The visible switching behavior still depends on the i3 theme files, the Alacritty import line, and the hook scripts from the later steps.
# 1. Install prerequisites
sudo pacman -S darkman xdg-desktop-portal xdg-desktop-portal-gtk \
jq ruby libnotify dunst alacritty xorg-xrdb
yay -S dracula-gtk-theme # or fall back to Adwaita-dark (handled by hook)
# 2. Create directory layout
mkdir -p ~/.local/share/dark-mode.d \
~/.local/share/light-mode.d \
~/.config/themes/{dracula,solarized-light} \
~/.config/alacritty \
~/.config/xdg-desktop-portal
# 3. Tell xdg-desktop-portal to use darkman as the Settings backend.
# REQUIRED since xdg-desktop-portal 1.17.0 — without this, Firefox &
# other portal-aware apps will not see darkman's color-scheme.
# default=gtk keeps the GTK portal as fallback for file choosers and
# other portal interfaces that darkman does not implement.
cat > ~/.config/xdg-desktop-portal/portals.conf <<'EOF'
[preferred]
default=gtk
org.freedesktop.impl.portal.Settings=darkman
EOF
# 4. Pull terminal theme files
curl -fsSL https://raw.githubusercontent.com/dracula/alacritty/master/dracula.toml \
-o ~/.config/themes/dracula/alacritty.toml
curl -fsSL https://raw.githubusercontent.com/alacritty/alacritty-theme/master/themes/solarized_light.toml \
-o ~/.config/themes/solarized-light/alacritty.toml
# 5. Wire up i3 (idempotent — safe to run twice)
grep -q "bindsym \$mod+Shift+d exec --no-startup-id darkman toggle" ~/.config/i3/config \
|| echo 'bindsym $mod+Shift+d exec --no-startup-id darkman toggle' >> ~/.config/i3/config
grep -q "include ~/.config/i3/theme.conf" ~/.config/i3/config \
|| echo 'include ~/.config/i3/theme.conf' >> ~/.config/i3/config
i3-msg reload >/dev/null 2>&1 || true
# 6. Enable the service
systemctl --user enable --now darkman.service
systemctl --user restart xdg-desktop-portal.service 2>/dev/null || true
# 7. Quick smoke test
# Note: these only have a *visible* effect after you create the
# hook scripts in Steps 7 & 8. Until then darkman just flips an
# internal flag — nothing recolors yet.
darkman get # current mode
darkman toggle # switch without using the keybindingThen create the i3 theme stubs from Step 4, import the generated Alacritty theme file as shown in Step 10, and create the hook scripts from Step 7 and Step 8. After that, $mod+Shift+d should give you a working basic switch.
Expected result after Quickstart + Step 4 + Step 7/8 + Alacritty import: Pressing
$mod+Shift+d(or runningdarkman toggle) should recolor i3 borders, switch the Alacritty theme without restarting the terminal, and update the XDG portal color-scheme for Firefox/Thunderbird. Firefox normally useswidget.use-xdg-desktop-portal.settings = 2(“auto”); set it to1only if auto-detection does not pick up the portal on your system. Full Firefox chrome repainting can still require an app restart. If any of the core checks fail, see the Reality Check section.
Recommended Approach: Set up Step-by-Step
To avoid frustration and facilitate troubleshooting, you should approach the setup in phases. This way, you’ll always know which component is responsible for problems.
- Phase 1 — Basic:
darkman+ GTK config + XDG portal + i3 + Alacritty. These components are reliable and cover the majority of the switcher architecture. - Phase 2 — Extension: Polybar + VSCodium + neomutt. This requires a bit more configuration, but the functionality is stable.
- Phase 3 — Problem Children: Qt apps such as nheko, KeePassXC when not set to Automatic, Chromium, generic GTK apps such as Thunar, Firefox chrome repainting, and urxvt live-reload. These applications can present app-specific challenges and should be tested separately.
Follow this tutorial linearly and test the functionality after each phase before moving on to the next.
Theme Choice: Dracula ↔ Solarized Light
For dark mode, we will use Dracula, a popular and well-supported theme. For the light mode counterpart, several options are available:
- Solarized Light: A classic with well-documented color palettes for many tools, known for its good readability.
- Catppuccin Latte: A modern alternative that aesthetically complements Catppuccin Mocha (if you decide to replace Dracula later).
- GitHub Light: A pragmatic choice, as many editor themes and browser extensions are available for it.
This guide will use the Dracula ↔ Solarized Light pair.
Migrating from a hand-themed setup
If you’ve already painstakingly themed each application by hand, read this section before running any of the hook scripts. The hooks are designed for a setup built with darkman in mind — applied to an existing setup, they will overwrite or symlink-replace several files without asking.
What the hooks will update in place
These files are changed on every toggle, but the hook examples below update only the relevant keys and preserve unrelated local settings:
~/.config/gtk-3.0/settings.iniand~/.config/gtk-4.0/settings.ini—gtk-theme-name,gtk-icon-theme-name, andgtk-application-prefer-dark-themeare created or updated. Existing font, cursor, Xft, and other keys are preserved.~/.config/qt5ct/qt5ct.conf,~/.config/qt6ct/qt6ct.conf—color_scheme_path,custom_palette, and optionallyicon_themeare created or updated. The rest of the file is preserved.~/.config/VSCodium/User/settings.json— only theworkbench.colorThemekey is updated viajq. Caveat:jqrequires strict JSON. If your settings file contains JSONC-style comments or trailing commas, the hook will skip the change rather than rewrite the file.~/.config/khal/config—[view] theme,[highlight_days], and semantic ANSI[palette]keys are updated. The section-aware helper is important here; a global string replacement can accidentally overwrite unrelated keys with the same name.~/.config/dunst/dunstrc— only theme color keys such asframe_color,format,background, andforegroundare updated, then Dunst is reloaded withdunstctl reload. Existing notification rules and sound scripts are preserved.~/.config/jellyfin-tui/config.yaml,~/.local/share/jellyfin-tui/preferences/*.json, and~/.config/sonicRadio/config.json— only theme-related values are changed.jellyfin-tuican repaint live when it is already using the stableMode Switchertheme; NeoMutt and ikhal can repaint live after they have loaded the semantic ANSI palette once. Other running TUI instances may still keep their current in-memory theme until restart or in-app theme selection.
What the hooks will replace via symlink (non-destructive but pathing changes)
These paths become symlinks pointing into ~/.config/themes/<theme>/. If a real file exists at the path, the symlink replaces it (the original file stays where it was, but is no longer reachable through the original path):
~/.Xresources~/.config/i3/theme.conf~/.config/polybar/colors.ini~/.config/neomutt/colorsor, in mutt-wizard style setups,~/.config/mutt/theme.muttrc~/.config/nvim/colorscheme.vim
~/.config/alacritty/theme.toml is handled differently: the hooks rewrite it as a regular file. In practice this is more reliable for Alacritty live reload than switching the target of a symlink.
What is changed system-wide via gsettings
org.gnome.desktop.interface gtk-theme and color-scheme — affects every GTK app on your system that reads gsettings, regardless of whether you intended to theme it through darkman.
What is NOT touched
Your main configuration files (~/.config/i3/config, ~/.config/alacritty/alacritty.toml, your Polybar bar layout, your .muttrc, your Neovim init, your shell rc files, Firefox profiles) are only referenced via include/import/source lines. They stay where they are. Chromium is intentionally treated as restart-only and is not changed by the default hooks.
One important exception to watch for in existing setups: remove hardcoded theme environment variables such as export GTK_THEME=Adwaita:dark from .profile, .xprofile, .zprofile, or display-manager startup scripts. They can override the hook result for newly started GTK applications and make Light mode appear broken.
Recommended: snapshot before the first toggle
mkdir -p ~/theme-switcher-backup
for path in \
~/.config/gtk-3.0 \
~/.config/gtk-4.0 \
~/.config/qt5ct \
~/.config/qt6ct \
~/.Xresources \
~/.config/VSCodium/User/settings.json
do
[[ -e "$path" ]] && cp -a "$path" ~/theme-switcher-backup/
doneAfter the first toggle, diff what changed:
diff -r ~/theme-switcher-backup/gtk-3.0 ~/.config/gtk-3.0 || trueIf you find that a setting still needs to switch with the theme, teach the hooks about that key explicitly — open the hook scripts in Step 7 and Step 8 and add another set_ini_key call for GTK/Qt-style INI files or another focused jq update for JSON files. Extend the hook deliberately instead of replacing whole application configs.
Step 1: Install Prerequisites
darkman and Portal Infrastructure
sudo pacman -S darkman xdg-desktop-portal xdg-desktop-portal-gtk jq ruby libnotify dunst
sudo pacman -S xorg-xrdb
systemctl --user enable --now darkman.servicejqis needed for patching JSON settings;rubyis used for YAML-awarejellyfin-tuitheme updates.libnotifyenables notifications vianotify-send;dunstis the notification daemon reloaded by the hooks.xorg-xrdbprovidesxrdbfor applying Xresources.
Configure xdg-desktop-portal to use darkman (REQUIRED)
This is the single most overlooked step in dark-mode setups. Since xdg-desktop-portal 1.17.0, the portal must be told which backend implements the Settings interface — otherwise Firefox, Thunderbird, and other portal-aware apps will never see darkman’s color-scheme value, no matter how correctly darkman itself is configured.
Create ~/.config/xdg-desktop-portal/portals.conf:
mkdir -p ~/.config/xdg-desktop-portal
cat > ~/.config/xdg-desktop-portal/portals.conf <<'EOF'
[preferred]
default=gtk
org.freedesktop.impl.portal.Settings=darkman
EOFThe default=gtk line matters: darkman only implements the Settings portal. Without a default backend, a user-level portals.conf can accidentally disable other portal interfaces such as file choosers.
You can verify the portal really hands darkman’s value to clients with:
gdbus call --session \
--dest org.freedesktop.portal.Desktop \
--object-path /org/freedesktop/portal/desktop \
--method org.freedesktop.portal.Settings.ReadOne \
org.freedesktop.appearance color-schemeThis should return a GVariant containing 1 (dark) or 2 (light), for example (<uint32 1>,). If it returns 0 (“no preference”) or fails, restart xdg-desktop-portal.service and verify that your user-level portals.conf is being read.
systemctl --user restart xdg-desktop-portal.serviceFirefox-side counterpart: Current Firefox builds use
widget.use-xdg-desktop-portal.settings = 2as “auto”. That usually works for the Settings portal. If Firefox does not pick up the portal even though thegdbuscheck is correct, set this preference to1to force portal usage. This can still require restarting Firefox and does not guarantee that every browser chrome element repaints live.
For dotfile-managed systems, the forced setting can be made persistent in the active Firefox profile:
// ~/.mozilla/firefox/<profile>/user.js
user_pref("widget.use-xdg-desktop-portal.settings", 1);GTK Theme
Dracula is not in the official Arch repository. You will need an AUR helper like yay or paru:
yay -S dracula-gtk-theme
# or: paru -S dracula-gtk-themeIf you prefer not to use an AUR helper, the setup will automatically fall back to Adwaita-dark and Adwaita respectively (see hook script).
Qt Control (for Phase 3)
sudo pacman -S qt5ct qt6ct kvantumi3 Keybinding
Add the following keybinding to your i3 configuration file ~/.config/i3/config:
bindsym $mod+Shift+d exec --no-startup-id darkman toggleStep 2: Create Directory Structure
A clean directory structure is crucial for maintainability:
mkdir -p ~/.local/share/dark-mode.d
mkdir -p ~/.local/share/light-mode.d
mkdir -p ~/.config/themes/{dracula,solarized-light}
mkdir -p ~/.config/qt5ct/colors ~/.config/qt6ct/colorsIn the ~/.config/themes/ directory, theme-specific files for each application will be stored. The hook scripts will then create symlinks to most currently desired theme files. Alacritty is the exception: its imported theme file is rewritten as a regular file to make live reload more reliable.
Step 3: Prepare Qt Apps (KeePassXC, nheko)
Qt applications do not automatically follow GTK configuration or the XDG-Portal. You usually need to set the QT_QPA_PLATFORMTHEME environment variable before Qt applications are started.
Where to put the variable depends on how you start X:
- Display managers such as LightDM, LXDM, SDDM, and GDM usually source
~/.xprofile. startx/xinitsetups only get~/.xprofileif your~/.xinitrcsources it beforeexec i3.~/.config/environment.d/is read bysystemd --user, but it does not automatically update the environment of an already-started X session.
For an i3 + X11 setup, use one global baseline first:
# ~/.xprofile
export QT_QPA_PLATFORMTHEME=qt5ct:qt6ctIf you use startx, make sure ~/.xinitrc contains this before exec i3:
[ -f ~/.xprofile ] && . ~/.xprofileQt6 caveat: If Qt6 apps ignore theming (e.g., KeePassXC remains unstyled), test the app with
qt6ctexplicitly:QT_QPA_PLATFORMTHEME=qt6ct keepassxcOn current Arch Qt packages, the combined
qt5ct:qt6ctvalue lets Qt5 and Qt6 applications pick their matching platform theme plugin. If an individual app still ignores it, use a per-app wrapper or desktop file for that application.
For KeePassXC specifically, set View → Theme → Automatic once inside the application. On a portal-aware setup this allows KeePassXC to follow the dark/light switch live; a hardcoded ApplicationTheme=dark in ~/.config/keepassxc/keepassxc.ini will override the system value and make Light mode look broken.
The tray icon is a separate setting with the same trap: the monochrome variants (TrayIconAppearance=monochrome-dark/monochrome-light) are fixed to one color and disappear on one of the two bar backgrounds. The hooks set TrayIconAppearance=colorful, which is readable in both modes — it takes effect on the next KeePassXC start (the hooks never restart KeePassXC automatically, since that would lock your database).
Last-resort fallback: per-app wrapper
If neither global setup gets a stubborn Qt6 app to pick up the theme, set the variable just for that one app:
QT_QPA_PLATFORMTHEME=qt6ct keepassxcYou can wrap this in a small shell script and place it in ~/.local/bin/, or make a desktop file with the env-var prepended in Exec=. Inelegant, but reliable when the global escalation chain fails.
Obtain Qt Color Schemes
Qt color schemes are stored as .conf files under ~/.config/qt5ct/colors/ and ~/.config/qt6ct/colors/. Do not store them under /usr/share/qt5ct/colors/, as these directories are reserved for system defaults and can be overwritten by Pacman updates.
For Dracula, an official Qt5ct color scheme exists in the Dracula project:
curl -fsSL https://raw.githubusercontent.com/dracula/qt5/master/Dracula.conf \
-o ~/.config/qt5ct/colors/Dracula.conf
cp ~/.config/qt5ct/colors/Dracula.conf ~/.config/qt6ct/colors/Dracula.confFor Solarized Light, no official Qt5ct color scheme exists. The most reliable approach is to create one once via the GUI:
- Run
qt5ctfrom a terminal. - Tab “Style” → next to “Color scheme” click “Edit color scheme” → “Create”.
- Set Window Background
#fdf6e3, Window Text#586e75, Base#fdf6e3, Highlight#268bd2, etc. – the eight Solarized base values are documented at ethanschoonover.com/solarized. - Save as
SolarizedLight.confunder~/.config/qt5ct/colors/. - Copy the file to
~/.config/qt6ct/colors/SolarizedLight.conf.
Why not use a third-party Solarized Qt scheme? Various community ports exist, but their quality and maintenance vary, and the
qt5ct.confformat is fairly strict. A one-time GUI creation gives you a result that exactly matches your other Solarized apps, without surprises.
Step 4: Split i3 Config
The i3 configuration cannot be recolored at runtime. You must switch it and reload.
Option A: include file (recommended, from i3 4.20)
Add the following line to ~/.config/i3/config:
include ~/.config/i3/theme.confThen create two theme-specific configuration files. Minimal working examples:
# Dracula i3 theme
cat > ~/.config/themes/dracula/i3-theme.conf <<'EOF'
# class border bground text indicator child_border
client.focused #ff5555 #44475a #f8f8f2 #f1fa8c #ff5555
client.focused_inactive #44475a #44475a #f8f8f2 #44475a #44475a
client.unfocused #282a36 #282a36 #bfbfbf #282a36 #282a36
client.urgent #ffb86c #ffb86c #282a36 #ffb86c #ffb86c
client.placeholder #282a36 #282a36 #f8f8f2 #282a36 #282a36
client.background #282a36
EOF
# Solarized Light i3 theme
cat > ~/.config/themes/solarized-light/i3-theme.conf <<'EOF'
# class border bground text indicator child_border
client.focused #dc322f #eee8d5 #073642 #875f00 #dc322f
client.focused_inactive #93a1a1 #eee8d5 #586e75 #93a1a1 #93a1a1
client.unfocused #fdf6e3 #fdf6e3 #93a1a1 #fdf6e3 #fdf6e3
client.urgent #cb4b16 #cb4b16 #fdf6e3 #cb4b16 #cb4b16
client.placeholder #fdf6e3 #fdf6e3 #586e75 #fdf6e3 #fdf6e3
client.background #fdf6e3
EOFThe hook script will then symlink the correct file to ~/.config/i3/theme.conf and execute i3-msg reload.
The focused border is deliberately a strong signal red (#ff5555 / #dc322f): with muted slate or blue tones it is genuinely hard to tell which window has focus, in both modes. Urgent windows move to orange so the two states stay distinguishable. Pair this with a visible border width in your main config — with default_border pixel 1 even a red border is easy to miss:
default_border pixel 3Option B: Symlink the entire config
If you prefer not to use the include directive, you can symlink the entire i3 configuration file. This is less flexible but also works.
Step 5: Integrate Polybar
Polybar loads its configuration at startup. There are two ways to restart Polybar:
Option 1: polybar-msg cmd restart (clean, from Polybar 3.6)
polybar-msg cmd restartThis requires enable-ipc = true in every [bar/...] section. Without it, polybar-msg fails and the hooks silently fall back to the kill-and-relaunch path on every toggle — which is exactly the path with intermittent failure modes (see below).
[bar/mybar]
enable-ipc = trueOption 2: pkill + Launch Script (universal)
pkill polybar
sleep 0.3
~/.config/polybar/launch.shMinimal theme files
You’ll already have a working Polybar configuration from your existing setup. Depending on the age of the setup, it may be ~/.config/polybar/config, ~/.config/polybar/config.ini, or started with an explicit -c path. The safest approach is to keep that bar layout intact, extract only the colors into theme-specific files, and include the active palette.
# Dracula Polybar palette
cat > ~/.config/themes/dracula/polybar.ini <<'EOF'
[colors]
background = #282a36
background-alt = #44475a
foreground = #f8f8f2
foreground-alt = #6272a4
primary = #bd93f9
secondary = #8be9fd
alert = #ff5555
disabled = #6272a4
EOF
# Solarized Light Polybar palette
cat > ~/.config/themes/solarized-light/polybar.ini <<'EOF'
[colors]
background = #fdf6e3
background-alt = #eee8d5
foreground = #586e75
foreground-alt = #93a1a1
primary = #268bd2
secondary = #2aa198
alert = #dc322f
disabled = #93a1a1
EOFIn your main Polybar config, reference the colors via:
include-file = ~/.config/polybar/colors.iniThen remove or replace your old [colors] section, so the included file becomes the single source of color values. The hook script below will symlink ~/.config/polybar/colors.ini to one of the two palette files. If your setup deliberately keeps colors and bar layout in one file, you can instead maintain two full Polybar config files — but then adjust the hook paths accordingly. Do not blindly symlink config.ini on systems where Polybar actually reads ~/.config/polybar/config.
Also review your launch.sh: it should only start bars that are actually defined and whose monitors are connected. If you use the legacy Polybar systray, keep tray-position = right on exactly one active bar and leave secondary bars at tray-position = none. Do not accidentally replace an existing tray-enabled bar with none, or the small app/status icons will disappear after the next Polybar restart. When a hook starts Polybar from a service context, a detached launch such as setsid -f polybar mybar1 ... is more reliable than relying on interactive-shell disown.
One more tray pitfall: the icons inside the tray are drawn by the applets themselves (XEmbed), not by Polybar, and GTK applets load their icon theme once at startup. After a mode switch they keep the previous light/dark icon set — white icons from a dark icon theme become nearly invisible on a light bar. Plain i3 setups usually run no XSettings daemon that could propagate the change, so the hooks below simply restart the relevant applets (nm-applet, pasystray, flameshot); adjust the list to whatever actually sits in your tray.
Restarting alone is not enough if the icon theme itself has no light-bar variant. Panel/tray icons inside an icon theme have fixed colors: Papirus, for example, ships near-white (#dfdfdf) panel icons by design in both Papirus and Papirus-Dark — readable only on dark bars. For a light bar you must switch to the Papirus-Light variant (dark #444444 panel icons), so the hook’s icon-theme variable has to differ per mode (for example Papirus-Dark in the dark hook, Papirus-Light in the light hook). And apps with bundled monochrome tray icons ignore the icon theme entirely: the Nextcloud client’s mono icon assumes a dark bar, so the hooks set monoIcons=false in nextcloud.cfg — the colorful brand icon is readable in both modes and needs no per-mode restart.
Step 6: Chromium Flags
Chromium does not react to theme changes at runtime. It’s important to clearly separate two things:
- UI-Dark-Mode: Affects the browser interface (toolbar, tabs, menus).
- Webpage-Force-Dark: Forces a dark mode for every webpage. However, this can distort images, diagrams, and carefully designed pages.
Optional: fixed dark UI on next start
Chromium’s launcher reads ~/.config/chromium-flags.conf, but these flags apply only when Chromium starts. A permanently configured --force-dark-mode is therefore not a true light/dark toggle: it keeps Chromium dark even after the rest of the desktop switches back to light mode.
If you want a fixed dark Chromium UI, create the file ~/.config/chromium-flags.conf:
--force-dark-mode
--gtk-version=4--gtk-version=4 asks Chromium to use its GTK4 integration path on builds that support it. Test it on your version; Chromium command-line flags change over time.
Optional: Additionally force dark content for webpages
If you want to force dark mode for webpage content as well, add:
--enable-features=WebContentsForceDarkWarning: This flag name has changed multiple times across Chromium versions (
WebContentsForceDark,ForceWebContentsDarkMode, or only viachrome://flags). If it doesn’t work, checkchrome://flagsfor “Force Dark Mode for Web Contents” and enable it there. The recommendation remains: only enable if you’re comfortable with potential content distortion.
A true live toggle is not practical here without a browser restart. For that reason, the hook scripts below leave Chromium alone by default.
Step 7: The Dark-Mode Hook Script
This script will be executed when darkman switches to dark mode. Create the file ~/.local/share/dark-mode.d/apply-theme:
A note on hook directory layout: This guide uses
~/.local/share/dark-mode.d/and~/.local/share/light-mode.d/, which thedarkman(1)manpage describes as the legacy format (kept for backwards compatibility). The current format is a single~/.local/share/darkman/directory with one script that receives the mode as$1. Both formats work; the split-directory layout is used here because it makes the dark and light scripts easy to read side-by-side. If you want the modern single-script style, consolidate the two scripts and branch oncase "$1" in dark|light) ... esac.
#!/usr/bin/env bash
# Robustness: undefined vars are errors, but individual app errors
# should not abort the entire hook.
set -u
trap 'echo "Hook error in: $BASH_COMMAND" >&2' ERR
# darkman.service can start before the display manager has imported the X
# environment into the systemd user scope; without DISPLAY/XAUTHORITY every
# GUI applet relaunched by this hook crashes on startup and xrdb fails.
# (See Step 9 for the permanent session-side fix.)
[[ -n "${DISPLAY:-}" ]] || export DISPLAY=:0
if [[ -z "${XAUTHORITY:-}" ]]; then
for xauth_candidate in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/lyxauth" "$HOME/.Xauthority"; do
[[ -f "$xauth_candidate" ]] && export XAUTHORITY="$xauth_candidate" && break
done
fi
# Session variables from ~/.xprofile are not visible inside the service
# either; without the platform theme, relaunched Qt apps (nheko) skip
# qt5ct/qt6ct entirely and fall back to unthemed Qt defaults.
export QT_QPA_PLATFORMTHEME="${QT_QPA_PLATFORMTHEME:-qt5ct:qt6ct}"
THEME_DIR="$HOME/.config/themes/dracula"
GTK_THEME="Dracula"
# Official Dracula ANSI values — keep these identical to the Xresources and
# Alacritty theme files, or live-recolored and freshly started terminals will
# show two different palettes side by side.
DRACULA_ANSI_OSC=$'\033]4;0;#21222c\007\033]4;1;#ff5555\007\033]4;2;#50fa7b\007\033]4;3;#f1fa8c\007\033]4;4;#bd93f9\007\033]4;5;#ff79c6\007\033]4;6;#8be9fd\007\033]4;7;#f8f8f2\007\033]4;8;#6272a4\007\033]4;9;#ff6e6e\007\033]4;10;#69ff94\007\033]4;11;#ffffa5\007\033]4;12;#d6acff\007\033]4;13;#ff92df\007\033]4;14;#a4ffff\007\033]4;15;#ffffff\007'
set_ini_key() {
local config_file="$1"
local section="$2"
local key="$3"
local value="$4"
local tmp
mkdir -p "$(dirname "$config_file")"
[[ -f "$config_file" ]] || printf '[%s]\n' "$section" > "$config_file"
tmp=$(mktemp)
awk -v section="$section" -v key="$key" -v value="$value" '
BEGIN { in_section=0; section_found=0; key_done=0 }
$0 == "[" section "]" {
in_section=1; section_found=1; print; next
}
/^[[:space:]]*\[/ {
if (in_section && !key_done) {
print key "=" value
key_done=1
}
in_section=0
}
in_section && $0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
if (!key_done) {
print key "=" value
key_done=1
}
next
}
{ print }
END {
if (!section_found) {
print ""
print "[" section "]"
print key "=" value
} else if (in_section && !key_done) {
print key "=" value
}
}
' "$config_file" > "$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp"
}
write_alacritty_theme() {
local tmp
mkdir -p "$HOME/.config/alacritty"
tmp=$(mktemp "$HOME/.config/alacritty/theme.toml.XXXXXX")
install -m 0644 "$THEME_DIR/alacritty.toml" "$tmp"
mv -f "$tmp" "$HOME/.config/alacritty/theme.toml"
touch "$HOME/.config/alacritty/alacritty.toml"
}
set_dunst_theme() {
local config="$HOME/.config/dunst/dunstrc"
[[ -f "$config" ]] || return 0
set_ini_key "$config" global frame_color '"#bd93f9"'
set_ini_key "$config" global separator_color '"#44475a"'
set_ini_key "$config" global format "\"<span foreground='#f8f8f2'><b>%s %p</b></span>\\\\n%b\""
sed -i '/^%b"$/d' "$config"
set_ini_key "$config" urgency_low background '"#282a36"'
set_ini_key "$config" urgency_low foreground '"#f8f8f2"'
set_ini_key "$config" urgency_normal background '"#282a36"'
set_ini_key "$config" urgency_normal foreground '"#f8f8f2"'
set_ini_key "$config" urgency_critical background '"#ff5555"'
set_ini_key "$config" urgency_critical foreground '"#f8f8f2"'
set_ini_key "$config" urgency_critical frame_color '"#ff5555"'
command -v dunstctl >/dev/null 2>&1 && dunstctl reload "$config" >/dev/null 2>&1 || true
}
recolor_child_ttys() {
local terminal_comm="$1"
local bg="$2"
local fg="$3"
local cursor="$4"
local palette="${5:-}"
local tty
while IFS= read -r tty; do
[[ -n "$tty" && -w "/dev/$tty" ]] || continue
{
printf '\033]708;%s\007\033]11;%s\007\033]10;%s\007\033]12;%s\007' \
"$bg" "$bg" "$fg" "$cursor"
[[ -n "$palette" ]] && printf '%s' "$palette"
} > "/dev/$tty" 2>/dev/null || true
done < <(
ps -eo pid=,ppid=,tty=,comm= | awk -v terminal_comm="$terminal_comm" '
{
pid[NR]=$1; ppid[NR]=$2; tty[NR]=$3; comm[NR]=$4;
parent[$1]=$2;
if ($4 == terminal_comm) terminal[$1]=1;
}
END {
for (i=1; i<=NR; i++) {
p=ppid[i];
while (p && p != "1") {
if (p in terminal) {
if (tty[i] ~ /^pts\//) print tty[i];
break;
}
p=parent[p];
}
}
}
' | sort -u
)
}
redraw_tui_apps() {
local pid
command -v pgrep >/dev/null 2>&1 || return 0
while IFS= read -r pid; do
[[ -n "$pid" ]] && kill -WINCH "$pid" 2>/dev/null || true
done < <(pgrep -x neomutt 2>/dev/null || true)
while IFS= read -r pid; do
[[ -n "$pid" ]] && kill -WINCH "$pid" 2>/dev/null || true
done < <(pgrep -f '(^|[[:space:]])(/usr/bin/)?ikhal([[:space:]]|$)|(^|[[:space:]])khal[[:space:]]+interactive([[:space:]]|$)' 2>/dev/null || true)
}
# Restart a GUI app so its new window reappears exactly where the old one
# lived, on any workspace. Uses i3's native layout-restore mechanism: a
# placeholder container with swallow criteria is appended to the app's old
# workspace, and i3 materializes the next matching window directly inside
# it — no focus-dependent placement, no post-hoc moving. Apps that were
# not running are left closed.
restart_app_in_place() {
local proc="$1" class="$2"
local ws focused layout mark
pgrep -x "$proc" >/dev/null 2>&1 || return 0
mark="restore_${proc}"
ws=""
if command -v jq >/dev/null 2>&1 && command -v i3-msg >/dev/null 2>&1; then
ws=$(i3-msg -t get_tree 2>/dev/null | jq -r --arg c "$class" '
recurse(.nodes[]?, .floating_nodes[]?)
| select(.type? == "workspace") | . as $ws
| recurse(.nodes[]?, .floating_nodes[]?)
| select((.window_properties.class? // "") == $c)
| $ws.name' 2>/dev/null | head -n1)
fi
pkill -x "$proc" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
pgrep -x "$proc" >/dev/null 2>&1 || break
sleep 0.5
done
pkill -9 -x "$proc" 2>/dev/null || true
if [[ -n "$ws" ]]; then
layout=$(mktemp --suffix=.json)
printf '{"marks":["%s"],"swallows":[{"class":"^%s$"}]}\n' "$mark" "$class" > "$layout"
focused=$(i3-msg -t get_workspaces 2>/dev/null | jq -r '.[] | select(.focused) | .name')
i3-msg "unmark \"$mark\"" >/dev/null 2>&1
if [[ "$ws" == "$focused" ]]; then
i3-msg "append_layout $layout" >/dev/null 2>&1
else
i3-msg "workspace --no-auto-back-and-forth \"$ws\"; append_layout $layout; workspace --no-auto-back-and-forth \"$focused\"" >/dev/null 2>&1
fi
rm -f "$layout"
# Remove the empty placeholder if the app fails to come back.
(
sleep 25
pgrep -x "$proc" >/dev/null 2>&1 \
|| i3-msg "[con_mark=\"$mark\"] kill" >/dev/null 2>&1
) >/dev/null 2>&1 &
fi
command -v "$proc" >/dev/null 2>&1 && setsid -f "$proc" >/dev/null 2>&1
}
# Fallback if dracula-gtk-theme is not installed
if ! [[ -d /usr/share/themes/Dracula || -d ~/.themes/Dracula ]]; then
GTK_THEME="Adwaita-dark"
fi
# ─── GTK 3 / GTK 4 ──────────────────────────────────────────────
# Update only theme-related keys; preserve font, cursor, Xft and other local settings.
for gtk_file in ~/.config/gtk-3.0/settings.ini ~/.config/gtk-4.0/settings.ini; do
set_ini_key "$gtk_file" Settings gtk-theme-name "$GTK_THEME"
set_ini_key "$gtk_file" Settings gtk-icon-theme-name Adwaita
set_ini_key "$gtk_file" Settings gtk-application-prefer-dark-theme 1
done
# Also via gsettings (for apps that read this)
gsettings set org.gnome.desktop.interface gtk-theme "$GTK_THEME" 2>/dev/null || true
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' 2>/dev/null || true
# ─── Qt5 / Qt6 ──────────────────────────────────────────────────
# Redirect color scheme paths in user config, not /usr/share.
set_ini_key "$HOME/.config/qt5ct/qt5ct.conf" Appearance color_scheme_path "$HOME/.config/qt5ct/colors/Dracula.conf"
set_ini_key "$HOME/.config/qt5ct/qt5ct.conf" Appearance custom_palette true
set_ini_key "$HOME/.config/qt6ct/qt6ct.conf" Appearance color_scheme_path "$HOME/.config/qt6ct/colors/Dracula.conf"
set_ini_key "$HOME/.config/qt6ct/qt6ct.conf" Appearance custom_palette true
# Nextcloud client: the monochrome tray icon guesses light-on-dark and is
# unreadable on a light bar; the colorful brand icon works in both modes.
if [[ -f ~/.config/Nextcloud/nextcloud.cfg ]]; then
set_ini_key ~/.config/Nextcloud/nextcloud.cfg General monoIcons false
fi
# KeePassXC follows the portal/system switch live when its in-app theme is "Automatic".
if [[ -f ~/.config/keepassxc/keepassxc.ini ]]; then
set_ini_key ~/.config/keepassxc/keepassxc.ini GUI ApplicationTheme auto
# The monochrome tray variants are FIXED light or dark and vanish on
# one of the two bars; the colorful icon is readable in both modes.
# Applied on the next KeePassXC start (no auto-restart: open database).
set_ini_key ~/.config/keepassxc/keepassxc.ini GUI TrayIconAppearance colorful
fi
# nheko: keep theme=system. Its built-in light theme hardcodes a DARK
# sidebar (#233649, Element-style) — only theme=system derives the sidebar
# and scrollbars from the Qt palette, which qt6ct supplies per mode. A live
# palette switch leaves a running instance mixed, so restart it in place.
# theme=system is also what nheko writes back on exit, so the value
# survives its settings persistence without ordering tricks.
if [[ -f ~/.config/nheko/nheko.conf ]]; then
set_ini_key ~/.config/nheko/nheko.conf user theme system
restart_app_in_place nheko nheko
fi
# ─── khal / ikhal ───────────────────────────────────────────────
if [[ -f ~/.config/khal/config ]]; then
set_ini_key ~/.config/khal/config view theme dark
set_ini_key ~/.config/khal/config view frame width
set_ini_key ~/.config/khal/config highlight_days method fg
set_ini_key ~/.config/khal/config highlight_days multiple brown
# Days with events from exactly two calendars show both colors as
# half-and-half; three or more fall back to the "multiple" color.
set_ini_key ~/.config/khal/config highlight_days multiple_on_overflow True
# Empty color keeps per-calendar day colors in the month view; the
# [palette] overrides below remap them to adaptive ANSI accents.
set_ini_key ~/.config/khal/config highlight_days color ""
set_ini_key ~/.config/khal/config palette header "'', '', ''"
set_ini_key ~/.config/khal/config palette footer "'', '', ''"
set_ini_key ~/.config/khal/config palette "line header" "dark blue, '', bold"
set_ini_key ~/.config/khal/config palette "alt header" "dark blue, '', bold"
set_ini_key ~/.config/khal/config palette bright "dark blue, '', bold"
set_ini_key ~/.config/khal/config palette list "'', '', ''"
set_ini_key ~/.config/khal/config palette "list focused" "white, black, bold"
set_ini_key ~/.config/khal/config palette edit "'', '', ''"
set_ini_key ~/.config/khal/config palette "edit focus" "white, black, bold"
set_ini_key ~/.config/khal/config palette button "black, light gray, ''"
set_ini_key ~/.config/khal/config palette "button focused" "white, black, bold"
set_ini_key ~/.config/khal/config palette "reveal focus" "white, black, standout"
set_ini_key ~/.config/khal/config palette "today focus" "white, black, standout"
set_ini_key ~/.config/khal/config palette today "black, light gray, ''"
set_ini_key ~/.config/khal/config palette "date header" "light red, '', ''"
set_ini_key ~/.config/khal/config palette "date header focused" "white, black, bold"
set_ini_key ~/.config/khal/config palette "date header selected" "white, black, ''"
set_ini_key ~/.config/khal/config palette dayname "dark cyan, '', ''"
set_ini_key ~/.config/khal/config palette monthname "dark magenta, '', ''"
set_ini_key ~/.config/khal/config palette weeknumber_right "dark gray, '', ''"
set_ini_key ~/.config/khal/config palette alert "white, dark red, bold"
set_ini_key ~/.config/khal/config palette mark "white, dark gray, bold"
set_ini_key ~/.config/khal/config palette frame "dark gray, '', ''"
set_ini_key ~/.config/khal/config palette "frame focus" "dark blue, '', ''"
set_ini_key ~/.config/khal/config palette "frame focus color" "dark blue, '', ''"
set_ini_key ~/.config/khal/config palette "frame focus top" "dark magenta, '', ''"
set_ini_key ~/.config/khal/config palette eventcolumn "'', '', ''"
set_ini_key ~/.config/khal/config palette "eventcolumn focus" "'', '', ''"
set_ini_key ~/.config/khal/config palette calendar "'', '', ''"
# Default like khal's built-in themes: a color here washes over every
# plain day whenever the calendar column holds focus.
set_ini_key ~/.config/khal/config palette "calendar focus" "'', '', ''"
set_ini_key ~/.config/khal/config palette editbx "white, black, ''"
set_ini_key ~/.config/khal/config palette editcp "black, light gray, standout"
set_ini_key ~/.config/khal/config palette popupbg "'', '', ''"
set_ini_key ~/.config/khal/config palette popupper "white, black, bold"
set_ini_key ~/.config/khal/config palette caption "dark blue, '', bold"
# Per-calendar accents for month-view day numbers and event-list rows
# (both share the "calendar <name>" attributes, ikhal merges the config
# palette last): ANSI names track the terminal palette live, unlike
# static RGB values from vdir `color` files. The attribute names must
# match the discovered calendar names from `khal printcalendars`.
set_ini_key ~/.config/khal/config palette "calendar personal" "dark blue, '', ''"
set_ini_key ~/.config/khal/config palette "calendar work" "dark magenta, '', ''"
fi
# ─── Xresources / urxvt ─────────────────────────────────────────
ln -sf "$THEME_DIR/Xresources" ~/.Xresources
command -v xrdb >/dev/null 2>&1 && xrdb -load ~/.Xresources
# Recolor only PTSes whose process ancestry belongs to the terminal.
# This changes Background/Foreground/Cursor and the 16-color ANSI palette
# where the terminal supports OSC 4.
if [[ "${RECOLOR_URXVT_PTS:-1}" == "1" ]]; then
recolor_child_ttys "urxvt" "#282a36" "#f8f8f2" "#f8f8f2" "$DRACULA_ANSI_OSC"
fi
if [[ "${RECOLOR_ALACRITTY_PTS:-1}" == "1" ]]; then
recolor_child_ttys "alacritty" "#282a36" "#f8f8f2" "#f8f8f2" "$DRACULA_ANSI_OSC"
fi
redraw_tui_apps
# ─── Alacritty ──────────────────────────────────────────────────
write_alacritty_theme
# Alacritty reloads automatically with live_config_reload=true
# ─── i3 Theme ───────────────────────────────────────────────────
mkdir -p ~/.config/i3
ln -sf "$THEME_DIR/i3-theme.conf" ~/.config/i3/theme.conf
command -v i3-msg >/dev/null 2>&1 && i3-msg reload >/dev/null 2>&1 || true
# ─── Polybar ────────────────────────────────────────────────────
mkdir -p ~/.config/polybar
ln -sf "$THEME_DIR/polybar.ini" ~/.config/polybar/colors.ini
# IPC restart first (needs enable-ipc=true per bar); fall back to a kill
# that waits until the old instances are really gone, then verify the bars
# came up — polybar's monitor detection can fail transiently right after
# an i3 reload, which can otherwise leave the desktop without any bar.
restart_polybar() {
local i
if command -v polybar-msg >/dev/null 2>&1 && polybar-msg cmd restart >/dev/null 2>&1; then
sleep 1
pgrep -x polybar >/dev/null 2>&1 && return 0
fi
pkill -x polybar 2>/dev/null || true
for i in 1 2 3 4 5 6 7 8 9 10; do
pgrep -x polybar >/dev/null 2>&1 || break
sleep 0.2
done
if [[ -x ~/.config/polybar/launch.sh ]]; then
setsid -f ~/.config/polybar/launch.sh >/dev/null 2>&1
sleep 1
pgrep -x polybar >/dev/null 2>&1 \
|| setsid -f ~/.config/polybar/launch.sh >/dev/null 2>&1
fi
}
restart_polybar
# Fixed list of the applets that belong in this machine's tray. Tray icons
# are drawn by the applets (XEmbed) with the GTK icon theme they loaded at
# startup, so they must be restarted to match the new mode. Two pitfalls
# shape this function: XEmbed applets exit immediately when they dock into
# a tray that is still re-initializing after the Polybar restart (hence
# the delay and the safety net), and rapid consecutive toggles can kill
# applets a previous hook run just started (hence "ensure running" with a
# fixed list instead of "only restart what was running").
TRAY_APPS="nm-applet pasystray flameshot"
restart_tray_apps() {
local app
for app in $TRAY_APPS; do
pkill -x "$app" 2>/dev/null || true
done
sleep 2
for app in $TRAY_APPS; do
command -v "$app" >/dev/null 2>&1 || continue
pgrep -x "$app" >/dev/null 2>&1 || setsid -f "$app" >/dev/null 2>&1
done
# Safety net: relaunch anything that died docking too early.
(
sleep 5
for app in $TRAY_APPS; do
command -v "$app" >/dev/null 2>&1 || continue
pgrep -x "$app" >/dev/null 2>&1 || setsid -f "$app" >/dev/null 2>&1
done
) >/dev/null 2>&1 &
}
restart_tray_apps
# ─── Rofi ───────────────────────────────────────────────────────
# Rofi reads its theme on startup; existing menus are transient.
mkdir -p ~/.config/rofi
ln -sf "$THEME_DIR/rofi.rasi" ~/.config/rofi/theme.rasi
# ─── neomutt ────────────────────────────────────────────────────
mkdir -p ~/.config/neomutt
ln -sf "$THEME_DIR/mutt-colors" ~/.config/neomutt/colors
# Existing sessions repaint live after they have loaded this ANSI palette once.
# mutt-wizard style setups often use ~/.config/mutt/muttrc instead.
# Add `source ~/.config/mutt/theme.muttrc` there, then let the hook manage:
if [[ -d ~/.config/mutt ]]; then
ln -sf "$THEME_DIR/mutt-colors" ~/.config/mutt/theme.muttrc
fi
# ─── jellyfin-tui ───────────────────────────────────────────────
set_jellyfin_tui_preference() {
local theme="$1"
[[ -d "$HOME/.local/share/jellyfin-tui/preferences" ]] || return 0
command -v jq >/dev/null 2>&1 || return 0
for pref in "$HOME"/.local/share/jellyfin-tui/preferences/*.json; do
[[ -f "$pref" ]] || continue
tmp=$(mktemp)
jq --arg theme "$theme" '.theme = $theme' "$pref" > "$tmp" && mv "$tmp" "$pref" || rm -f "$tmp"
done
}
set_jellyfin_tui_config_theme() {
local mode="$1"
local config="$HOME/.config/jellyfin-tui/config.yaml"
[[ -f "$config" ]] || return 0
command -v ruby >/dev/null 2>&1 || return 0
ruby - "$config" "$mode" <<'RUBY'
require "yaml"
path, mode = ARGV
cfg = YAML.load_file(path) || {}
dark = {
"base" => "Dark",
"background" => "#282a36",
"foreground" => "#f8f8f2",
"foreground_secondary" => "#d7d8e8",
"foreground_dim" => "#a7abc4",
"foreground_disabled" => "#6f758f",
"section_title" => "#bd93f9",
"accent" => "#8be9fd",
"border" => "#4f5268",
"border_focused" => "#bd93f9",
"selected_active_background" => "#44475a",
"selected_active_foreground" => "#f8f8f2",
"selected_inactive_background" => "#343746",
"selected_inactive_foreground" => "#f8f8f2",
"scrollbar_thumb" => "#c6c8d6",
"scrollbar_track" => "#44475a",
"progress_fill" => "#ffb86c",
"progress_track" => "#44475a",
"tab_active_foreground" => "#bd93f9",
"tab_inactive_foreground" => "#7b819d",
"album_header_background" => "#343746",
"album_header_foreground" => "#8be9fd"
}
light = {
"base" => "Light",
"background" => "#fdf6e3",
"foreground" => "#073642",
"foreground_secondary" => "#25464d",
"foreground_dim" => "#405f66",
"foreground_disabled" => "#586e75",
"section_title" => "#005f87",
"accent" => "#005f87",
"border" => "#586e75",
"border_focused" => "#005f87",
"selected_active_background" => "#005f87",
"selected_active_foreground" => "#fdf6e3",
"selected_inactive_background" => "#d7ebe8",
"selected_inactive_foreground" => "#073642",
"scrollbar_thumb" => "#586e75",
"scrollbar_track" => "#eee8d5",
"progress_fill" => "#875f00",
"progress_track" => "#d6cab0",
"tab_active_foreground" => "#005f87",
"tab_inactive_foreground" => "#586e75",
"album_header_background" => "#eee8d5",
"album_header_foreground" => "#073642"
}
palette = mode == "light" ? light : dark
managed_names = ["Mode Switcher", "Dracula High Contrast", "Solarized Light High Contrast"]
themes = cfg["themes"].is_a?(Array) ? cfg["themes"] : []
unmanaged = themes.reject { |theme| theme.is_a?(Hash) && managed_names.include?(theme["name"]) }
cfg["auto_color"] = false
cfg["themes"] = managed_names.map { |name| { "name" => name }.merge(palette) } + unmanaged
content = YAML.dump(cfg)
if !File.exist?(path) || File.read(path) != content
mode_bits = File.exist?(path) ? File.stat(path).mode & 0o777 : 0o600
tmp = "#{path}.tmp.#{$$}"
File.write(tmp, content)
File.chmod(mode_bits, tmp)
File.rename(tmp, path)
end
RUBY
}
# jellyfin-tui live-reloads config.yaml, but not its preferences file.
# Keep one stable selected theme and rewrite that theme's colors per mode.
set_jellyfin_tui_preference "Mode Switcher"
set_jellyfin_tui_config_theme "dark"
if pgrep -x jellyfin-tui >/dev/null 2>&1; then
(
while pgrep -x jellyfin-tui >/dev/null 2>&1; do
sleep 0.5
done
set_jellyfin_tui_preference "Mode Switcher"
) >/dev/null 2>&1 &
fi
# ─── sonicradio ─────────────────────────────────────────────────
# Every built-in theme ships separate dark and light profiles, picked from
# the terminal background at startup. A constant high-contrast theme works
# for both modes and survives sonicradio's config write-back on exit.
if [[ -f ~/.config/sonicRadio/config.json ]] && command -v jq >/dev/null 2>&1; then
tmp=$(mktemp)
jq '.theme = 4' ~/.config/sonicRadio/config.json > "$tmp" \
&& mv "$tmp" ~/.config/sonicRadio/config.json \
|| rm -f "$tmp"
fi
# ─── VSCodium ───────────────────────────────────────────────────
# Patch settings.json. VSCodium usually reacts live via file watcher,
# in rare cases a "Reload Window" (Ctrl+Shift+P) is needed.
SETTINGS="$HOME/.config/VSCodium/User/settings.json"
if [[ -f "$SETTINGS" ]] && command -v jq >/dev/null 2>&1; then
tmp=$(mktemp)
jq '."workbench.colorTheme" = "Dracula"' "$SETTINGS" > "$tmp" \
&& mv "$tmp" "$SETTINGS" \
|| rm -f "$tmp"
fi
# ─── Claude Code ────────────────────────────────────────────────
# Claude Code paints its UI with truecolor values from its own theme and
# ignores the terminal palette. Running sessions keep their theme until
# restarted (or changed in-session via /config).
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
if [[ -f "$CLAUDE_SETTINGS" ]] && command -v jq >/dev/null 2>&1; then
tmp=$(mktemp)
jq '.theme = "dark"' "$CLAUDE_SETTINGS" > "$tmp" && mv "$tmp" "$CLAUDE_SETTINGS" || rm -f "$tmp"
fi
# ─── Vim/Neovim (if used) ───────────────────────────────────────
mkdir -p ~/.config/nvim
ln -sf "$THEME_DIR/nvim-colorscheme.vim" ~/.config/nvim/colorscheme.vim 2>/dev/null || true
# ─── Firefox / Thunderbird ──────────────────────────────────────
# Nothing to do in the hook: the portal value is updated by darkman.
# Firefox default "auto" is usually enough; force
# widget.use-xdg-desktop-portal.settings = 1 only if auto does not work.
# Full browser UI repainting may still require a Firefox restart.
# ─── Chromium ───────────────────────────────────────────────────
# Does not react live; flags in ~/.config/chromium-flags.conf take effect
# on next start. See Step 6.
# ─── User Notification ──────────────────────────────────────────
set_dunst_theme
notify-send -i weather-clear-night "Theme" "Dark Mode activated (Dracula)" 2>/dev/null || true
echo "Dark mode activated (Dracula)"Make the script executable:
chmod +x ~/.local/share/dark-mode.d/apply-themeStep 8: The Light-Mode Hook Script
This script will be executed when darkman switches to light mode. Create the file ~/.local/share/light-mode.d/apply-theme:
#!/usr/bin/env bash
set -u
trap 'echo "Hook error in: $BASH_COMMAND" >&2' ERR
# darkman.service can start before the display manager has imported the X
# environment into the systemd user scope; without DISPLAY/XAUTHORITY every
# GUI applet relaunched by this hook crashes on startup and xrdb fails.
# (See Step 9 for the permanent session-side fix.)
[[ -n "${DISPLAY:-}" ]] || export DISPLAY=:0
if [[ -z "${XAUTHORITY:-}" ]]; then
for xauth_candidate in "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/lyxauth" "$HOME/.Xauthority"; do
[[ -f "$xauth_candidate" ]] && export XAUTHORITY="$xauth_candidate" && break
done
fi
# Session variables from ~/.xprofile are not visible inside the service
# either; without the platform theme, relaunched Qt apps (nheko) skip
# qt5ct/qt6ct entirely and fall back to unthemed Qt defaults.
export QT_QPA_PLATFORMTHEME="${QT_QPA_PLATFORMTHEME:-qt5ct:qt6ct}"
THEME_DIR="$HOME/.config/themes/solarized-light"
GTK_THEME="Adwaita"
SOLARIZED_LIGHT_ANSI_OSC=$'\033]4;0;#073642\007\033]4;1;#dc322f\007\033]4;2;#5f8700\007\033]4;3;#875f00\007\033]4;4;#005f87\007\033]4;5;#af005f\007\033]4;6;#006c6b\007\033]4;7;#eee8d5\007\033]4;8;#002b36\007\033]4;9;#cb4b16\007\033]4;10;#586e00\007\033]4;11;#6c5a00\007\033]4;12;#00629d\007\033]4;13;#6c71c4\007\033]4;14;#00736f\007\033]4;15;#fdf6e3\007'
set_ini_key() {
local config_file="$1"
local section="$2"
local key="$3"
local value="$4"
local tmp
mkdir -p "$(dirname "$config_file")"
[[ -f "$config_file" ]] || printf '[%s]\n' "$section" > "$config_file"
tmp=$(mktemp)
awk -v section="$section" -v key="$key" -v value="$value" '
BEGIN { in_section=0; section_found=0; key_done=0 }
$0 == "[" section "]" {
in_section=1; section_found=1; print; next
}
/^[[:space:]]*\[/ {
if (in_section && !key_done) {
print key "=" value
key_done=1
}
in_section=0
}
in_section && $0 ~ "^[[:space:]]*" key "[[:space:]]*=" {
if (!key_done) {
print key "=" value
key_done=1
}
next
}
{ print }
END {
if (!section_found) {
print ""
print "[" section "]"
print key "=" value
} else if (in_section && !key_done) {
print key "=" value
}
}
' "$config_file" > "$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp"
}
write_alacritty_theme() {
local tmp
mkdir -p "$HOME/.config/alacritty"
tmp=$(mktemp "$HOME/.config/alacritty/theme.toml.XXXXXX")
install -m 0644 "$THEME_DIR/alacritty.toml" "$tmp"
mv -f "$tmp" "$HOME/.config/alacritty/theme.toml"
touch "$HOME/.config/alacritty/alacritty.toml"
}
recolor_child_ttys() {
local terminal_comm="$1"
local bg="$2"
local fg="$3"
local cursor="$4"
local palette="${5:-}"
local tty
while IFS= read -r tty; do
[[ -n "$tty" && -w "/dev/$tty" ]] || continue
{
printf '\033]708;%s\007\033]11;%s\007\033]10;%s\007\033]12;%s\007' \
"$bg" "$bg" "$fg" "$cursor"
[[ -n "$palette" ]] && printf '%s' "$palette"
} > "/dev/$tty" 2>/dev/null || true
done < <(
ps -eo pid=,ppid=,tty=,comm= | awk -v terminal_comm="$terminal_comm" '
{
pid[NR]=$1; ppid[NR]=$2; tty[NR]=$3; comm[NR]=$4;
parent[$1]=$2;
if ($4 == terminal_comm) terminal[$1]=1;
}
END {
for (i=1; i<=NR; i++) {
p=ppid[i];
while (p && p != "1") {
if (p in terminal) {
if (tty[i] ~ /^pts\//) print tty[i];
break;
}
p=parent[p];
}
}
}
' | sort -u
)
}
redraw_tui_apps() {
local pid
command -v pgrep >/dev/null 2>&1 || return 0
while IFS= read -r pid; do
[[ -n "$pid" ]] && kill -WINCH "$pid" 2>/dev/null || true
done < <(pgrep -x neomutt 2>/dev/null || true)
while IFS= read -r pid; do
[[ -n "$pid" ]] && kill -WINCH "$pid" 2>/dev/null || true
done < <(pgrep -f '(^|[[:space:]])(/usr/bin/)?ikhal([[:space:]]|$)|(^|[[:space:]])khal[[:space:]]+interactive([[:space:]]|$)' 2>/dev/null || true)
}
# Restart a GUI app so its new window reappears exactly where the old one
# lived, on any workspace. Uses i3's native layout-restore mechanism: a
# placeholder container with swallow criteria is appended to the app's old
# workspace, and i3 materializes the next matching window directly inside
# it — no focus-dependent placement, no post-hoc moving. Apps that were
# not running are left closed.
restart_app_in_place() {
local proc="$1" class="$2"
local ws focused layout mark
pgrep -x "$proc" >/dev/null 2>&1 || return 0
mark="restore_${proc}"
ws=""
if command -v jq >/dev/null 2>&1 && command -v i3-msg >/dev/null 2>&1; then
ws=$(i3-msg -t get_tree 2>/dev/null | jq -r --arg c "$class" '
recurse(.nodes[]?, .floating_nodes[]?)
| select(.type? == "workspace") | . as $ws
| recurse(.nodes[]?, .floating_nodes[]?)
| select((.window_properties.class? // "") == $c)
| $ws.name' 2>/dev/null | head -n1)
fi
pkill -x "$proc" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
pgrep -x "$proc" >/dev/null 2>&1 || break
sleep 0.5
done
pkill -9 -x "$proc" 2>/dev/null || true
if [[ -n "$ws" ]]; then
layout=$(mktemp --suffix=.json)
printf '{"marks":["%s"],"swallows":[{"class":"^%s$"}]}\n' "$mark" "$class" > "$layout"
focused=$(i3-msg -t get_workspaces 2>/dev/null | jq -r '.[] | select(.focused) | .name')
i3-msg "unmark \"$mark\"" >/dev/null 2>&1
if [[ "$ws" == "$focused" ]]; then
i3-msg "append_layout $layout" >/dev/null 2>&1
else
i3-msg "workspace --no-auto-back-and-forth \"$ws\"; append_layout $layout; workspace --no-auto-back-and-forth \"$focused\"" >/dev/null 2>&1
fi
rm -f "$layout"
# Remove the empty placeholder if the app fails to come back.
(
sleep 25
pgrep -x "$proc" >/dev/null 2>&1 \
|| i3-msg "[con_mark=\"$mark\"] kill" >/dev/null 2>&1
) >/dev/null 2>&1 &
fi
command -v "$proc" >/dev/null 2>&1 && setsid -f "$proc" >/dev/null 2>&1
}
set_dunst_theme() {
local config="$HOME/.config/dunst/dunstrc"
[[ -f "$config" ]] || return 0
set_ini_key "$config" global frame_color '"#005f87"'
set_ini_key "$config" global separator_color '"#93a1a1"'
set_ini_key "$config" global format "\"<span foreground='#073642'><b>%s %p</b></span>\\\\n%b\""
sed -i '/^%b"$/d' "$config"
set_ini_key "$config" urgency_low background '"#fdf6e3"'
set_ini_key "$config" urgency_low foreground '"#073642"'
set_ini_key "$config" urgency_normal background '"#fdf6e3"'
set_ini_key "$config" urgency_normal foreground '"#073642"'
set_ini_key "$config" urgency_critical background '"#dc322f"'
set_ini_key "$config" urgency_critical foreground '"#fdf6e3"'
set_ini_key "$config" urgency_critical frame_color '"#dc322f"'
command -v dunstctl >/dev/null 2>&1 && dunstctl reload "$config" >/dev/null 2>&1 || true
}
# ─── GTK 3 / GTK 4 ──────────────────────────────────────────────
# Update only theme-related keys; preserve font, cursor, Xft and other local settings.
for gtk_file in ~/.config/gtk-3.0/settings.ini ~/.config/gtk-4.0/settings.ini; do
set_ini_key "$gtk_file" Settings gtk-theme-name "$GTK_THEME"
set_ini_key "$gtk_file" Settings gtk-icon-theme-name Adwaita
set_ini_key "$gtk_file" Settings gtk-application-prefer-dark-theme 0
done
gsettings set org.gnome.desktop.interface gtk-theme "$GTK_THEME" 2>/dev/null || true
gsettings set org.gnome.desktop.interface color-scheme 'prefer-light' 2>/dev/null || true
# ─── Qt5 / Qt6 ──────────────────────────────────────────────────
set_ini_key "$HOME/.config/qt5ct/qt5ct.conf" Appearance color_scheme_path "$HOME/.config/qt5ct/colors/SolarizedLight.conf"
set_ini_key "$HOME/.config/qt5ct/qt5ct.conf" Appearance custom_palette true
set_ini_key "$HOME/.config/qt6ct/qt6ct.conf" Appearance color_scheme_path "$HOME/.config/qt6ct/colors/SolarizedLight.conf"
set_ini_key "$HOME/.config/qt6ct/qt6ct.conf" Appearance custom_palette true
# Nextcloud client: the monochrome tray icon guesses light-on-dark and is
# unreadable on a light bar; the colorful brand icon works in both modes.
if [[ -f ~/.config/Nextcloud/nextcloud.cfg ]]; then
set_ini_key ~/.config/Nextcloud/nextcloud.cfg General monoIcons false
fi
# KeePassXC follows the portal/system switch live when its in-app theme is "Automatic".
if [[ -f ~/.config/keepassxc/keepassxc.ini ]]; then
set_ini_key ~/.config/keepassxc/keepassxc.ini GUI ApplicationTheme auto
# The monochrome tray variants are FIXED light or dark and vanish on
# one of the two bars; the colorful icon is readable in both modes.
# Applied on the next KeePassXC start (no auto-restart: open database).
set_ini_key ~/.config/keepassxc/keepassxc.ini GUI TrayIconAppearance colorful
fi
# nheko: keep theme=system. Its built-in light theme hardcodes a DARK
# sidebar (#233649, Element-style) — only theme=system derives the sidebar
# and scrollbars from the Qt palette, which qt6ct supplies per mode. A live
# palette switch leaves a running instance mixed, so restart it in place.
# theme=system is also what nheko writes back on exit, so the value
# survives its settings persistence without ordering tricks.
if [[ -f ~/.config/nheko/nheko.conf ]]; then
set_ini_key ~/.config/nheko/nheko.conf user theme system
restart_app_in_place nheko nheko
fi
# ─── khal / ikhal ───────────────────────────────────────────────
if [[ -f ~/.config/khal/config ]]; then
set_ini_key ~/.config/khal/config view theme light
set_ini_key ~/.config/khal/config view frame width
set_ini_key ~/.config/khal/config highlight_days method fg
set_ini_key ~/.config/khal/config highlight_days multiple brown
# Days with events from exactly two calendars show both colors as
# half-and-half; three or more fall back to the "multiple" color.
set_ini_key ~/.config/khal/config highlight_days multiple_on_overflow True
# Empty color keeps per-calendar day colors in the month view; the
# [palette] overrides below remap them to adaptive ANSI accents.
set_ini_key ~/.config/khal/config highlight_days color ""
set_ini_key ~/.config/khal/config palette header "'', '', ''"
set_ini_key ~/.config/khal/config palette footer "'', '', ''"
set_ini_key ~/.config/khal/config palette "line header" "dark blue, '', bold"
set_ini_key ~/.config/khal/config palette "alt header" "dark blue, '', bold"
set_ini_key ~/.config/khal/config palette bright "dark blue, '', bold"
set_ini_key ~/.config/khal/config palette list "'', '', ''"
set_ini_key ~/.config/khal/config palette "list focused" "white, black, bold"
set_ini_key ~/.config/khal/config palette edit "'', '', ''"
set_ini_key ~/.config/khal/config palette "edit focus" "white, black, bold"
set_ini_key ~/.config/khal/config palette button "black, light gray, ''"
set_ini_key ~/.config/khal/config palette "button focused" "white, black, bold"
set_ini_key ~/.config/khal/config palette "reveal focus" "white, black, standout"
set_ini_key ~/.config/khal/config palette "today focus" "white, black, standout"
set_ini_key ~/.config/khal/config palette today "black, light gray, ''"
set_ini_key ~/.config/khal/config palette "date header" "light red, '', ''"
set_ini_key ~/.config/khal/config palette "date header focused" "white, black, bold"
set_ini_key ~/.config/khal/config palette "date header selected" "white, black, ''"
set_ini_key ~/.config/khal/config palette dayname "dark cyan, '', ''"
set_ini_key ~/.config/khal/config palette monthname "dark magenta, '', ''"
set_ini_key ~/.config/khal/config palette weeknumber_right "dark gray, '', ''"
set_ini_key ~/.config/khal/config palette alert "white, dark red, bold"
set_ini_key ~/.config/khal/config palette mark "white, dark gray, bold"
set_ini_key ~/.config/khal/config palette frame "dark gray, '', ''"
set_ini_key ~/.config/khal/config palette "frame focus" "dark blue, '', ''"
set_ini_key ~/.config/khal/config palette "frame focus color" "dark blue, '', ''"
set_ini_key ~/.config/khal/config palette "frame focus top" "dark magenta, '', ''"
set_ini_key ~/.config/khal/config palette eventcolumn "'', '', ''"
set_ini_key ~/.config/khal/config palette "eventcolumn focus" "'', '', ''"
set_ini_key ~/.config/khal/config palette calendar "'', '', ''"
# Default like khal's built-in themes: a color here washes over every
# plain day whenever the calendar column holds focus.
set_ini_key ~/.config/khal/config palette "calendar focus" "'', '', ''"
set_ini_key ~/.config/khal/config palette editbx "white, black, ''"
set_ini_key ~/.config/khal/config palette editcp "black, light gray, standout"
set_ini_key ~/.config/khal/config palette popupbg "'', '', ''"
set_ini_key ~/.config/khal/config palette popupper "white, black, bold"
set_ini_key ~/.config/khal/config palette caption "dark blue, '', bold"
# Per-calendar accents for month-view day numbers and event-list rows
# (both share the "calendar <name>" attributes, ikhal merges the config
# palette last): ANSI names track the terminal palette live, unlike
# static RGB values from vdir `color` files. The attribute names must
# match the discovered calendar names from `khal printcalendars`.
set_ini_key ~/.config/khal/config palette "calendar personal" "dark blue, '', ''"
set_ini_key ~/.config/khal/config palette "calendar work" "dark magenta, '', ''"
fi
# ─── Xresources / urxvt ─────────────────────────────────────────
ln -sf "$THEME_DIR/Xresources" ~/.Xresources
command -v xrdb >/dev/null 2>&1 && xrdb -load ~/.Xresources
# Recolor only PTSes whose process ancestry belongs to the terminal.
# This changes Background/Foreground/Cursor and the 16-color ANSI palette
# where the terminal supports OSC 4.
if [[ "${RECOLOR_URXVT_PTS:-1}" == "1" ]]; then
recolor_child_ttys "urxvt" "#fdf6e3" "#073642" "#586e75" "$SOLARIZED_LIGHT_ANSI_OSC"
fi
if [[ "${RECOLOR_ALACRITTY_PTS:-1}" == "1" ]]; then
recolor_child_ttys "alacritty" "#fdf6e3" "#073642" "#586e75" "$SOLARIZED_LIGHT_ANSI_OSC"
fi
redraw_tui_apps
# ─── Alacritty ──────────────────────────────────────────────────
write_alacritty_theme
# ─── i3 ─────────────────────────────────────────────────────────
mkdir -p ~/.config/i3
ln -sf "$THEME_DIR/i3-theme.conf" ~/.config/i3/theme.conf
command -v i3-msg >/dev/null 2>&1 && i3-msg reload >/dev/null 2>&1 || true
# ─── Polybar ────────────────────────────────────────────────────
mkdir -p ~/.config/polybar
ln -sf "$THEME_DIR/polybar.ini" ~/.config/polybar/colors.ini
# IPC restart first (needs enable-ipc=true per bar); fall back to a kill
# that waits until the old instances are really gone, then verify the bars
# came up — polybar's monitor detection can fail transiently right after
# an i3 reload, which can otherwise leave the desktop without any bar.
restart_polybar() {
local i
if command -v polybar-msg >/dev/null 2>&1 && polybar-msg cmd restart >/dev/null 2>&1; then
sleep 1
pgrep -x polybar >/dev/null 2>&1 && return 0
fi
pkill -x polybar 2>/dev/null || true
for i in 1 2 3 4 5 6 7 8 9 10; do
pgrep -x polybar >/dev/null 2>&1 || break
sleep 0.2
done
if [[ -x ~/.config/polybar/launch.sh ]]; then
setsid -f ~/.config/polybar/launch.sh >/dev/null 2>&1
sleep 1
pgrep -x polybar >/dev/null 2>&1 \
|| setsid -f ~/.config/polybar/launch.sh >/dev/null 2>&1
fi
}
restart_polybar
# Fixed list of the applets that belong in this machine's tray. Tray icons
# are drawn by the applets (XEmbed) with the GTK icon theme they loaded at
# startup, so they must be restarted to match the new mode. Two pitfalls
# shape this function: XEmbed applets exit immediately when they dock into
# a tray that is still re-initializing after the Polybar restart (hence
# the delay and the safety net), and rapid consecutive toggles can kill
# applets a previous hook run just started (hence "ensure running" with a
# fixed list instead of "only restart what was running").
TRAY_APPS="nm-applet pasystray flameshot"
restart_tray_apps() {
local app
for app in $TRAY_APPS; do
pkill -x "$app" 2>/dev/null || true
done
sleep 2
for app in $TRAY_APPS; do
command -v "$app" >/dev/null 2>&1 || continue
pgrep -x "$app" >/dev/null 2>&1 || setsid -f "$app" >/dev/null 2>&1
done
# Safety net: relaunch anything that died docking too early.
(
sleep 5
for app in $TRAY_APPS; do
command -v "$app" >/dev/null 2>&1 || continue
pgrep -x "$app" >/dev/null 2>&1 || setsid -f "$app" >/dev/null 2>&1
done
) >/dev/null 2>&1 &
}
restart_tray_apps
# ─── Rofi ───────────────────────────────────────────────────────
# Rofi reads its theme on startup; existing menus are transient.
mkdir -p ~/.config/rofi
ln -sf "$THEME_DIR/rofi.rasi" ~/.config/rofi/theme.rasi
# ─── neomutt ────────────────────────────────────────────────────
mkdir -p ~/.config/neomutt
ln -sf "$THEME_DIR/mutt-colors" ~/.config/neomutt/colors
if [[ -d ~/.config/mutt ]]; then
ln -sf "$THEME_DIR/mutt-colors" ~/.config/mutt/theme.muttrc
fi
# ─── jellyfin-tui ───────────────────────────────────────────────
set_jellyfin_tui_preference() {
local theme="$1"
[[ -d "$HOME/.local/share/jellyfin-tui/preferences" ]] || return 0
command -v jq >/dev/null 2>&1 || return 0
for pref in "$HOME"/.local/share/jellyfin-tui/preferences/*.json; do
[[ -f "$pref" ]] || continue
tmp=$(mktemp)
jq --arg theme "$theme" '.theme = $theme' "$pref" > "$tmp" && mv "$tmp" "$pref" || rm -f "$tmp"
done
}
set_jellyfin_tui_config_theme() {
local mode="$1"
local config="$HOME/.config/jellyfin-tui/config.yaml"
[[ -f "$config" ]] || return 0
command -v ruby >/dev/null 2>&1 || return 0
ruby - "$config" "$mode" <<'RUBY'
require "yaml"
path, mode = ARGV
cfg = YAML.load_file(path) || {}
dark = {
"base" => "Dark",
"background" => "#282a36",
"foreground" => "#f8f8f2",
"foreground_secondary" => "#d7d8e8",
"foreground_dim" => "#a7abc4",
"foreground_disabled" => "#6f758f",
"section_title" => "#bd93f9",
"accent" => "#8be9fd",
"border" => "#4f5268",
"border_focused" => "#bd93f9",
"selected_active_background" => "#44475a",
"selected_active_foreground" => "#f8f8f2",
"selected_inactive_background" => "#343746",
"selected_inactive_foreground" => "#f8f8f2",
"scrollbar_thumb" => "#c6c8d6",
"scrollbar_track" => "#44475a",
"progress_fill" => "#ffb86c",
"progress_track" => "#44475a",
"tab_active_foreground" => "#bd93f9",
"tab_inactive_foreground" => "#7b819d",
"album_header_background" => "#343746",
"album_header_foreground" => "#8be9fd"
}
light = {
"base" => "Light",
"background" => "#fdf6e3",
"foreground" => "#073642",
"foreground_secondary" => "#25464d",
"foreground_dim" => "#405f66",
"foreground_disabled" => "#586e75",
"section_title" => "#005f87",
"accent" => "#005f87",
"border" => "#586e75",
"border_focused" => "#005f87",
"selected_active_background" => "#005f87",
"selected_active_foreground" => "#fdf6e3",
"selected_inactive_background" => "#d7ebe8",
"selected_inactive_foreground" => "#073642",
"scrollbar_thumb" => "#586e75",
"scrollbar_track" => "#eee8d5",
"progress_fill" => "#875f00",
"progress_track" => "#d6cab0",
"tab_active_foreground" => "#005f87",
"tab_inactive_foreground" => "#586e75",
"album_header_background" => "#eee8d5",
"album_header_foreground" => "#073642"
}
palette = mode == "light" ? light : dark
managed_names = ["Mode Switcher", "Dracula High Contrast", "Solarized Light High Contrast"]
themes = cfg["themes"].is_a?(Array) ? cfg["themes"] : []
unmanaged = themes.reject { |theme| theme.is_a?(Hash) && managed_names.include?(theme["name"]) }
cfg["auto_color"] = false
cfg["themes"] = managed_names.map { |name| { "name" => name }.merge(palette) } + unmanaged
content = YAML.dump(cfg)
if !File.exist?(path) || File.read(path) != content
mode_bits = File.exist?(path) ? File.stat(path).mode & 0o777 : 0o600
tmp = "#{path}.tmp.#{$$}"
File.write(tmp, content)
File.chmod(mode_bits, tmp)
File.rename(tmp, path)
end
RUBY
}
# jellyfin-tui live-reloads config.yaml, but not its preferences file.
# Keep one stable selected theme and rewrite that theme's colors per mode.
set_jellyfin_tui_preference "Mode Switcher"
set_jellyfin_tui_config_theme "light"
if pgrep -x jellyfin-tui >/dev/null 2>&1; then
(
while pgrep -x jellyfin-tui >/dev/null 2>&1; do
sleep 0.5
done
set_jellyfin_tui_preference "Mode Switcher"
) >/dev/null 2>&1 &
fi
# ─── sonicradio ─────────────────────────────────────────────────
# Every built-in theme ships separate dark and light profiles, picked from
# the terminal background at startup. A constant high-contrast theme works
# for both modes and survives sonicradio's config write-back on exit.
if [[ -f ~/.config/sonicRadio/config.json ]] && command -v jq >/dev/null 2>&1; then
tmp=$(mktemp)
jq '.theme = 4' ~/.config/sonicRadio/config.json > "$tmp" \
&& mv "$tmp" ~/.config/sonicRadio/config.json \
|| rm -f "$tmp"
fi
# ─── VSCodium ───────────────────────────────────────────────────
SETTINGS="$HOME/.config/VSCodium/User/settings.json"
if [[ -f "$SETTINGS" ]] && command -v jq >/dev/null 2>&1; then
tmp=$(mktemp)
jq '."workbench.colorTheme" = "Solarized Light"' "$SETTINGS" > "$tmp" \
&& mv "$tmp" "$SETTINGS" \
|| rm -f "$tmp"
fi
# ─── Claude Code ────────────────────────────────────────────────
CLAUDE_SETTINGS="$HOME/.claude/settings.json"
if [[ -f "$CLAUDE_SETTINGS" ]] && command -v jq >/dev/null 2>&1; then
tmp=$(mktemp)
jq '.theme = "light"' "$CLAUDE_SETTINGS" > "$tmp" && mv "$tmp" "$CLAUDE_SETTINGS" || rm -f "$tmp"
fi
# ─── Neovim ─────────────────────────────────────────────────────
mkdir -p ~/.config/nvim
ln -sf "$THEME_DIR/nvim-colorscheme.vim" ~/.config/nvim/colorscheme.vim 2>/dev/null || true
# ─── User Notification ──────────────────────────────────────────
set_dunst_theme
notify-send -i weather-clear "Theme" "Light Mode activated (Solarized Light)" 2>/dev/null || true
echo "Light mode activated (Solarized Light)"Make the script executable:
chmod +x ~/.local/share/light-mode.d/apply-themeStep 9: Initial State and Persistence on Login
darkman is designed as a user service that persists its own state. The service typically remembers the last mode after a restart and executes the corresponding hooks at startup.
Important — no hardcoded
darkman setcalls in.xprofile! It might be tempting to writedarkman set darkin.xprofileto “always start with Dark mode.” However, this is counterproductive:
- It overwrites the last manual toggle on every login.
- It completely disables auto-switching based on sunrise/sunset, if you enable it later.
Just let the service run.
Reality Check: Verify Persistence
In practice, reliable persistence depends on the interplay between the user service, login manager, and XDG state directory. Do not blindly trust it – check it after the first login cycle:
# What does darkman currently think?
darkman get
# Was the service started cleanly at login and did it trigger hooks?
journalctl --user -u darkman.service -bIf darkman get returns a different mode than expected, or if the hook scripts were not executed at login, you have a persistence problem (see next section).
If Persistence is Unreliable
Some possible countermeasures:
- Ensure
darkman.serviceis truly enabled:systemctl --user is-enabled darkman.service - In TTY login +
startxsetups, source~/.xprofilefrom~/.xinitrcbeforeexec i3. - Import X session variables into the user service manager — treat this as required, not optional.
darkman.serviceusually starts before the display manager has exportedDISPLAY/XAUTHORITY, and a long-running service keeps the environment it started with forever. The symptoms are sneaky: OSC recoloring, Polybar IPC, and config rewrites all work without X, so the switcher looks fine — butxrdbfails and every GUI process the hooks relaunch (tray applets, nheko) crashes instantly with “could not connect to display”. Put this in~/.xinitrc(or your session startup) beforeexec i3:
Thedbus-update-activation-environment --systemd DISPLAY XAUTHORITY XDG_CURRENT_DESKTOP systemctl --user import-environment DISPLAY XAUTHORITY XDG_CURRENT_DESKTOP systemctl --user try-restart darkman.servicetry-restartmakes darkman re-run the current mode’s hook with the freshly imported environment, which also repairs the failed X calls from its too-early boot start. The hook scripts above additionally carry aDISPLAY/XAUTHORITYfallback as a second line of defense. - If hooks run too early and cannot reach X/i3, do not add
darkman set ...to.xprofile. Fix the environment import above and restart the user service once:systemctl --user restart darkman.service
Optional: Auto-Switching Based on Sunrise/Sunset
If darkman should automatically switch based on time of day, create the file ~/.config/darkman/config.yaml:
lat: 48.2082
lng: 16.3738
usegeoclue: false(The coordinates provided here are for Vienna – adjust them to your location.)
The service calculates sunrise and sunset times from this and automatically triggers the hooks.
If you really need a default value on the very first start
If you’ve just installed darkman and want a defined initial state for the very first toggle, perform this manually once in the shell:
darkman set dark— not in .xprofile. From the next login onwards, the service will remember.
Step 10: Obtain Theme Files
For each application, place a dark and a light variant in ~/.config/themes/<theme>/. The following commands cover most cases. You can paste these directly:
Alacritty
curl -fsSL https://raw.githubusercontent.com/dracula/alacritty/master/dracula.toml \
-o ~/.config/themes/dracula/alacritty.toml
curl -fsSL https://raw.githubusercontent.com/alacritty/alacritty-theme/master/themes/solarized_light.toml \
-o ~/.config/themes/solarized-light/alacritty.tomlThe upstream Solarized Light terminal palette is faithful to the original scheme, but several TUI applications render blue/cyan/yellow text too lightly on #fdf6e3. For a terminal-focused setup, use this high-contrast variant instead of the raw upstream light file:
cat > ~/.config/themes/solarized-light/alacritty.toml <<'EOF'
# Colors (Solarized Light High Contrast)
[colors.primary]
background = '#fdf6e3'
foreground = '#073642'
[colors.normal]
black = '#073642'
red = '#dc322f'
green = '#5f8700'
yellow = '#875f00'
blue = '#005f87'
magenta = '#af005f'
cyan = '#006c6b'
white = '#eee8d5'
[colors.bright]
black = '#002b36'
red = '#cb4b16'
green = '#586e00'
yellow = '#6c5a00'
blue = '#00629d'
magenta = '#6c71c4'
cyan = '#00736f'
white = '#fdf6e3'
EOFIn your main ~/.config/alacritty/alacritty.toml, import the generated theme file the hook rewrites and confirm live-reload is on. Live-reload defaults to enabled in current Alacritty versions, but being explicit avoids confusion if a future upstream change flips the default:
[general]
import = ["~/.config/alacritty/theme.toml"]
live_config_reload = true # default; explicit for clarityTOML note: Since Alacritty 0.14,
import,working_directory,live_config_reload, andipc_socketlive under[general]. If you run an older Alacritty, checkman 5 alacrittyfor your version before copying this block.
Xresources / urxvt
The upstream dracula/xresources file uses a slightly different ANSI variant (pure black #000000, muted whites #bfbfbf/#e6e6e6) than the official Dracula Alacritty theme. If the OSC nudge, the Xresources, and the Alacritty file disagree, a live-recolored terminal and a freshly started one will show two different palettes side by side. Write a flat file with the official values instead:
cat > ~/.config/themes/dracula/Xresources <<'EOF'
! Dracula — flat, aligned with the official Alacritty palette
*.foreground: #f8f8f2
*.background: #282a36
*cursorColor: #f8f8f2
*.color0: #21222c
*.color8: #6272a4
*.color1: #ff5555
*.color9: #ff6e6e
*.color2: #50fa7b
*.color10: #69ff94
*.color3: #f1fa8c
*.color11: #ffffa5
*.color4: #bd93f9
*.color12: #d6acff
*.color5: #ff79c6
*.color13: #ff92df
*.color6: #8be9fd
*.color14: #a4ffff
*.color7: #f8f8f2
*.color15: #ffffff
EOFFor Solarized Light, the upstream solarized/xresources file uses C preprocessor #define statements. Many display managers and login flows invoke xrdb with -nocpp, which means those defines are not expanded and color entries that depend on them can fail to load as intended. To avoid this entirely, write a flat (preprocessor-free) version directly:
cat > ~/.config/themes/solarized-light/Xresources <<'EOF'
! Solarized Light — flat, no #define preprocessing required
*background: #fdf6e3
*foreground: #073642
URxvt.foreground: #073642
URxvt*foreground: #073642
*fadeColor: #fdf6e3
*cursorColor: #586e75
*pointerColorBackground:#93a1a1
*pointerColorForeground:#586e75
! black
*color0: #073642
*color8: #002b36
! red
*color1: #dc322f
*color9: #cb4b16
! green
*color2: #5f8700
*color10: #586e00
! yellow
*color3: #875f00
*color11: #6c5a00
! blue
*color4: #005f87
*color12: #00629d
! magenta
*color5: #af005f
*color13: #6c71c4
! cyan
*color6: #006c6b
*color14: #00736f
! white
*color7: #eee8d5
*color15: #fdf6e3
EOFThe values above keep the Solarized Light background and base colors but deliberately darken several accent colors. This is less purist than the canonical palette, but it is much more readable for TUIs such as neomutt, ikhal, sonicradio, and other apps that rely heavily on ANSI blue/cyan/yellow.
The hook uses xrdb -load, not xrdb -merge, because this setup manages .Xresources as a complete theme file. -merge keeps old keys that are absent in the new file; in practice that can leave stale urxvt values from the previous mode in the live Xresources database.
Polybar
See Step 5 — minimal Dracula and Solarized Light palettes are inlined there. Copy them into ~/.config/themes/<theme>/polybar.ini if you haven’t already, then include ~/.config/polybar/colors.ini from your real Polybar config.
Rofi
Rofi reads its theme file when the menu starts. Point your main config at a stable user theme path once:
@theme "~/.config/rofi/theme.rasi"Then create ~/.config/themes/dracula/rofi.rasi and ~/.config/themes/solarized-light/rofi.rasi; the hooks above switch ~/.config/rofi/theme.rasi to the active file. This affects the next Rofi invocation. A menu that is already open will not repaint live.
neomutt
Use terminal-palette colors for NeoMutt unless you have deliberately configured a DirectColor terminfo entry. The official NeoMutt configuration guide allows hexadecimal #RRGGBB colors only when $color_directcolor is set, and that option must be active before any color commands are read. On many i3/Alacritty systems COLORTERM=truecolor is not enough for NeoMutt: infocmp "$TERM" may show 256 colors but no Tc/RGB DirectColor capability, which makes HEX colors fail with “color not supported by terminal”.
For live repainting, use the same semantic ANSI color file in both theme directories. NeoMutt keeps the same color0-color15 attributes in memory; the hooks change what those terminal color slots mean:
mkdir -p ~/.config/themes/dracula ~/.config/themes/solarized-light
cat > ~/.config/themes/dracula/mutt-colors <<'EOF'
# Semantic ANSI NeoMutt palette for the mode switcher.
uncolor index *
uncolor index_author *
uncolor index_collapsed *
uncolor index_date *
uncolor index_flags *
uncolor index_label *
uncolor index_number *
uncolor index_size *
uncolor index_subject *
uncolor index_tag *
uncolor index_tags *
uncolor body *
uncolor header *
uncolor status *
color normal default default
# Never reuse one background pair for two different row markers: the
# cursor must stay visible on top of unread rows (color15/color8).
color indicator color0 color7
color status color15 color8
color tree color4 default
color error color15 color1
color message color2 default
color prompt color2 default
color options color4 default
color progress color0 color7
color signature color8 default
color attachment color2 default
color search reverse color5 default
color markers color1 default
color tilde color8 default
color hdrdefault color6 default
color sidebar_background default default
color sidebar_divider color8 default
color sidebar_flagged color3 default
color sidebar_highlight color15 color8
color sidebar_indicator color15 color8
color sidebar_new color4 default
color sidebar_ordinary default default
color sidebar_spool_file default default
color sidebar_unread color2 default
color header color4 default "^Subject:.*"
color body color4 default "[[:alnum:]_.+%-]+@[[:alnum:].-]+"
color body color3 default "(https?|ftp)://[[:alnum:].,/%~_:?&=#,+-]+"
color body color6 default "(^|[[:space:]])\\*[^[:space:]]+\\*([[:space:]]|$)"
color body color6 default "(^|[[:space:]])_[^[:space:]]+_([[:space:]]|$)"
color body color6 default "(^|[[:space:]])/[^[:space:]]+/([[:space:]]|$)"
color quoted color4 default
color quoted1 color6 default
color quoted2 color2 default
color quoted3 color5 default
color quoted4 color1 default
# State rows with visible backgrounds that work on both terminal palettes.
# "reverse colorN default" renders an accent background whose text takes the
# terminal's default background color — the contrast is identical to using
# the accent as a foreground, so it stays readable in both modes.
color index color5 default "~Q"
color index color8 default "~v"
color index color15 color8 "~U"
color index reverse color6 default "~T"
color index reverse color3 default "~F"
color index reverse color1 default "~D"
# Column accents only on rows without a state background, so state rows
# stay uniform blocks instead of getting default-background holes.
color index_author color5 default "!(~U|~T|~F|~D)"
color index_date color2 default "!(~U|~T|~F|~D)"
color index_flags color4 default "!(~U|~T|~F|~D)"
color index_number color4 default "!(~U|~T|~F|~D)"
color index_size color3 default "!(~U|~T|~F|~D)"
EOF
cp ~/.config/themes/dracula/mutt-colors ~/.config/themes/solarized-light/mutt-colorsThe indicator, status, and sidebar_highlight rules deliberately stay on the neutral slots (color0, color7, color8, color15). Accent slots are bright pastels in Dracula but dark tones in the Solarized Light High Contrast palette, so an accent background fails in one of the two modes: color15 on a color4 background reads as near-white on light purple in Dracula (about 1.9:1 contrast), and color0 on color3 as dark slate on dark yellow in Solarized Light (about 2.3:1). The neutral pairs stay above 4.5:1 in both palettes.
A second design rule matters as soon as rows get background highlighting: never reuse one background pair for two different marker types. If the cursor (indicator) and unread rows shared color15/color8, the cursor would become invisible while sitting on unread mail. That is why the palette above splits them: cursor = bright color0/color7 bar, unread = calm color15/color8 block, search = magenta via reverse color5 — each marker owns its background.
The mail-state rows still get colored backgrounds, just through a different mechanism: reverse colorN default makes NeoMutt render the row in reverse video, which displays the accent as the background and the terminal’s default background color as the text. The resulting contrast is mathematically identical to the accent used as a foreground on the default background — and that combination is readable in both modes by definition, otherwise the accent would be useless as a text color. This yields tagged = cyan/teal block, flagged = gold block, deleted = red block, while unread mail uses the explicit neutral color15/color8 pair as a calmer highlight. The per-column accents are restricted to !(~U|~T|~F|~D) because an index_author rule with a default background would otherwise punch holes into the state rows.
If your setup comes from mutt-wizard, your main config is often ~/.config/mutt/muttrc, not ~/.config/neomutt/. In that case add this once:
source ~/.config/mutt/theme.muttrcand let the hooks switch ~/.config/mutt/theme.muttrc instead of ~/.config/neomutt/colors.
If a NeoMutt instance is already running with an older 256-color or HEX theme loaded, it needs one manual reload once. Press : to open NeoMutt’s command prompt, then run:
source ~/.config/mutt/theme.muttrcAfter that, further Dark/Light switches can repaint via the terminal OSC palette and the hook’s SIGWINCH redraw nudge. If you maintain your own color files, start them with uncolor index *, uncolor body *, uncolor header *, and any other list-style color objects you use. Otherwise old dark rules and new light rules can stack together after a manual :source. Also keep body regexes POSIX-safe: use classes such as [[:alnum:]_.+%-], and place - at the end of a character class. Ambiguous ranges can make NeoMutt abort with errors such as “invalid endpoint in range expression”.
khal / ikhal
ikhal loads its Urwid palette once at startup. It does not watch ~/.config/khal/config for theme changes, so rewriting [palette] alone does not repaint an already running instance. The live strategy is the same as NeoMutt: use only ANSI color names and let the terminal palette change underneath.
Do not use dark blue as a large focused background. In the Dracula terminal palette it intentionally maps to purple, which looks good as an accent but turns the focused mini-calendar into a purple block. Keep dark blue for foreground accents and use neutral black or empty backgrounds for broad focus surfaces.
The same rule applies to every accent slot. dark cyan and dark green map to dark teal/olive in the Solarized Light High Contrast palette but to bright pastels in Dracula, so as backgrounds they fail in exactly one of the two modes: button = black, dark cyan drops to about 2.1:1 contrast in Light mode, and mark = white, dark green to about 1.1:1 in Dracula. Treat ANSI slots 1–6 and 9–14 as foreground-only and build backgrounds from black, dark gray, light gray, and white — that is why the palette below uses button = black, light gray and mark = white, dark gray.
The hooks above still set [view] theme for next starts, but the visible custom palette stays semantic and identical in both modes:
[view]
theme = light
frame = width[highlight_days]
method = fg
multiple = brown
# two-calendar days show both colors half-and-half; three or more
# fall back to the "multiple" color
multiple_on_overflow = True
# empty: per-calendar day colors via the palette overrides below
color =[palette]
header = '', '', ''
footer = '', '', ''
line header = dark blue, '', bold
alt header = dark blue, '', bold
bright = dark blue, '', bold
list = '', '', ''
list focused = white, black, bold
edit = '', '', ''
edit focus = white, black, bold
button = black, light gray, ''
button focused = white, black, bold
reveal focus = white, black, standout
today focus = white, black, standout
today = black, light gray, ''
date header = light red, '', ''
date header focused = white, black, bold
date header selected = white, black, ''
dayname = dark cyan, '', ''
monthname = dark magenta, '', ''
weeknumber_right = dark gray, '', ''
alert = white, dark red, bold
mark = white, dark gray, bold
frame = dark gray, '', ''
frame focus = dark blue, '', ''
frame focus color = dark blue, '', ''
frame focus top = dark magenta, '', ''
eventcolumn = '', '', ''
eventcolumn focus = '', '', ''
calendar = '', '', ''
calendar focus = '', '', ''
editbx = white, black, ''
editcp = black, light gray, standout
popupbg = '', '', ''
popupper = white, black, bold
caption = dark blue, '', bold
calendar personal = dark blue, '', ''
calendar work = dark magenta, '', ''Event days and calendar colors
If your calendars come from a CalDAV server and vdirsyncer syncs metadata = ["color"], each vdir carries an RGB color file (for example #1c57ec). Those static RGB values cannot adapt to a mode switch: a dark blue that reads fine on a light background drops below 3:1 contrast on a dark one. Worse, in type = discover setups the vdir color takes precedence over any color value in the khal config, so you cannot neutralize it there — and editing the vdir files is not an option either, because metasync would push the change back to your server.
The escape hatch is the palette merge order: ikhal builds its palette from the theme, then appends the generated per-calendar and highlight attributes, and merges the config [palette] section last — config entries win. So define one calendar <name> = dark blue, '', '' entry per calendar to remap its color to an ANSI name that tracks the terminal palette live. Month-view day numbers and event-list rows share these calendar <name> attributes, which means both stay color-coded per calendar and readable in both modes. The attribute names must match the discovered calendar names — check khal printcalendars, since type = discover setups often append suffixes (personal becomes personal1).
Two refinements round off the month view: multiple_on_overflow = True renders days with events from exactly two calendars as half-and-half two-color cells, and the de-emphasized weeknumber_right/frame (dark gray) plus the dark magenta month column give the calendar a color hierarchy instead of uniform cyan.
If you wonder whether there are ready-made color tables for exactly this: khal ships two built-in themes (dark and light in khal/ui/colors.py), which [view] theme selects and our [palette] section refines. They are worth reading as a reference for which attribute does what, but they assume a terminal with pure black/white defaults rather than a theme-switched palette — that is why this setup overrides them with semantic ANSI values. Two lessons from the built-ins are baked into the palette above: calendar focus stays at '', '', '' (khal’s own default — any color there washes over every plain day whenever the calendar column holds focus), and structural accents like date header follow the built-ins’ use of light red, which stays distinct from all per-calendar accent colors.
Alternative: uniform background blocks. If you prefer one clearly visible block over per-calendar colors, set
[highlight_days] colorto any non-empty value (for exampledark gray) and override the generated attribute withhighlight_days_color = white, dark gray, ''in[palette]. Every event day then renders as white on slate (#6272a4) in Dracula and cream on dark slate (#002b36) in Solarized Light — distinct from thetodayblock in both modes. Note that this drops the per-calendar distinction in the month view; the shared-attribute coupling means truly per-calendar background blocks would also turn every event-list row into a colored block.
All overrides use only ANSI color names, so they repaint live with the OSC palette switch — no ikhal restart needed beyond the usual one-time migration.
Use '' for empty/default foreground, background, or mono fields. default is not a valid khal palette color name and will make khal reject the config. Existing ikhal sessions that loaded an older HEX palette need one restart; after that the hook’s OSC palette update plus SIGWINCH redraw is enough for normal mode switches.
jellyfin-tui
jellyfin-tui has two separate pieces of theme state:
~/.config/jellyfin-tui/config.yamlcontains the custom theme definitions and is watched by the running app.~/.local/share/jellyfin-tui/preferences/*.jsonstores the selected theme name and is best treated as a next-start setting.
That means the reliable live strategy is not to switch between two selected theme names. Keep the selected theme stable, for example Mode Switcher, and let the hooks rewrite that theme’s colors in config.yaml. The running app will then repaint from its config watcher, while the preferences file only needs to stay pointed at the stable theme name.
Create a starting theme in ~/.config/jellyfin-tui/config.yaml:
auto_color: false
themes:
- name: Mode Switcher
base: Dark
background: "#282a36"
foreground: "#f8f8f2"
foreground_secondary: "#d7d8e8"
foreground_dim: "#a7abc4"
foreground_disabled: "#6f758f"
section_title: "#bd93f9"
accent: "#8be9fd"
border: "#4f5268"
border_focused: "#bd93f9"
selected_active_background: "#44475a"
selected_active_foreground: "#f8f8f2"
selected_inactive_background: "#343746"
selected_inactive_foreground: "#f8f8f2"
scrollbar_thumb: "#c6c8d6"
scrollbar_track: "#44475a"
progress_fill: "#ffb86c"
progress_track: "#44475a"
tab_active_foreground: "#bd93f9"
tab_inactive_foreground: "#7b819d"
album_header_background: "#343746"
album_header_foreground: "#8be9fd"Select that theme once in every existing preference file:
for pref in ~/.local/share/jellyfin-tui/preferences/*.json; do
jq '.theme = "Mode Switcher"' "$pref" > /tmp/jellyfin-tui-pref.json \
&& mv /tmp/jellyfin-tui-pref.json "$pref"
doneThe hook examples above use ruby to update YAML without string surgery. They also update the legacy names Dracula High Contrast and Solarized Light High Contrast to the same current palette, which helps during migration if a running instance still has one of those names loaded in memory. If the app is currently on a built-in theme, select Mode Switcher once in the app or restart it once; after that, mode changes can be picked up from config.yaml.
sonicradio
sonicradio is a Bubble Tea/Lipgloss application with built-in themes stored as numeric IDs in ~/.config/sonicRadio/config.json. A look at its ui/theme.go reveals the right strategy: every theme ships a separate dark and light color profile, and the app picks the profile from the terminal background at startup. So instead of switching theme IDs per mode, set one constant theme whose two profiles are both high-contrast — in the tested setup ID 4 (“Mono Yellow”: near-black text and an amber selection bar on light terminals, amber-on-dark in dark mode):
jq '.theme = 4' ~/.config/sonicRadio/config.json > /tmp/sonicradio.json \
&& mv /tmp/sonicradio.json ~/.config/sonicRadio/config.jsonThe constant value has a second benefit: sonicradio persists its config on exit, which would overwrite a per-mode value — a constant one survives. The duo-tone themes (IDs 0–3) are poor choices here: their pale selection backgrounds (for example lavender #D4DAF7) almost disappear on a light terminal. A live terminal switch still leaves a running instance on the old profile (pale dark-profile text on a now-light background); restart it once after toggling.
Dunst
Dunst is not affected by GTK, Qt, or the XDG color-scheme portal. It reads ~/.config/dunst/dunstrc, so the hook should update only the color keys you actually want to switch and then reload the running daemon:
set_ini_key ~/.config/dunst/dunstrc global frame_color '"#005f87"'
set_ini_key ~/.config/dunst/dunstrc global separator_color '"#93a1a1"'
set_ini_key ~/.config/dunst/dunstrc global format "\"<span foreground='#073642'><b>%s %p</b></span>\\\\n%b\""
set_ini_key ~/.config/dunst/dunstrc urgency_normal background '"#fdf6e3"'
set_ini_key ~/.config/dunst/dunstrc urgency_normal foreground '"#073642"'
dunstctl reload ~/.config/dunst/dunstrcThe double escaping before n is intentional: the file should contain a literal \n inside the quoted Dunst format string, not a physical newline that breaks the next config line.
VSCodium
VSCodium (like VS Code) ships with Solarized Light and Solarized Dark as built-in themes — no extension required. Dracula, however, must be installed separately:
codium --install-extension dracula-theme.theme-draculaThe hook scripts already set the correct theme name via jq. If your VSCodium uses a different binary name (e.g., vscodium), adjust the command accordingly.
Claude Code
Claude Code renders its terminal UI with truecolor values from its own theme setting and ignores the terminal’s ANSI palette entirely — on a light terminal with the app still on "theme": "dark", dim text and diff views become nearly invisible. The hooks patch ~/.claude/settings.json (.theme = "dark" / "light") via jq. New sessions pick the matching theme up automatically; already running sessions keep their theme until restarted or changed in-session via /config.
nheko
nheko needs three insights at once, all confirmed against src/ui/Theme.cpp:
- Do not use
theme=lightif you expect a fully light window. nheko’s built-in light theme hardcodes a dark Element-style sidebar (sidebarBackground = #233649) — the dark rail and scrollbar tones you might mistake for dark-mode leftovers are upstream design. Onlytheme=systemderivessidebarBackgroundand the scrollbars from the Qt palette (p.window().color()), which qt6ct supplies correctly per mode. So the hooks keeptheme=systemand rely on a complete qt5ct/qt6ct color scheme. - A live palette switch always leaves a running instance mixed — parts of the QML UI cache their colors. A restart is unavoidable, so the hooks do it automatically.
- nheko persists its in-memory settings on exit, just like
jellyfin-tui: quitting it right after a config write restores the old value. The only reliable order is: stop the running instance, wait until it has saved and quit, write the config, start it fresh — exactly what the hook block does. A nheko that was not running is left closed.
The cost is a brief reconnect of the Matrix client per mode switch; remove the restart part of the block if you prefer switching nheko manually. Two traps follow from the restart itself:
- Hooks run inside
darkman.service, which does not see~/.xprofile— without theQT_QPA_PLATFORMTHEMEexport from the hook header, a hook-restarted nheko silently skips qt6ct and renders with unthemed Qt defaults. - A restarted app is a new window, and i3 places new windows on the currently focused workspace — the old position died with the old window. The root-level fix is i3’s own session-restore mechanism:
restart_app_in_place(defined with the other hook helpers) appends a placeholder container with swallow criteria to the app’s old workspace viaappend_layout, and i3 materializes the next matching window directly inside it — on whatever workspace the app happened to live, with no capture-and-move-back scripting. Two implementation notes: i3 returnssuccesseven when a criteria matches nothing (scripted post-hoc moves fail silently), and a watchdog removes the placeholder if the app fails to come back. If you instead keep an app on one fixed workspace anyway, a declarativeassign [class="..."] number Nrule is the simpler alternative.
GTK and Qt
GTK Dracula via AUR (yay -S dracula-gtk-theme); Adwaita is shipped by default. Qt color schemes: see Step 3.
Neovim
The hook scripts manage ~/.config/nvim/colorscheme.vim. This only has an effect if your Neovim config sources that file:
source ~/.config/nvim/colorscheme.vimThen create theme-specific files, for example:
cat > ~/.config/themes/dracula/nvim-colorscheme.vim <<'EOF'
set background=dark
colorscheme dracula
EOF
cat > ~/.config/themes/solarized-light/nvim-colorscheme.vim <<'EOF'
set background=light
colorscheme solarized
EOFAdjust the colorscheme names to match the plugins actually installed in your Neovim setup.
Reference table
| App | File | Source |
|---|---|---|
| GTK | Theme via AUR/Pacman | dracula-gtk-theme, Adwaita is default |
| Xresources/urxvt | Xresources | local flat files aligned with the Alacritty palettes (Step 10) |
| Alacritty | alacritty.toml | dracula/alacritty, alacritty/alacritty-theme |
| i3 | i3-theme.conf | inlined examples in Step 4 |
| Polybar | polybar.ini → ~/.config/polybar/colors.ini | inlined examples in Step 5 |
| Rofi | rofi.rasi → ~/.config/rofi/theme.rasi | local .rasi files |
| neomutt | mutt-colors | local semantic 16-color palette |
| khal / ikhal | ~/.config/khal/config | local semantic ANSI palette |
| jellyfin-tui | config.yaml, preferences JSON | live-reloaded custom Mode Switcher theme |
| sonicradio | ~/.config/sonicRadio/config.json | built-in numeric themes |
| Dunst | ~/.config/dunst/dunstrc | local color keys, reload via dunstctl reload |
| KeePassXC | ~/.config/keepassxc/keepassxc.ini | set app theme to Automatic |
| VSCodium | built-in / Extension | Solarized built-in, “Dracula Official” via marketplace |
| Claude Code | ~/.claude/settings.json | built-in dark/light truecolor themes |
| nheko | ~/.config/nheko/nheko.conf | built-in themes via [user] theme |
| qt5ct/qt6ct | *.conf | dracula/qt5, Solarized via qt5ct GUI |
You can find Dracula themes collected at https://draculatheme.com – Solarized at https://ethanschoonover.com/solarized.
Caveats and Honest Expectations
What switches live (without restart):
- Alacritty (with
live_config_reload = true) - i3 (after
reload) - Polybar (after restart)
- Rofi on the next menu invocation, because Rofi reads
theme.rasiat startup - VSCodium (mostly, via file watcher on settings.json)
- KeePassXC if View → Theme → Automatic is selected
- Dunst notifications after the hook updates
dunstrcand runsdunstctl reload jellyfin-tui, if it is already using the stableMode Switchercustom theme; the hooks rewrite that theme inconfig.yaml, which the app watches.- NeoMutt and ikhal after they have loaded the semantic ANSI palette once; the hook changes the terminal’s 16-color palette and sends
SIGWINCHfor redraw. - The XDG portal color-scheme value consumed by portal-aware apps
- Terminal foreground/background/cursor nudges for running urxvt/Alacritty windows via OSC sequences
- Tray applet icons, because the hooks restart the applets (
nm-applet,pasystray,flameshot) with the freshly switched GTK icon theme
What requires an app restart:
- Firefox/Thunderbird chrome can require a restart even when the portal signal itself updates live. Web content using
prefers-color-schemeis usually more likely to follow than the full browser UI. - Generic GTK apps in plain i3 setups, such as Thunar, may read the new theme only on next start unless an XSettings/settings daemon propagates changes.
- nheko and other Qt apps that keep their own in-memory palette. The hooks handle nheko by restarting it automatically (stop, wait for its settings write-back on exit, keep
theme=system, start fresh) — note that nheko’s built-in light theme deliberately uses a dark sidebar, sotheme=systemplus a complete qt6ct palette is the only fully light/dark-consistent configuration. - Claude Code: the hooks switch its truecolor theme in
~/.claude/settings.json; new sessions match automatically, running sessions keep the old theme until restart or an in-session/configchange. - GIMP, LibreOffice (own theme logic)
- urxvt terminals still do not fully reload arbitrary Xresources live. The included PTS escape-sequence trick updates running urxvt Background/Foreground/Cursor and the 16-color ANSI palette, but font/resource changes and app-internal colors still require a restart or app-level reload.
- Terminal apps with their own theme state, such as sonicradio, usually need restart or an app-internal theme change.
jellyfin-tuiis the exception when the stableMode Switcherstrategy above is used; NeoMutt and ikhal are the exception when the semantic ANSI palette strategy above is used. - Bubble Tea/Lipgloss/termenv apps can briefly use the old AdaptiveColor branch after a live terminal theme change. The Alacritty OSC nudge reduces this, but a running TUI can still repaint one step late.
- Chromium (flags file takes effect on start)
What needs to be configured manually within the app:
- LibreOffice: set Light/Dark once in
Tools → Options → Application Colors - GIMP: has its own theme system under
Preferences → Theme - KeePassXC: set
View → Theme → Automaticonce; hardcodeddarkorlightoverrides the portal - Inkscape: follows GTK, but restart required
Tips
- Geo-based Auto-Switching:
darkmancan do this automatically based on sunrise/sunset – see Step 9. - Debugging:
journalctl --user -u darkman.service -fshows if hooks are being executed. - Manual Hook Testing: Execute
~/.local/share/dark-mode.d/apply-themedirectly to see errors in isolation. - Terminal OSC Nudges: The hook enables targeted urxvt and Alacritty PTS recoloring plus OSC 4 ANSI-palette updates by default. Set
RECOLOR_URXVT_PTS=0orRECOLOR_ALACRITTY_PTS=0when running the hook if you want to disable these partial live updates. - Consistency Across Multiple Machines: Version
~/.config/themes/,~/.local/share/dark-mode.d/,~/.local/share/light-mode.d/, and~/.config/qt5ct/colors/in your dotfiles repository.
Extension Ideas
- base16-Framework instead of Dracula/Solarized: Generate a unified scheme for hundreds of apps from a single palette (https://github.com/chriskempson/base16).
- Per-Workspace-Themes: Theoretically possible via i3-IPC, but rarely practical.
- Polybar Module with current theme status as an indicator.
Summary
The implemented architecture is:
darkman (Trigger + Portal + Persistence)
├── Hooks → GTK, Qt, Xresources, App Configs
├── Live-Reload: i3, Polybar, Alacritty, XDG portal signal, VSCodium
└── Accepted: Some apps require restartYou switch with $mod+Shift+d. Most apps react immediately, some require a restart – this is the current state of Linux desktop reality, not a flaw in your setup.
When setting up for the first time, it’s worthwhile to follow the phased strategy from the beginning of the tutorial: first the basics (GTK config, XDG portal, i3, Alacritty), then extensions, and finally the “problem children.” This way, you can more easily identify the cause of problems if they arise.





