13 KiB
Context
The user runs jpporta-nixos (Hyprland on Wayland) and jpporta-deck (a OrangePi running cage + foot as a "writer deck"). They maintain 11 hand-curated palettes under ~/.config/colorschemes/<theme>/ with per-app config files (hyprland, waybar, swaync, wlogout, kitty, ghostty, nvim, rofi, gtk-4.0, plus a wallpapers/ directory). Currently:
- Hyprland, awww, waybar, kitty, ghostty are reachable from the themed dirs (with manual edits).
- swaync, wlogout, alacritty have their colors hardcoded as Nix strings in
modules/home-manager/*/default.nix, so swapping themes requireshome-manager switch. - darkman runs a dark/light switch (geolocation-based) that flips GTK theme and hyprsunset temperature — its
gtk-themestep overlaps with what theme-switch will own. - No canonical "current theme" pointer exists. The
~/.config/colorschemes/wallpaperssymlink is a one-off pointing at gruvbox-dark. - No live propagation contract — every themed app needs its own reload mechanism (file watch, IPC, signal, or restart-on-next-launch).
The user wants: one command to swap themes, no rebuild, every themed app reflects the new palette, dark/light mode is owned by the theme (not by darkman), darkman keeps doing what it's good at (time-of-day temperature), and the writer deck can adopt the same workflow.
Constraints discovered during exploration:
- All per-theme assets must remain editable in
~/.config/colorschemes/<theme>/without rebuild — this is the precedent set by theawwwmodule (reads wallpaper dir live, never copies to store). - Theme colors live in dotfiles, not Nix. Anything baked into Nix is a second source of truth and must be removed.
- The
.activesymlink pattern is preferred over a sentinel file because it's atomic (ln -sfn) and serves as the directory apps follow directly. - Darkman should keep its sunset/sunrise logic for
hyprsunset, but stop touching GTK (theme owns that).
Goals / Non-Goals
Goals:
- Atomic theme swap with one shell command (
theme-switch <name>) — no Nix rebuild, no per-app scripting by the user. - Single canonical pointer (
~/.config/colorschemes/.activesymlink) that every themed app can follow. - Per-app live reload: each app gets exactly the reload signal it natively supports, scripted in one place.
- gsettings
color-schemeflip driven bymeta(theme declares its mode). - Hook point for user-owned wallpaper logic (
~/.config/colorschemes/.hook <theme-name>), exec'd at the end of every switch. - Boot-time application: a systemd user oneshot re-applies
.currenton graphical-session start so login is consistent. - Rofi-driven picker (
theme-picker) wired to a Hyprland keybind (SUPER+T). - Same module works on the writer deck; foot has no live reload but a relaunch in cage is fine.
Non-Goals:
- Generating palettes from images (this is what matugen/pywal do — user wants named curated palettes).
- Per-app templating engines. Each app already has its own config format; we hand-write the swap for each.
- Supporting themes that live outside
~/.config/colorschemes/. - Backwards compatibility for the existing
~/.config/colorschemes/wallpaperssymlink (it becomes redundant once.active/wallpapers/exists; the script handles the migration). - Replacing darkman. darkman stays for
hyprsunsettime-of-day control. - Auto-detecting new themes. The
metafile must exist per theme; the user adds them by hand. - Adding new external dependencies (no new packages).
Decisions
D1. .active symlink as the single source of truth, not a sentinel file
Choice: ~/.config/colorschemes/.active is a symlink pointing at the current theme's directory. A .current text file mirrors the name for tools that can't follow symlinks (some app loaders, the boot-time applier).
Why: Atomic swap with one syscall (ln -sfn). Apps that support symlink-following read ~/.config/colorschemes/.active/<app>/ directly. Apps that need a name read .current. Both can coexist; the cost is two writes per switch.
Alternatives considered:
- Plain
.currenttext file only: requires every app to maintain its own per-app link/symlink. Doesn't scale. - Environment variable: lost across reboots, hard to introspect from running processes, fights with systemd user services.
- dconf / gsettings schema: forces every app into a GTK-ish world; waybar/kitty/ghostty/hyprland don't read it.
D2. Per-app reload handled inside theme-switch, not via inotify watchers
Choice: theme-switch is the orchestrator. It runs each per-app reload step explicitly. No daemon watches ~/.config/colorschemes/.active for changes.
Why: Hyprland auto-watches its config (free). Other apps don't, and inventing an inotify daemon to poke apps adds a new failure surface. The user's stance: "if the app watches its own config, fine; otherwise, send the signal." That's exactly what theme-switch does.
Alternatives considered:
- inotifywait daemon: one more systemd service, one more race condition, no benefit over the explicit script.
- DBus signal + per-app subscribers: significant scaffolding for what amounts to ~10 commands.
D3. Theme metadata via per-theme meta file, not Nix
Choice: Each theme gets a one-line ~/.config/colorschemes/<theme>/meta file with name=Display Name and mode=dark|light (KEY=value, simple parser).
Why: Keeps the colorscheme dir fully self-describing. The theme-picker script reads these for its rofi menu. theme-switch reads the mode field to flip gsettings. No Nix expression, no rebuild.
Alternatives considered:
- Filename convention (
gruvbox-dark/,e-ink-light/) — infer mode from name: fragile, requires a registry. - First-line comment in
colors.conf: mixes concerns (colors.conf is for hyprland, not for metadata).
D4. Cleanup pass on swaync/wlogout/alacritty is required, not optional
Choice: Remove the hardcoded gruvbox colors from the three Nix modules. They become thin wrappers that xdg.configFile the active theme dir via out-of-store symlinks (same pattern as the existing nvim module).
Why: Without this, three of the user's themed apps simply won't switch (their colors are baked at build time). The cleanup is a one-time home-manager switch; theme switches thereafter are runtime only.
Alternatives considered:
- Leave Nix modules alone, accept that swaync/wlogout/alacritty don't switch: violates the user's goal of "every app picks up the new theme."
- Template them at activation time: requires
home-manager switchon every theme change — explicitly rejected.
D5. Darkman narrowed to hyprsunset only
Choice: darkman stays enabled. Its scripts lose the gtk-theme step (theme-switch owns that now) and keep the hyprsunset step (time-of-day temperature). theme-switch does not poke darkman.
Why: Theme owns which palette (dark vs light). Darkman owns when to warm the screen (sunset/sunrise). They're orthogonal and shouldn't fight. Conflating them — e.g., theme-switch telling darkman what mode to be in — creates two writers for the same gsettings keys.
Alternatives considered:
- Theme-switch sets the darkman mode file: simple but couples two systems that don't otherwise need to know about each other.
- Kill darkman: loses the automatic sunset temperature shift, which the user said they want to keep.
D6. Wallpaper hook is user-owned, executed at end of switch
Choice: If ~/.config/colorschemes/.hook exists and is executable, theme-switch runs it as ~/.config/colorschemes/.hook <theme-name> at the very end of the switch. If it doesn't exist, the switch still completes.
Why: Wallpaper policy is personal (random pick? curated list? match a subdirectory?). The change shouldn't ship a wallpaper-rotation script the user didn't ask for. The hook is a single, documented extension point.
Alternatives considered:
- Bundle a default random-wallpaper script in the Nix module: scope creep, and the user explicitly said "you can leave that part to me."
- awww integration baked into theme-switch: same as above; also, awww already has its own
awww-cyclesystemd timer that picks from~/Wallpapers, and thewallpaperssymlink today points at the theme's dir — so all theme-switch needs to do is repoint the symlink.
D7. Boot-time re-apply via systemd user oneshot
Choice: theme-switch (no args) is idempotent: it reads .current and re-applies. A theme-apply.service systemd user oneshot is WantedBy=graphical-session.target and runs it on session start.
Why: Ensures the desktop matches .current after login even if a previous session crashed mid-switch. Cheap (the script no-ops if the symlink already points there).
Alternatives considered:
- Home Manager activation hook: runs only on
home-manager switch, not on plain reboots. - Hyprland autostart command: works but mixes init logic into compositor config; systemd target is cleaner.
D8. Rofi picker is a thin wrapper, not a replacement for the CLI
Choice: theme-picker is a small script that reads each theme's meta, pipes them through rofi -dmenu, and execs theme-switch on the chosen name. The CLI is the source of truth; the picker is sugar.
Why: Scriptability (cron, hyprland keybind action, shell aliases) belongs in the CLI. The picker is one Hyprland keybind away.
Alternatives considered:
- Waybar module: cute but adds a waybar dependency to a feature that doesn't need it.
- TUI (fzf, gum): extra dependency, no gain.
D9. Writer deck adopts the same script, with foot handled as "relaunch on switch"
Choice: Same Nix module imports into hosts/writter-deck/home.nix. The theme-switch script on the deck has a foot-specific step: pkill foot && foot --server & (foot's server-mode makes this near-instant for a writer deck).
Why: Zero new concepts on the deck. The user's deck is already gruvbox; this just lets them pick another.
Alternatives considered:
- Separate
theme-switch-footscript: duplication for one app. - Skip the deck: explicit non-goal of the user — they want it if cheap.
Risks / Trade-offs
-
Atomicity of multi-app reload → Mitigation: order the steps so data lands before signals (
hyprlandconfig swap is just a file rewrite; signals come after). If one app fails mid-sequence, the user can re-runtheme-switch <name>— it's idempotent. -
Hyprland's
source =directive points at a path that must exist before hyprland reads it → Mitigation: on first install, the activation script creates.activefromcustom.theme.currentbefore hyprland starts. Hyprland only loads on graphical-session start (aftertheme-apply.service). -
Waybar
@importand symlink interplay → Mitigation: waybar'sstyle.csswill@importfromthemes/current.css(a symlink the script updates); SIGUSR2 forces re-eval. -
nvim running sessions won't recolor → Mitigation: the script tries
nvim --remote-expr "colorscheme <name>"if$NVIMis set; if not, only new nvim windows pick up the new theme. Documented as expected. -
alacritty has no live color reload → Mitigation: next-launched alacritty instances pick up the new config; existing windows keep their colors until restarted. Documented. (This matches alacritty's own behavior; not a regression.)
-
ghostty live reload is
SIGHUP→ Mitigation: sendSIGHUPto the running ghostty; on some versions this re-reads the config. If ghostty is unresponsive, the user restarts the terminal. Acceptable. -
First-time switch after the cleanup rebuild may visually flash old-then-new → Mitigation: the boot-time oneshot re-applies
.currentbefore the user sees the desktop; the flash only happens if the user runstheme-switchinteractively, which is the point. -
Adding a new theme requires writing a
metafile → Mitigation: documented in the theme module's option description. Themetafile is two lines. -
darkman's gsettings writes could fighttheme-switch's → Mitigation: the cleanup pass removes darkman'sgtk-themescript entirely. After this change, darkman writes only tohyprsunset. No overlap. -
Nix modules becoming "thin" can feel like over-abstraction to a reader who expects colors there → Mitigation: the modules keep their
enableoptions and any non-color config (font, opacity, layout). Only color strings leave. Comment in each module notes "colors come from~/.config/colorschemes/.active/<app>/." -
Hook script runs as the user with no sandboxing → Mitigation: it lives in the user's home dir, owned by the user. This is the same trust level as a shell alias. Documented in
theme-switch's usage output. -
ln -sfnover an existing.activesymlink is correct, but if.activeexists as a directory (corruption), the symlink will be created inside it → Mitigation: the script first checks[ -L .active ] || [ ! -e .active ]and bails with a clear error if.activeis a directory. Documented in the script header.