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 emanix/theme-set in Emacs, the authority for a
switch: it loads the Emacs theme, writes the state markers, symlinks every
per-app theme file, calls gsettings, runs the consumer's own registered
side effects and signals ghostty. dot-theme-set <name> is a consumer-side
wrapper that reaches it through emacsclient. Flipping between dark and
light is emanix/theme-toggle, bound to C-c v; dot-theme-toggle wraps
that the same way.
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 (or its Emacs binding, C-c v) 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 (or M-x
emanix/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 are consumer scripts, not part of the distribution, and both are now
thin wrappers: each resolves emacsclient and calls into the running Emacs
daemon rather than doing the work itself. Emanix owns the palettes, the
generated theme tree, and the authority for a switch, emanix/theme-set; the
flake that consumes Emanix supplies dot-theme-set and dot-theme-toggle as
emacsclient wrappers around it and its partner, emanix/theme-toggle. See
Keybindings for the same split applied to the
agent binding.
emanix/theme-toggle is bound to C-c v. Nothing under EWM binds
dot-theme-set or dot-theme-toggle directly — there is no need to, now
that the toggle has a real keybinding one layer down, in Emacs.
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. - A typo is an eval error, not a silent default.
emanix.themeis an enum, so an unknown name fails the build and names the valid set. The value is a lookup key for the plan, and the consumers of that plan — the Firefox chrome and the startup seed — degrade quietly on a name that matches nothing, so a silent fallback was the wrong trade.
4. State files
Three files in ~/.config/dotfiles/:
| File | Contents |
|---|---|
active-theme |
Currently applied theme (read by Emacs at startup to converge a host with no runtime state onto it) |
last-dark |
Most recent dark theme (source of truth for emanix/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 (parsed by Emacs, not sourced)
├── swaylock.conf # → ~/.config/swaylock/config
└── pi-agent-theme.json # generated from colors.toml by lib/gen-pi-theme.py
Seven 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.
swaylock.conf is the newest arrival, added 2026-09-11 alongside the rest of
this switch. Locking used to be themed once, at build time, by
swaylock.nix rendering a single palette straight into the generated
config — that module is deleted, and the locker now follows a runtime switch
like every other symlinked app.
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 theme 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 Emacs symlinks one of those to
~/.config/ghostty/theme.conf as part of emanix/theme-set. Home Manager
deliberately does not
declare theme.conf — two owners for that path meant every rebuild silently
reverted the active theme.
Ghostty, btop and swaylock are themed by symlinking a rendered config into
place. Everything else uses a different mechanism per app: Emacs (including
the EWM top bar) is the switch — it applies its own theme directly, inside
emanix/theme-set, rather than being called by something else; pi's agent
theme is generated from colors.toml at build time, inside
lib/theme-tree.nix's derivation, and dot-theme-apply-pi, called from
emanix/theme-apply-functions (see below), 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
emanix/theme-set picks one of them. Firefox renders exactly one palette
into the generated userChrome.css, and the runtime switcher
(emanix/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
originally covered, and it undercounts what the system now reaches.
6. How the switch works
dot-theme-set <name> is a wrapper: it resolves emacsclient and calls
(emanix/theme-set "<name>") in the running Emacs daemon. Everything below
happens inside that one call, in Emacs, not in bash.
- Plans the switch: validates
$EMANIX_THEMES_DIR/<name>/exists and reads itsvariant(must bedarkorlight). An unusable name or tree produces no plan, andemanix/theme-setreturnsnilwithout touching anything else in this list. - Loads the Emacs theme named in
<name>/emacs-theme, disabling whatever was enabled first — resolved and confirmed loadable before anything is disabled, so a bad theme name cannot leave the session themeless. - Writes
~/.config/dotfiles/active-theme=<name>andlast-<variant>=<name>. - Symlinks
~/.config/btop/themes/active.themeand~/.config/swaylock/config, whose sources are in the theme directory, and~/.config/ghostty/theme.conf, whose source is the pre-rendered~/.config/ghostty/themes/<name>.conf— a host without ghostty or without the locker enabled just makes fewer of these. - Symlinks
available/emanix-<variant>.kdl→active/theme.kdlin~/.local/share/emanix/zellij-themes/. Both definitions are namedemanix, so zellij'sthemeline never changes. - Parses
gtk.confasKEY=VALUEdata — notsource=d, so a theme tree cannot execute anything inside this Emacs — and runs =gsettingsforcolor-schemeandgtk-theme. - Runs every function on
emanix/theme-apply-functions, the consumer extension point (see below). A function that signals is reported and skipped; the rest of the switch continues. - Signals ghostty (
SIGUSR2) to reload. btop, zellij and swaylock all read their theme file the next time they start, so nothing else needs signalling.
Every step above is wrapped individually and reports failures rather than signalling them upward: this runs from Emacs init on the host where Emacs is the compositor, so an uncaught error partway through must not abort the rest of startup.
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. emanix/theme-apply-functions
The consumer extension point. A defcustom holding a list of functions, each
called with the plan plist (:name, :dir, :variant, :emacs-theme,
:links and :gtk) after state, links and gsettings, but before the ghostty
reload signal. The distribution
registers none of its own — side effects for programs Emanix does not
install belong here, not folded into emanix/theme-set itself, which must
stay ignorant of anything a consumer declares.
This flake's home/scott/emacs/personal.el registers one function on it,
scott/theme-apply-personal, which shells out to bin/dot-theme-apply-pi
(passed :name and :dir, to symlink pi's agent theme and rewrite its
settings.json) and bin/dot-theme-apply-claude (passed :variant, to set
Claude Code's theme key to dark-ansi=/=light-ansi). Both scripts are
extracted from what used to be inline steps of dot-theme-set itself, before
2026-09-11, and both now exit nonzero on failure instead of the || true
that used to swallow it; the registered function re-raises that as an Emacs
error, which the hook runner below catches, reports, and continues past.
A function that signals here never aborts the theme switch itself, and signalling from one registered function does not stop the others from running: the colours are the part you can see, and a broken pi symlink must not look like a failed theme.
6.2. dot-theme-toggle
A wrapper, like dot-theme-set: it resolves emacsclient and calls
(emanix/theme-toggle), which is also bound to C-c v in Emacs. The logic
below runs there, not in this script.
- Reads
active-theme, looks up its variant. - Applies the theme named in
last-<opposite-variant>viaemanix/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, messages if none exists.
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 seven-file directory —emacs-theme,btop.themeandswaylock.confincluded — 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.
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-apply-claude, run from
emanix/theme-apply-functions (extracted out of dot-theme-set itself on
2026-09-11), 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 the module that wired it in. 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.