Theming
Table of Contents
One palette drives every surface. Set emanix.theme and rebuild; the terminal,
the browser, the screen locker, Emacs and the system tooling all follow from the
same source.
The system is directory-per-theme with a dark/light toggle layer on top.
Applying a specific theme is dot-theme-set <name>; flipping between dark and
light is the separate dot-theme-toggle.
1. Shipped palettes
$EMANIX_THEMES_DIR/ ├── catppuccin-mocha/ # dark — soft, the daily default ├── catppuccin-latte/ # light — soft ├── high-contrast-dark/ # dark — 16.16:1 └── high-contrast-light/ # light — 16.87:1
dot-theme-toggle flips dark↔light within the last-used theme of each
variant, so it stays a two-way toggle even with four themes. Choosing high
contrast is an explicit dot-theme-set.
The high-contrast pair exists for accessibility (visual snow syndrome), not
aesthetics. Every accent slot clears WCAG AAA (7:1) against its base;
tests/contrast-check.py enforces that. Catppuccin is exempt by name — it is
knowingly soft, and 14 of Latte's 18 accent slots fail WCAG AA (4.5:1) against
its base.
2. Commands
dot-theme-set <name> # apply a specific theme dot-theme-toggle # flip between last-dark and last-light dot-theme-set # (no arg) prints usage + lists available themes
Both commands are consumer scripts, not part of the distribution. Emanix owns
the palettes, the generated theme tree and the Emacs entry point
(emanix/theme-set); the flake that consumes Emanix supplies the dot-*
wrappers that drive them. See Keybindings for the
same split applied to the agent binding.
Neither is bound to a key under EWM — see the omissions at the end of this page.
3. How it resolves
The palette is looked up at rebuild time, not read at runtime:
activePalette = palettes.${config.emanix.theme} or palettes.catppuccin-mocha;
Two consequences follow, and both are deliberate:
- Editing a generated config file by hand does nothing durable. The file is
a build product. Change
emanix.themeand rebuild instead — the manual edit is discarded on the next activation, which is the correct behaviour for a declarative system but surprising the first time. - The fallback is a real palette, not an error. An unknown theme name silently produces the default rather than failing the build. That is a trade: it keeps a typo from bricking a rebuild, at the cost of a typo being quiet. If you set a theme and nothing changes, check the spelling first.
4. State files
Three files in ~/.config/dotfiles/:
| File | Contents |
|---|---|
active-theme |
Currently applied theme (used by install/10-theme.sh on re-install) |
last-dark |
Most recent dark theme (source of truth for dot-theme-toggle when flipping to dark) |
last-light |
Most recent light theme |
5. Theme directory anatomy
Each theme is a self-contained directory, generated by Emanix itself —
lib/theme-tree.nix builds the whole tree as a single Nix derivation from the
palettes in lib/themes.nix — and it lives at a read-only Nix store path, not
in a checkout. That path is exposed as the option emanix.src.themesDir and,
for shell scripts, the environment variable EMANIX_THEMES_DIR; consumers may
override the option to point elsewhere.
$EMANIX_THEMES_DIR/catppuccin-mocha/ ├── variant # "dark" or "light" — single word ├── colors.toml # the palette; [ui] [ansi] [palette] sections ├── emacs-theme # Emacs theme to load, e.g. "catppuccin", "modus-vivendi" ├── btop.theme # btop theme → ~/.config/btop/themes/active.theme ├── gtk.conf # GTK_THEME + COLOR_SCHEME (sourced by dot-theme-set) └── pi-agent-theme.json # generated from colors.toml by lib/gen-pi-theme.py
Six files, no more. palette.sh (shell-var reference dump) and the per-theme
README.md (origin, contrast figures) both used to ship alongside these but
had no consumer and are gone.
emacs-theme is no longer hand-written either: it comes straight from each
palette's emacsTheme field in lib/themes.nix. And btop.theme is not
pre-rendered text copied verbatim — it is rendered from a template,
lib/templates/btop.theme.in, whose @slot@ placeholders lib/themes.nix
substitutes from the palette.
pi-agent-theme.json is generated at build time, inside the derivation,
by lib/gen-pi-theme.py (a script Emanix carries), reading the same
derivation's freshly generated colors.toml. This used to be regenerated at
every dot-theme-set switch by a consumer-side bin/gen-pi-theme.py; that
script is gone from the consumer, and nothing regenerates this file at switch
time any more — it is baked into the store path once, at build time.
These files are generated, not hand-written. lib/theme-tree.nix renders
them from the palette in lib/themes.nix, which is the single source of
truth for colour (26 slots per palette). To change a colour, edit the palette
and rebuild — do not edit these files directly, and there is nothing to edit
in place anyway: the tree is read-only.
ghostty.conf is not here: ghostty configs are rendered by Nix into
~/.config/ghostty/themes/<name>.conf, and dot-theme-set symlinks one of
those to ~/.config/ghostty/theme.conf. Home Manager deliberately does not
declare theme.conf — two owners for that path meant every rebuild silently
reverted the active theme.
Only ghostty and btop are themed by symlinking a rendered config into place.
Everything else uses a different mechanism per app: Emacs (including the EWM
top bar) is themed by calling emanix/theme-set in the running daemon; pi's
agent theme is generated from colors.toml at build time, inside
lib/theme-tree.nix's derivation, and dot-theme-set just points pi's
settings.json at the already-built file; zellij and
Claude Code follow the terminal's own ANSI palette rather than reading
anything theme-specific; GTK goes through gsettings; and Firefox chrome is
rendered by Nix from config.emanix.theme at build time. That is not the
same mechanism as ghostty: ghostty pre-renders all four palettes into
~/.config/ghostty/themes/, and the runtime switcher picks one of them.
Firefox renders exactly one palette into the generated userChrome.css, and
the runtime switcher (dot-theme-set) never picks — it has no way to touch
Firefox at all. Running dot-theme-set never changes Firefox, on restart or
ever; only editing emanix.theme in host config and rebuilding
does. "Themed by file symlink" was never a complete description even for the
two apps it did cover, and it undercounts what the system now reaches.
6. How the switch works
- Validates
$EMANIX_THEMES_DIR/<name>/exists; refuses unknown names. - Reads
variant(must bedarkorlight). - Writes
~/.config/dotfiles/active-theme=<name>andlast-<variant>=<name>. - Symlinks
~/.config/ghostty/themes/<name>.conf→~/.config/ghostty/theme.conf, andbtop.theme→~/.config/btop/themes/active.theme. - Symlinks
available/emanix-<variant>.kdl→active/theme.kdlin~/.local/share/dotfiles/zellij-themes/. Both definitions are namedemanix, so zellij'sthemeline never changes. - Points pi's
settings.jsonatpi-agent-theme.json, which was already generated fromcolors.tomlat build time — nothing is regenerated here. - Writes Claude Code's
themekey todark-ansi=/=light-ansi. - Sources
gtk.confand runsgsettingsforcolor-schemeandgtk-theme. - Calls
(emanix/theme-set "<name>")in the running Emacs daemon, resolvingemacsclientfromPATH. Emacs maps the name via$EMANIX_THEMES_DIR/<name>/emacs-theme. - Signals ghostty (
SIGUSR2) to reload.
zellij and Claude Code are themed by terminal ANSI colours, not by hex, so they follow whichever terminal renders them — including over ssh, where that terminal belongs to the client. Only the dark/light axis is written for them.
A note on the zellij theme files, so nobody "simplifies" them back. Both
emanix-dark.kdl and emanix-light.kdl use zellij's verbose per-declaration
format — text_unselected, ribbon_selected, and so on, each spelling out
base=/=background=/=emphasis_0=–=emphasis_3 as ANSI indices — rather than
zellij's shorter bare fg=/=bg=/=black=/=white=/… palette format. That
shorthand has no way to set =theme_hue, which then defaults to Dark;
impl From<Palette> for Styling in zellij's zellij-utils/src/data.rs derives the
background from palette.black, so a light theme written in the bare format
still renders its unselected rows on black. This was got wrong twice during
development. Both definitions here are instead modelled on zellij's own
bundled assets/themes/ansi.kdl (a 16-ANSI-colour per-declaration theme),
with the light variant exchanging the greyscale ends (indices 0↔15,
7↔8).
6.1. dot-theme-toggle
- Reads
active-theme, looks up its variant. - Applies the theme named in
last-<opposite-variant>viadot-theme-set. - If
last-<opposite>is empty (first-ever toggle to that variant): falls back to the first theme in$EMANIX_THEMES_DIR/*/with the opposite variant, warns on stderr.
The first toggle after a fresh install uses the fallback path. Every subsequent toggle reads the markers cleanly.
7. Adding a palette
Themes are generated from lib/themes.nix, so adding one is:
- Add a palette to
palettesinlib/themes.nix— 26 colour slots plus anemacsThemefield naming the Emacs theme package to load; copy an existing palette as the shape. - Check contrast before committing to it:
cd $EMANIX
PAL=$(mktemp)
nix eval --json --impure --expr 'let t = import ./lib/themes.nix { pkgs = import <nixpkgs> {}; };
in builtins.mapAttrs (n: p: p // { ansi = t.ansiSlots p; }) t.palettes' > "$PAL"
python3 tests/contrast-check.py < "$PAL"
- Rebuild and apply.
lib/theme-tree.nixrenders the new theme's entire six-file directory —emacs-themeandbtop.themeincluded — as part of the derivation; there is no separate generator step to run by hand.
sudo nixos-rebuild switch --flake .#<host> dot-theme-set <new-theme>
No code changes needed beyond the palette entry — dot-theme-set discovers
themes by listing $EMANIX_THEMES_DIR, and Emacs resolves the theme through
$EMANIX_THEMES_DIR/<name>/emacs-theme.
8. Scope
Theming is gated on emanix.gui for the surfaces that only exist on a graphical
host — the locker and the browser among them. The terminal is gated separately
on emanix.ghostty.enable, which is why a headless host that still wants a real
terminal gets themed correctly without pulling in a desktop's worth of
configuration. A WSL host is the usual case.
Emacs takes the palette through the same mechanism, so the editor and the compositor cannot disagree about what colour anything is.
9. Not handled by the theme system (yet)
- Firefox page content. Chrome is themed; page colours are left as
authored. Forcing the palette onto content is one pref
(
browser.display.document_color_use = 2infirefox.nix) and is the bigger accessibility lever, but it breaks sites that hardcode colours without honouringforced-colors. Deliberately not enabled — the reasoning is recorded in the repository. - LibreOffice / Electron apps
- Cursor theme (set once, not swapped per theme)
- Per-theme fonts. Every shipped palette uses JetBrains Mono — the plain
typeface, with
nerd-fonts.symbols-onlysupplying icon glyphs through fontconfig fallback. Do not ask for "JetBrainsMono Nerd Font": the patched family is not installed, and the name resolves to nothing. - No keybinding for
dot-theme-toggle. The Hyprland-era$mod+Shift+Twent with Hyprland; nothing is bound under EWM.
These are explicit omissions, not oversights.
10. Decisions and dead ends
Kept because the symptoms are memorable enough to be worth recognising, and because knowing why something is absent is cheaper than rediscovering it.
10.1. The removed post-set.sh hook
A post-set.sh hook was removed from the switcher on 2026-08-18.
dot-theme-set used to run a theme-specific post-set.sh at the end of its
sequence if the file was present and executable, but the theme tree is a
read-only Nix store path that lib/theme-tree.nix builds, and nothing in that
derivation ever wrote a post-set.sh. No theme, shipped or custom, could have
one under the current generator, so the hook could never fire — it was dead
code, not a design decision, and has been deleted rather than left dormant. If
it is ever wanted again, it would need lib/theme-tree.nix (or a per-host
override of emanix.src.themesDir) to start supplying the file, and the
dot-theme-set code to run it would need to be reintroduced alongside that.
10.2. The Helix drift caveat, resolved 2026-08-07
Helix used to be themed by sed-rewriting ~/.config/helix/config.toml. Because
that path was a stow symlink, sed followed it and dirtied the repo copy, so
every dot-theme-toggle away from the committed default left a modified line in
git status.
Helix is retired and every trace of it is gone — the module, base/helix/, the
$EMANIX_THEMES_DIR/*/helix-theme files and the sed block itself. That
sed-into-a-symlink defect is gone. Its counterpart used to be that
dot-theme-set also regenerated pi-agent-theme.json from colors.toml on
every switch inside the checkout's own themes/ tree — that no longer
happens: the tree lives at a read-only Nix store path
($EMANIX_THEMES_DIR) built once at rebuild time, so dot-theme-set has
nothing writable there left to dirty. dot-theme-set still rewrites
~/.claude/settings.json, an out-of-store symlink into the consumer's
checkout, per claude.nix — that one leaves a clean git status today only
because its output is deterministic (every switch reproduces the same bytes
for a given theme, not new ones), so there is nothing to commit. That is
incidental, not structural: a change to that generator that makes its output
non-deterministic would dirty the tree on every switch with no warning. Kept
here because the symptom (a mysteriously dirty repo after toggling themes)
is memorable enough to be worth recognising if it ever recurs.
10.3. Wallpaper, and the retirement of fragpaper
There is no wallpaper layer. EWM is the desktop and paints its own background; nothing in the theme system sets it.
Fragpaper — a GPU shader wallpaper generator — used to fill this role under
Hyprland. It was retired along with its themes/*/fragpaper.conf files, the
bin/fragpaper-* launchers and ioshi/i-intelligence/fragpaper.nix. By the time
it was removed it had already stopped running anywhere: on the T14 the user unit
was not-found, no process was alive, and there was no source checkout.
EWM does expose a Background layer through layer-shell (compositor/src/render.rs),
so a wallpaper client could be reintroduced later. Its absence is a preference,
not a limitation.