Theming

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:

  1. Editing a generated config file by hand does nothing durable. The file is a build product. Change emanix.theme and 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.
  2. A typo is an eval error, not a silent default. emanix.theme is 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.

  1. Plans the switch: validates $EMANIX_THEMES_DIR/<name>/ exists and reads its variant (must be dark or light). An unusable name or tree produces no plan, and emanix/theme-set returns nil without touching anything else in this list.
  2. 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.
  3. Writes ~/.config/dotfiles/active-theme = <name> and last-<variant> = <name>.
  4. Symlinks ~/.config/btop/themes/active.theme and ~/.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.
  5. Symlinks available/emanix-<variant>.kdl → active/theme.kdl in ~/.local/share/emanix/zellij-themes/. Both definitions are named emanix, so zellij's theme line never changes.
  6. Parses gtk.conf as KEY=VALUE data — not source=d, so a theme tree cannot execute anything inside this Emacs — and runs =gsettings for color-scheme and gtk-theme.
  7. 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.
  8. 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.

  1. Reads active-theme, looks up its variant.
  2. Applies the theme named in last-<opposite-variant> via emanix/theme-set.
  3. 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:

  1. Add a palette to palettes in lib/themes.nix — 26 colour slots plus an emacsTheme field naming the Emacs theme package to load; copy an existing palette as the shape.
  2. 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"
  1. Rebuild and apply. lib/theme-tree.nix renders the new theme's entire seven-file directory — emacs-theme, btop.theme and swaylock.conf included — 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 = 2 in firefox.nix) and is the bigger accessibility lever, but it breaks sites that hardcode colours without honouring forced-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-only supplying 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.