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 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:

  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. 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

  1. Validates $EMANIX_THEMES_DIR/<name>/ exists; refuses unknown names.
  2. Reads variant (must be dark or light).
  3. Writes ~/.config/dotfiles/active-theme = <name> and last-<variant> = <name>.
  4. Symlinks ~/.config/ghostty/themes/<name>.conf~/.config/ghostty/theme.conf, and btop.theme~/.config/btop/themes/active.theme.
  5. Symlinks available/emanix-<variant>.kdlactive/theme.kdl in ~/.local/share/dotfiles/zellij-themes/. Both definitions are named emanix, so zellij's theme line never changes.
  6. Points pi's settings.json at pi-agent-theme.json, which was already generated from colors.toml at build time — nothing is regenerated here.
  7. Writes Claude Code's theme key to dark-ansi=/=light-ansi.
  8. Sources gtk.conf and runs gsettings for color-scheme and gtk-theme.
  9. Calls (emanix/theme-set "<name>") in the running Emacs daemon, resolving emacsclient from PATH. Emacs maps the name via $EMANIX_THEMES_DIR/<name>/emacs-theme.
  10. 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

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

  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 six-file directory — emacs-theme and btop.theme 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.
  • No keybinding for dot-theme-toggle. The Hyprland-era $mod+Shift+T went 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.