--- type: reference title: Noctalia and Niri description: "The noctalia shell on niri across workstations: config layers, remote IPC, custom palettes, output rotation and pinning." tags: [desktop, noctalia, niri, blankpad, workstation, ghostty] timestamp: 2026-10-05T00:00:00-07:00 --- # Noctalia and Niri Noctalia (`/usr/bin/noctalia`) is the desktop shell on niri, running on both the workstation and blankpad. Since the 2026-09-10 unification both run the **bare `noctalia` v5 binary** (niri `spawn-at-startup "noctalia"`); the old Quickshell-hosted v4 architecture (`qs -c noctalia-shell`, JSON config) is retired on both hosts. ## Config layers - Hand-edit declarative layer: `~/.config/noctalia/.toml` (e.g. `config.toml`). Main config: `~/.config/noctalia/config.toml`; the niri config includes `noctalia.kdl` for display settings. - Runtime state file `~/.local/state/noctalia/settings.toml` loads LAST and **overrides declarative values for the same keys — never hand-edit it**; UI/IPC writes land there. - Main reference: `noctalia-reference` facts — IPC `noctalia msg ` replaced the `qs -c noctalia-shell ipc call` wrapper; app templates live at `/usr/share/noctalia/assets/templates/` (gtk, ghostty, alacritty, hyprland, sway, wezterm, helix, btop, starship, qt, kde, cava, scroll, mango, umbriel, foot, emacs, kitty); palette generation `noctalia theme [image]`. ## Remote IPC over ssh (the D-Bus trap) `noctalia msg ` resolves the running instance via the D-Bus session bus. sshd-spawned non-login shells lack `DBUS_SESSION_BUS_ADDRESS`, so plain ssh exec prints `error: noctalia is not running` even when the shell IS running — do not conclude "shell dead" from this error. Working remote procedure (runs in the systemd user-manager context where the bus address exists): ``` systemd-run --user --pipe --wait noctalia msg [args] ``` Verified: `msg status` returns live JSON state; unknown panel ids return an error listing all valid panel ids (clipboard, control-center, launcher, polkit, session, setup-wizard, test, tray-drawer, wallpaper). Verified v5 msg verb grammar (keybinds-safe): `panel-toggle ` `session lock|suspend|lock-and-suspend|logout|reboot|shutdown` `volume-up [step]`, `volume-down`, `volume-mute`, `mic-mute` `media next|previous|playPause` (graceful no-op, rc 0, with no active player), `brightness-up [target] [step]`, `brightness-down` `settings-open [context]`, `settings-toggle [context]`, `config-reload` `wallpaper-get [connector]`, `wallpaper-set `, `wallpaper-next` `color-scheme-get|set`, `theme-mode-get`, `templates-apply` `screenshot-fullscreen`. **Wallpaper directory semantics:** empty/unset `[wallpaper] directory` resolves to XDG Pictures via user-dirs.dirs; an explicit directory key overrides. Fleet default pattern: point `directory` at the repo-tracked wallpapers dir so git-tracked wallpapers serve the shell without copying files. After writing config.toml over ssh the file watcher hot-reloads automatically; `config-reload` via systemd-run forces it immediately. Note: the *current* wallpaper stays the built-in default until the next rotation or an explicit `wallpaper-next`; `wallpaper-get` then reporting a path inside the configured directory is the confirmation the config took effect. **Lock chain (unified):** swayidle-lock.service ExecStart on both hosts: `/usr/bin/swayidle -w timeout 300 '/usr/bin/noctalia msg session lock' before-sleep '…' after-resume '…'`. `msg` resolves via the session bus, so no WAYLAND_DISPLAY pinning is needed in unit context. ## Custom-palette recolor (without restarting the session) Why custom, never the community cache: `~/.local/state/noctalia/community-palettes/*.json` is a **sync cache** — `.catalog/palettes.json` carries an `md5` per palette and noctalia re-fetches from `https://api.noctalia.dev/palettes`, so edits there get reverted. Custom palettes live at `~/.config/noctalia/palettes/.json` (`NOCTALIA_CONFIG_HOME` then `XDG_CONFIG_HOME` honored) and are never synced. Missing/invalid file → silent fallback to the builtin palette. Palette JSON shape = the community file's shape: `dark`/`light` maps of `mPrimary`, `mOnPrimary`, `mSecondary`, `mTertiary`, `mError`, `mSurface` `mSurfaceVariant`, `mOutline`, `mShadow`, `mHover` plus a `terminal` block (`foreground`, `background`, `normal.{black..white}`, `bright.{…}` `cursor`, `cursorText`, `selectionFg`, `selectionBg`). NOTE: `noctalia theme -o` emits M3 snake_case tokens (`on_primary_container`, …) — that is the *generator* format, not this one. Copy an existing community JSON and mutate with `jq`. Procedure: 1. Read the live scheme first — never assume: `noctalia msg color-scheme-get` (e.g. `community Gruber Darker`); `noctalia msg theme-mode-get`. 2. Back up settings: `cp -a ~/.local/state/noctalia/settings.toml /tmp/noctalia-settings-backup-$(date +%s).toml`. 3. Build the palette from the current one (jq keeps structure): ```sh mkdir -p ~/.config/noctalia/palettes jq '.dark.mPrimary = "#f43841" | .dark.terminal.normal.yellow = "#f43841" | .dark.terminal.bright.yellow = "#ff4f58" | .dark.terminal.foreground = "#f43841"' \ "$HOME/.local/state/noctalia/community-palettes/Gruber%20Darker.json" \ > ~/.config/noctalia/palettes/"Gruber Red.json" ``` Prefer a hex already present in the palette (its own `mError` red) so the theme stays internally consistent. Set BOTH `normal` and `bright` variants, and both `dark` and `light` if the user toggles modes. 4. Switch (persists `theme.source = custom` + `theme.custom_palette`): `noctalia msg color-scheme-set custom "Gruber Red"` → `ok`; confirm with `color-scheme-get`. 5. Re-render every configured template: `noctalia msg templates-apply`. Regenerates `~/.config/ghostty/themes/noctalia` `~/.config/gtk-{3,4}.0/noctalia.css`, kcolorscheme, foot, wezterm, starship, etc. Symlinked-into-dotfiles paths show up as modified files worth committing. 6. Reload surfaces: compositor — `niri msg action load-config-file` (`reload-config` is NOT a valid niri subcommand); ghostty — see the trap below. Verification: `ghostty +show-config | grep -E '^(foreground|palette)'` (proves the theme file resolves — note it prints only the delta from defaults; cross-check with `ghostty +show-config --default` before concluding a key is unset); `noctalia config validate` → `✓ Config is valid`; visual via `noctalia msg screenshot-fullscreen` (saves to `~/Pictures/screenshot_.png` and ALSO overwrites the clipboard with the PNG — warn the user); `grep -c ~/.config/noctalia/palettes/*.json` → 0 for no leftovers. Track it in the dotfiles repo (`~/.omp`, the command-center — `scripts/deploy.sh` is a stow-equivalent over `dotfiles//` trees): mirror the shape for the new palette so `git pull` + `deploy.sh` is the whole propagation story — move the JSON into `~/.omp/dotfiles/noctalia/.config/noctalia/palettes/` and symlink it back into `~/.config/noctalia/palettes/`; re-set the custom palette (re-reads through the symlink). Do the link by hand for one file instead of running `deploy.sh` — sync mode quarantines and replaces every divergent live file. Traps: - **NEVER `kill -USR1` ghostty to reload.** It does not reload config on this fleet — it kills/respawns the terminal (took out Frank's window 2026-09-13). Correct: the default keybind `ctrl+shift+,` = `reload_config` (per `ghostty +show-config --default`). Existing windows keep old colors until reloaded; new windows inherit immediately. - Palette hex values are NOT a proxy for what the user sees: in `Gruber Darker` the *accent* (`mPrimary`) is yellow `#ffdd33`, so "change the yellow" means the accent too, not just terminal index 3. - Saturating terminal text makes ANSI 1/9 indistinguishable from default text (grep highlights flatten) — flag it and offer a lighter red. - Contrast sanity: `#f43841` on `#181818` ≈ 4.6:1 (dark-on-color beats white-on-color here, ~3.9:1 — keep `mOnPrimary` dark). - Revert = `noctalia msg color-scheme-set community ""`; the community cache is untouched by the whole flow. - Generated theme files symlinked into the dotfiles repo (`gtk-3.0`/`gtk-4.0` `noctalia.css`, ghostty theme) show up as tracked modifications after `templates-apply`; commit them together with the new palette JSON so every machine gets the same recolor. ## Offline template render (when templates-apply never ran) Failure signature: niri `failed to read included config from ~/.config/niri/noctalia.kdl: No such file or directory` — the desktop runs but with DEFAULT config (niri 26.04 falls back, does not exit): no custom keybinds/rules, no noctalia autostart, default output placement. ghostty: `theme "noctalia" not found, tried ~/.config/ghostty/themes/noctalia`. alacritty: import of `~/.config/alacritty/themes/noctalia.toml` missing. Root cause pattern: upstream dotfiles commits theme switches (niri include, ghostty `theme = noctalia`, alacritty import) that depend on noctalia TEMPLATE-GENERATED files. Those files are per-host outputs (rendered from the local palette) and are intentionally NOT in the repo; on any host where `templates-apply` never ran, they don't exist. Fix (offline, works while a session runs) — built-in templates live at `/usr/share/noctalia/assets/templates/` (declared in `builtin.toml`); render each needed one with the live palette: ```bash # niri include (plain host-local file; do NOT symlink into repo — regenerated per host) noctalia theme --theme-json ~/.config/noctalia/colors.json \ --render "/usr/share/noctalia/assets/templates/niri/niri.kdl:$HOME/.config/niri/noctalia.kdl" mkdir -p ~/.config/ghostty/themes && \ noctalia theme --theme-json ~/.config/noctalia/colors.json \ --render "/usr/share/noctalia/assets/templates/ghostty/ghostty:$HOME/.config/ghostty/themes/noctalia" mkdir -p ~/.config/alacritty/themes && \ noctalia theme --theme-json ~/.config/noctalia/colors.json \ --render "/usr/share/noctalia/assets/templates/alacritty/alacritty.toml:$HOME/.config/alacritty/themes/noctalia.toml" ``` All template tokens (primary, surface, error, primary_container, …) resolve from the flat 16-key colors.json; container tokens are derived, never blank. Verify: `niri validate` → rc 0; `ghostty +validate-config` → rc 0; next login journal shows `loaded config from config.kdl` with NO parse WARN, custom output placement (`niri msg outputs` vs display.kdl), noctalia autostarted (`pgrep noctalia`). Traps: **`noctalia msg templates-apply` on 5.0.1 returns "ok" but applies NOTHING** (this build's opt-in config schema does not act on it) — do not trust its exit code, use the explicit `--render` calls above. Niri config errors at boot are NOT fatal — the compositor keeps running on defaults, which reads as "some settings silently stopped working", not a crash. Regenerate after any palette change; the rendered files are static snapshots. ## Niri output rotation and pinning Verified on blankpad, niri 26.04 (two PM1601 externals + eDP-1 laptop panel). **Ground truth first:** `niri msg outputs` (connector names, current mode, scale, transform, logical pos/size) and `niri --version`. Output **names are connector names** (`eDP-1`, `DP-2`, `HDMI-A-1`), not EDID model strings — the EDID strings printed before them are decoration. On blankpad the live config splits via `include "./cfg/*.kdl"` from `~/.config/niri/config.kdl` and `cfg/display.kdl` is a **symlink into the dotfiles repo** — edit the repo path (that is what gets committed); check with `ls -ld`. **Transform direction is counter-clockwise** (`niri msg output transform --help`): `90` = content rotated 90° CCW ⇒ the panel's **top bezel sits on the LEFT**; `270` = top bezel RIGHT; `180` = upside down; `flipped-90`/`flipped-270` = mirrored variants. `90` vs `270` is a physical-stand fact no tool can read (portable monitors expose no orientation in EDID/MCCS) — pick one, apply, and hand the user the one-liner to flip; don't stall on the question. Two traps when editing the config: 1. **An `output "X" { … }` block resets every property you don't restate to its default.** Pinning position on `eDP-1` without repeating `scale 1.25` silently drops the laptop panel to scale 1.0. Restate `scale` (and `mode`) in every block you touch. 2. **Niri re-runs auto-layout for outputs with no pinned position.** Pinning only the externals shoved `eDP-1` from `0,0` to `3936,0`. Pin every output's position to keep the left-to-right order. Positions are logical pixels at each output's own scale; x-offsets chain (eDP-1 logical width 1536 → DP-2 at 1536 → +1200 portrait width → HDMI-A-1 at 2736): ```kdl output "DP-2" { scale 1 transform "90" position x=1536 y=0 } output "HDMI-A-1" { scale 1 transform "90" position x=2736 y=0 } output "eDP-1" { scale 1.25 position x=0 y=0 } // MUST restate scale ``` Apply and verify: ```bash niri validate -c ~/.config/niri/config.kdl # exit 0 niri msg action load-config-file # live reload; NOT reload-config sleep 1; niri msg outputs | grep -E '^Output|Transform|Logical|Scale' ``` Success looks like `Logical size: 1200x1920` (swapped) plus `Transform: 90° counter-clockwise` on each rotated panel, positions matching the intended chain. One-off rotation without touching config (temporary): `niri msg output DP-2 transform 90`. Commit via the dotfiles repo (`~/.omp`, `main`; push to the local Gitea permitted): `git pull --ff-only` → add the display.kdl → commit → push. Pitfalls: `reload-config` is not a niri subcommand; never SIGUSR1 a terminal to "reload" (niri config reloads do not touch ghostty themes); no man page for niri on this host — `niri --help` and `/usr/share/doc/niri/default-config.kdl` are the docs. ## GPU RGB over I2C (EVGA iCX3) Turn RGB on/off (or read it back) on an EVGA FTW3 / iCX3-controller GPU (the 3090 on dankclaw) using the GPU's own nvidia i2c bus. No display engine, no X, no daemon — the mode is EEPROM-saved and survives reboot. - **Find the controller:** the nvidia i2c driver exposes the card's lighting ports as `/dev/i2c-N`; on the 3090 FTW3 that is ports 1,5,6,7,8 → i2c-1..i2c-5, and only i2c-1 carries a live slave (NVIDIA port 1). Probe each bus. The iCX3 MCU answers at address **0x2D**. `/dev/i2c-*` are `root:i2c 660` — run with `sudo` (dank has passwordless sudo). - **Protocol (raw I2C_RDWR, NOT the SMBus ioctls)** — write the register byte first, then the payload: - enable `0xB2` write `04 C6 EB EA 15` (required once before mode writes) - mode `0xC0` write `09 m0 m1 m2 m3 .. .. .. .. SYNC`; m0..m3 are the four zone modes, `MODE_OFF = 0x00`; OFF = `09 00 00 00 00 FF FF FF FF 00` - save `0x90` write `04 9E EB 00 90` (commit to EEPROM — the reboot-persistence step) - fw `0xB1` block read (firmware version) - Read the mode back: write reg `0xC0`, then read 16 bytes; bytes 1..4 are the zone modes (0x00 = OFF), bytes 6..9 the per-zone LED counts. - **Verify persistence:** after a reboot, read the mode — all four zone modes `0x00` means the EEPROM save held (confirmed 2026-09-19 post-reboot). - **Tool:** a ~90-line I2C_RDWR reader/writer lived at `/tmp/evga_raw.c` (`evga_raw mode|off|save|enable|fw`) — `/tmp` is wiped by reboot, so recreate it if needed; OpenRGB's `EVGAGPUv3Controller.cpp` carries the same protocol plus the full colour API. - Gotchas: raw I2C_RDWR only (the iCX3 answers the raw form, not the SMBus ioctls); the `enable` write must precede the first mode write in a session; without the `save` write the mode is RAM-only and resets on power cycle. ## Related - [[Command Center Dotfiles]] — the stow-equivalent deploy this page links into - [[Hermes]] — the agent that drove the CDP daemon alongside this desktop - [[GGUF Compatibility]] — unrelated but shares the "read the file, not the name" lesson