# AGENTS.md Guidance for agents working in this repository. ## What this is One visual identity for a Hyprland desktop: Catppuccin Macchiato, Noto Sans for UI text, Inconsolata Nerd Font Mono for monospace. It covers rofi, Hyprland window borders, conky, Qt5/Qt6, GTK3/GTK4, waybar, kitty, Sublime Text, hyprlock, neovim, btop, Joplin and Feishin. The repo holds the canonical copies. `./install.sh` symlinks them into place, so the working config cannot drift from what is committed. Everything outside `~/.config/rofi/udt/` and `~/bin/` was edited in place; `docs/MIGRATION.md` is the record of what changed and where the backups are. The rofi menus' launch scripts live in the sibling `rofi-scripts` repo, which symlinks them into `~/bin/` itself. This repo holds their layouts only; the per-menu title, message and `-theme-str` overrides are the scripts' business. Sibling checkouts are wired from here, each skipped when not cloned: `conky-theme-udt` (five links into `~/.config/conky/`) and `hypr-theme`, which is the whole of `~/.config/hypr` as one symlink. A real `~/.config/hypr` directory is left alone with a warning. `grub-theme-udt` is SDDM's shape: udt-palette renders its `theme.txt.in`, `bin/udt-grub` runs its `build` as the user, then prints the root copy. A copy, not a link, because GRUB reads `/boot` before `/home` is mounted, so it goes stale on a scheme or wallpaper change. `install.sh` runs `udt-grub` in the foreground and `udt-accent` starts it detached, and when an installed theme differs from the new build the root command lands in the steps window. Its panel title is `PRETTY_HOSTNAME` from `/etc/machine-info`, which `bin/udt-machine-name` writes (root, run by the user; there is no hostnamectl without systemd). It rewrites that one key and keeps the rest. Live files that mix theme keys with anything else are merged, never replaced: `install.sh`'s `ini_merge` writes every key in `templates/qt-gtk/qt5ct.conf`, `qt6ct.conf` and `gtk3-settings.ini` into the live file and keeps every other line. So those templates hold theme keys only; icons and cursor are the appearance drawer's. `tests/ini-merge.sh` checks the merge. neovim runs `colorscheme udt`, a generated `templates/nvim/udt.lua` linked into `~/.config/nvim/colors/`. It is catppuccin/nvim with all 26 palette colours overridden, so every highlight group follows the scheme without restating one. `install.sh` rewrites only the `colorscheme` line of `init.vim`. ## One palette, many consumers Colours are written down once. `palette/.conf` holds them under the scheme's own names and `palette/roles-.conf` says what each is for. `bin/udt-palette` renders that pair into every consumer's own syntax; `install.sh` runs it first, before anything is linked. **The scheme selection lives outside the repo**, in `~/.config/udt/roles.conf`, seeded from `palette/roles.conf.default` on a first install and never overwritten after. That is deliberate: which theme a machine runs is local state, so switching does not dirty the working tree and two machines sharing this repo can differ. It is the one exception to this repo holding the canonical copy of everything. Ten schemes ship: `macchiato`, `frappe`, `mocha`, `tokyo-night`, `nord`, `dracula`, `material-ocean`, `material-palenight`, `material-darker`, `candy-night`. Switching is one line in `~/.config/udt/roles.conf` plus `./install.sh`. All ten are dark. A light scheme is not a role remap: homepage is pinned `theme: dark` and its generated card overrides target the dark-mode class, and `install.sh` would need to flip that setting too. Generated files are gitignored, because tracking them would turn every scheme switch into a diff. Editing one is pointless: the next install overwrites it. The generated set is `rofi/udt/palette.rasi`, `templates/conky.conf`, `templates/waybar/theme.css`, `templates/terminal/kitty-theme.conf`, `templates/homepage/custom.css`, `templates/kvantum/theme.{kvconfig,svg}`, `templates/kde/udt.colors`, `templates/sublime/udt.sublime-color-scheme`, `templates/sublime/udt.sublime-theme`, `templates/gtk/gtk{3,4}.css`, `templates/gtk/index.theme`, `templates/gtk/assets/*.svg`, `templates/hyprlock/hyprlock-palette.conf`, `templates/nvim/udt.lua`, `templates/btop/udt.theme`, `templates/joplin/userchrome.css`, `templates/feishin/UDT.json` and `bin/udt_colors.py`. Sublime is two outputs, not one. The **colour scheme** is the editor pane and tracks the wallpaper accent. The **theme** is the UI chrome, and is an override layered over Material Behave: that theme hardcodes 419 rules of `[r,g,b]` literals with no variables, so the chrome surfaces are restated rather than remapped, and its geometry and icons are inherited untouched. Its accent is fixed, because Sublime reads a theme once per window. **The theme installs under the name of the theme it overrides**, `Material Behave.sublime-theme`, because Sublime merges `.sublime-theme` files by filename across packages with `Packages/User` last. Installed as `udt.sublime-theme` it would be an unselected theme that never applies. It also has to come after Material Behave's own `material_theme_contrast_mode` block, which re-tints the sidebar and status bar, and carry no settings gate of its own, or contrast mode wins. Removing the Materialize package leaves the override inert. Qt apps reach the palette through Kvantum; GTK has its own generated theme. Kvantum's theme is a `.kvconfig` of colours plus a `.svg` of widget artwork with the colours baked into the paths, so both are templates: the twelve palette colours are placeholders and the fifteen neutral greys are left alone, because those are shading rather than theme colour. It installs to `~/.config/Kvantum/udt/` under one fixed name for every scheme, so switching needs no change to `kvantum.kvconfig` and leaves no stale theme behind. Derived from `catppuccin-macchiato-lavender`, which this desktop was already running by hand. **GTK is generated, not installed from upstream.** It used to sit on a hardcoded `catppuccin-macchiato-lavender-standard+default` from `catppuccin/gtk`, which meant every GTK app stayed Macchiato while the rest of the desktop moved to another scheme. That was a gap rather than a drift: nothing ever generated or installed a GTK theme, though this file claimed GTK was covered. The two stylesheets and their 67 widget assets are now templates rendered into `~/.themes/udt`, under one fixed name for every scheme so gsettings and the GTK4 symlink are set once. See `templates/gtk/README.md` for what is substituted and what is deliberately left as upstream drew it. **KDE Frameworks apps (kcalc etc.) skip qt6ct twice off Plasma.** `KStyleManager` forces Breeze unless kdeglobals `[KDE] widgetStyle` is set, and `KColorSchemeManager` applies BreezeLight unless `[UiSettings] ColorScheme` names a scheme; qt6ct never passes the portal's `prefer-dark` to Qt. Either alone left kcalc white. `install.sh` installs a generated `udt.colors` into `~/.local/share/color-schemes/` and sets both keys with `kwriteconfig6`. **Qt apps only read a theme at startup**, so a running one keeps the old colours until restarted. `install.sh` cannot signal them and does not try. Obsidian, Typora, btop, Joplin and Feishin are installed by `bin/udt-appthemes`, not by a plain copy. Obsidian gets a **snippet**, not a theme, so it layers over whatever theme a vault uses; enabling it means appending to `enabledCssSnippets` in that vault's `appearance.json`, never rewriting the list, or the vault loses the snippets it already had. Vaults come from Obsidian's own registry, `obsidian.json`, not a directory scan, which found abandoned vaults. Typora's theme is derived from its Dracula theme, whose 350 rules all route through `:root` variables, so only that block is templated. btop gets `~/.config/btop/themes/udt.theme` and only the `color_theme` line of `btop.conf` is rewritten; btop writes that file back on exit, so restart a running one after a switch. Joplin gets `userchrome.css`, which overrides the `--joplin-*` custom properties its UI is built on, with `!important` because Joplin's own `:root` block lands after it; parts still styled from its JS theme object keep the base theme, so that must stay a dark one. A `userchrome.css` udt did not write is left alone. Feishin gets `Themes/UDT.json`, extending its built-in Catppuccin Mocha; the folder is watched, so after selecting UDT once in its settings, a scheme switch reaches a running Feishin live. Signal, Discord and Spotify cannot be consumers: they ship closed bundles with no user-CSS hook, and patching a signed app bundle breaks on every update. The homepage stylesheet and the wallpaper are the outputs that leave this machine. `bin/udt-homepage` scp's both to `homepage:hp-stage/`, the wallpaper as `background.` with its own extension, and the finish command rewrites `settings.yaml`'s `background.image` line to that name. `udt-accent` starts it detached on every run, so it follows both a wallpaper change and a scheme switch without holding either up; a five second timeout and silence when the host is down keep it harmless off that network. It stages only: `/opt/homepage` is root-owned, and the final copy plus a `systemctl restart homepage` is a manual step, whose commands land in the steps window. An identical stylesheet and wallpaper pair is not staged twice (`~/.cache/udt/homepage.staged`), so a run that changed neither, an icon switch for one, raises no tab. homepage's `settings.yaml` must keep `color: gray`, the theme class the generated CSS targets. The last is imported by `udt-accent`, which is why its accent table follows the scheme. Roles are a flat namespace, so a name collides across sections: conky's outline is `body_outline` because waybar already has `outline`. A role value is a palette name with two optional modifiers, `name/75` for 75% alpha and `name*50` for half brightness. The second exists only because GTK's `shade(@main-bg, 0.5)` has no palette name to point at: half-brightness crust is darker than the darkest colour any of these schemes ships. **Adding a scheme means adding two files, never editing a consumer.** Every scheme must define the same role set, and `udt-palette --selftest` enforces that, then has rofi parse every theme under every scheme. Both checks exist because both failures shipped: a role set can drift silently, and a palette that resolves can still emit a theme rofi refuses to parse, which stops every launcher on the desktop. ## The steps window What this repo cannot do itself, it hands over: `bin/udt-steps NAME` takes a consumer's pending steps on stdin, writes them to `~/.cache/udt/steps/NAME` and pokes quickshell's desktop shell (`ipc call appearance steps`, the verb kept from the appearance shell it replaced), which shows one tab per file in a window centred on DP-3. Empty stdin clears the tab, so each producer reports on every run and a consumer that caught up disappears by itself. The producers are `udt-grub`, `udt-homepage`, `install.sh`'s SDDM block (only the steps still missing), and `install.sh` itself, which reruns itself as a child and hands its whole output to an `install` tab whenever it failed or printed a command, whoever called it, and clears the tab otherwise, so a login's wallpaper restore does not reopen an old recap. The usual caller is the appearance panel, not a terminal. So its output is written for that tab, a summary rather than a file list. A line indented two spaces is a command with a copy button; the rest is prose. It replaced notification balloons, which could not be kept open while pasting into a root shell. ## The accent One colour moves: `@accent`. `bin/udt-accent` reads the current wallpaper, extracts its signature colour, and snaps it to the nearest of ten Catppuccin accents by perceptual hue in CIELAB. `wallp` calls it on every wallpaper change. It writes nine things: `~/.cache/udt/udt-accent.rasi` (rofi), `~/.cache/udt/udt-border.lua` (Hyprland), `~/.cache/udt/hyprlock-border.conf` (hyprlock, the same three hues as the window border, as a hyprlang gradient), `~/.cache/wal/colors.json` (Firefox, via pywalfox), `~/.cache/udt/udt-palette.qml` (quickshell), `~/.local/share/udt/sddm-theme.conf` (the SDDM greeter, palette plus wallpaper, read through a root-made `theme.conf.user` symlink), and the accent substitution in three generated files: Sublime's installed `Packages/User/udt.sublime-color-scheme`, grub-theme-udt's `theme.txt` (its `# udt:` line) and `templates/homepage/custom.css` (its `--udt-accent` line). It then starts `udt-homepage` and `udt-grub` detached, for the two outputs that need a root step on another filesystem or host, and `udt-keyboard`, which lights an Ajazz AK820 Pro in the accent through `ak820-ctl` (a no-op without it). The keyboard only listens over its USB cable, so `udt-keyboard --watch`, started from hypr-theme's autostart, reapplies the accent on every plug-in. Its firmware ignores a mode change unless the lights go off first and settle for a few seconds, hence the off, sleep 3, mode sequence; one second was not enough. It also starts `udt-ledstrip`, which plays the window border's three hues as a moving gradient on a Tuya addressable LED strip, over the LAN only. The strip's local key comes from `~/.config/udt/tuya/devices.json`, written once by `tinytuya wizard` against a Tuya IoT cloud project; re-pairing the strip in the app changes the key, so the wizard has to run again. No devices file, no-op. The script speaks protocol 3.5 itself with `cryptography` and finds the strip by its UDP 6667 broadcast rather than a stored address. The effect is DP 51, `dreamlight_scene_mode`, whose layout Tuya does not document and the cloud spec omits: it was read off the strip's status pushes while the app set scenes. LEDs render pastel accents as white, so saturation is floored at full. The strip also follows the session: hypr-theme's autostart runs `udt-ledstrip on` at login and `off` on Hyprland's `hyprland.shutdown` event, which covers logout. Poweroff and reboot do not reach it in time: rc.6 stops networking before it signals Hyprland, so the event fires with no route to the strip. rofi-scripts' `udt-powermenu.sh` therefore runs `off` itself before `loginctl`. `off` cannot wait for a broadcast as the session dies, so the last address is cached in `~/.cache/udt/ledstrip.json` and discovery is only the fallback. Suspend does not switch it off: `udt-ledstrip sleep` breathes the accent alone, desaturated and at half brightness (DP 51 mode `0x03`, read off the strip the same way), and hypridle's `after_sleep_cmd` runs `on` to bring the flow back. `sleep` is sent before `loginctl suspend`, by the power menu and by hypridle's idle timeout, for the same reason as `off`: NetworkManager drops the network on the same sleep signal hypridle's `before_sleep_cmd` answers. That `before_sleep_cmd` sends it too, as a best effort for any other suspend path. `udt-bulb` does the same for IKEA TRADFRI colour bulbs, called beside every `udt-ledstrip`, except that suspend switches the bulbs off: the breathing strip alone says the desktop is asleep. A bulb shows one colour, so it gets the accent alone. The bulbs are Zigbee, reached through the TRADFRI gateway's local CoAP API over DTLS-PSK, which Python cannot speak, so it calls libcoap's `coap-client` (SBo `libcoap`). `udt-bulb --pair HOST CODE` trades the code on the gateway's label for an identity of its own and writes `~/.config/udt/tradfri.json` with every colour bulb listed; trimming that list by hand picks which bulbs follow. Two gateway quirks shape the requests, both found on the real bulb: any colour change switches a bulb on, so a bare run reads the state first and skips an off bulb; and a request setting colour and brightness together, or a colour sent while a brightness fade runs, is partly dropped, so colour goes first and brightness after, separately. `udt-tv` sends the same three hues to the TV launcher of the sibling tv-debloat repo, which draws its focus highlight's edge as their gradient and uses the accent for its pill edges and title glow. One HMAC-SHA256-signed UDP datagram to the TV's port 47800 (the protocol is in tv-debloat's README), the TV's address in `~/.config/udt/tv.json` and the secret shared with tv-debloat's deploy in `~/.config/tv-debloat/accent.secret`; without either it does nothing. There is no reply, so a TV in standby simply misses the change and keeps the last colour it took. **Sublime's, GRUB's and the homepage's accents are replaced, not substituted into a placeholder.** GRUB and the homepage are generated by udt-palette with the scheme's accent, then rewritten in place, before `udt-grub` and `udt-homepage` start. Both already refresh on every wallpaper change for the wallpaper itself, so following the accent adds no root step. Sublime's colour scheme cannot cascade, so the accent has to live in the one generated file rather than arriving as a second one, and `install.sh` copies that file out of the repo. `write_sublime` therefore rewrites the `"accent"` variable line by matching its current value, which makes it idempotent across wallpaper changes; a placeholder would survive only the first run. The QML palette is the one output that is not only the accent: it carries the whole structural palette too, parsed out of `rofi/udt/palette.rasi` rather than duplicated in the script. That file stays the single place the scheme's values are written down. Quickshell components watch the generated file and re-read it in place, so a palette edit reaches a running shell without restarting it; see the `quickshell` repo alongside this one. **The generated files live in `~/.cache/udt/`**, not `~/.cache/wal/`. pywal no longer writes anything on this desktop, so sharing its cache directory only made the ownership ambiguous. `colors.json` is the one exception, and it is why `~/.cache/wal/` still exists. pywalfox hardcodes `/wal/colors.json` and honours only `XDG_CACHE_HOME`, which would move every cache on the system, so `udt-accent` writes the real file to `~/.cache/udt/` and `install.sh` symlinks the old path at it. That directory now holds the link and nothing else. **The accent is what stands out, not what dominates.** `signature_color()` reads the pixels itself (PIL, on a 256px thumbnail) and scores each by chroma times its distance from the image's mean colour; the top 2% vote by hue. It replaced pywal's colorz backend, whose cluster means averaged a small vivid element into its surroundings, whose random seeds made one wallpaper snap differently between runs, and which `sys.exit()`s on some images, which `except Exception` did not catch, so the old accent stayed. A pick below chroma 25 is a tint, and takes the fallback. **Extraction is deliberately isolated from pywal's cache.** It reads pixels and writes nothing. Running `wal -i` instead would rewrite all of `~/.cache/wal`, including the terminal's ANSI colours, and that is the exact failure this design exists to avoid. If you change `signature_color()`, verify it still writes nothing. `colors.json` is rewritten wholesale rather than edited: the scheme's palette (`udt_colors.PALETTE`, generated by udt-palette) with the accent in the cursor and the two highlight slots. That is deliberate. pywalfox reads `colors.json` and nothing else, so it is the only way Firefox can follow the accent, and overwriting it costs nothing because nothing else reads it. In particular kitty reads `~/.config/kitty/current-theme.conf`, not this file, so the terminal palette follows the scheme and never the wallpaper. The write lands after `wallp` has already run `wal --theme`, so it wins; reorder those two and pywal's wallpaper-derived colours come back. Firefox's new tab shows the wallpaper, blurred so it reads as a page and not as the desktop. FF Ultima picks the new tab background from a fixed list of images by pref, and `udt-accent` writes a blurred copy (PIL, `FIREFOX_BLUR`) over its `walls/darknix.png` slot; selecting it once (`user.theme.wallpaper.darknix` in `about:config`) is the only manual step. A copy, not a symlink: Firefox's sandbox will not follow a link out of the profile, and the new tab stays blank. Not everything tracks the wallpaper. rofi, quickshell, Hyprland borders, hyprlock's border (through `~/.cache/udt/hyprlock-border.conf`), Sublime's colour scheme, Firefox, GRUB, the homepage, the AK820 Pro keyboard, the LED strip and the TRADFRI bulbs do; conky, Qt, GTK, waybar, hyprlock's panel palette (from `templates/hyprlock/hyprlock-palette.conf`) and Sublime's UI chrome sit on the scheme's accent, lavender by default, which is also the fallback when a wallpaper is too grey to snap. Qt and GTK apps only reread a theme on restart, so a moving accent there would leave running apps disagreeing with new ones. ## Verifying visual changes `rofi -no-config -theme -dump-theme >/dev/null` catches syntax errors without a display. It does **not** tell you what renders: it normalises values, prints `(null)` for every image, and collapses multi-value margins. For anything visual, screenshot and measure: grim -o DP-1 /tmp/shot.png Reasoning about rofi's box model was wrong repeatedly during this project; measuring pixels was right every time. When a widget looks wrong, measure its bounding box before theorising about why. Rofi needs a display, so these steps run in the user's session. Do not judge them by exit code: rofi exits non-zero for ordinary reasons such as Escape. ## Things that are not where they look - **rofi is 2.0.0**, not the 1.7.3 its config header claims. `rofi -v` is the authority. `-width` is gone; width lives in the theme. - **`userimage` in `powermenu.rasi` only honours its margin.** A `width` with `expand: false` collapses it to a line, and a percentage margin resolves against the whole window rather than the padded column. Its 230px margin is tied to the 680px column in `mainbox`; both move together. - **A layout that hardcodes `children` silently drops `-mesg`.** That is why `list.rasi` lists the message widget even though most callers pass nothing. - **`ClearlyU` is installed and claims large parts of the Private Use Area**, so it can win fontconfig's fallback and draw a blank where another font has the glyph. `fc-match ":charset=XXXX" family` says who actually wins. The `U+F0xxx` Nerd Font ranges avoid the problem. - **GTK3's theme comes from gsettings on Wayland**, not `settings.ini`. It was pinned to `Breeze`, silently overriding the file, for who knows how long. - **GTK4 ignores `gtk-theme-name`.** Its stylesheet is symlinked into `~/.config/gtk-4.0/theme` and imported by `gtk.css`. - **Hyprland's Lua parser refuses `hyprctl keyword`** ("keyword can't work with non-legacy parsers") and has no source directive. The border colours are a Lua table read with `dofile()`. - **`wallp` starts `swaybg` in the background**, which inherits the shell's stdout, so a pipe or command substitution appears to hang after `wallp` has already finished. Redirect its output when calling it from a script. - **There is no waybar.** The bar is the quickshell island (the `quickshell` repo, `desktop/island/`), which reads the generated palette itself, so `install.sh` neither themes nor reloads a bar. `waybar-theme-udt` and `templates/waybar/` are leftovers from before the island. ## Editing rules - **conky**: change colours in the `conky.config` block only. `conky.text` is laid out with absolute `${goto}` pixel offsets tuned to label widths, so editing text means re-tuning every goto on that line. See `~/.config/conky/CLAUDE.md`. Conky does not hot-reload. - **Committed files carry no home paths.** A gitleaks hook blocks them, and it has been right every time. Where a live config mixes theme keys with personal data (`qt6ct.conf` has an `ignored_applications` list), track only the theme-relevant keys. - **Themes that reference an image cannot be committed with a real path.** `launcher.rasi` and `header.rasi` carry an `@WPAPER@` placeholder that `install.sh` rewrites. rofi does not expand `~` inside a `url()`. - After editing anything under `rofi/udt/` or `palette/`, run `./install.sh`. - `bin/udt-palette --selftest` resolves every role in every scheme. Run it after touching a palette, a role map, or the generator, and before regenerating: `palette.rasi` is live the moment it is written. A name in `ROFI_NAMES` must be letters and digits only; rofi rejects `term_blue` and then no theme on the desktop parses, which the selftest catches and a regeneration does not. - `bin/udt-accent --selftest` covers the snapping logic. Run it after touching the colour maths. ## Reloading `./install.sh` reloads everything running, so a scheme switch is one command. The table is what it does, and what to run if you change a config by hand. | | How | | --- | --- | | rofi | nothing; the theme is read per launch | | quickshell | nothing; it watches the generated palette and re-reads it | | Firefox | `udt-accent` runs `pywalfox update` | | Hyprland | `hyprctl reload` | | hyprlock | nothing; it reads its config per lock, so the next lock picks it up | | kitty | `pkill -USR1 -x kitty` | | Sublime Text | nothing; it watches `Packages/User` and reloads both the scheme and the theme | | conky | restart it; no hot-reload | | neovim | `:colorscheme udt`, or restart it | | Qt / GTK apps | restart the app | | btop, Joplin | restart the app | | Feishin | nothing; it watches its Themes folder |