# 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 (`udt-powermenu.sh`, `udt-launcher.sh`, `qar-scrotmenu.sh`) live in `bin/` and are symlinked into `~/bin/` under the names hypr-theme's keybindings call. They used to sit loose in `~/bin/`, so the themes were tracked and the only things that open them were not. Sibling checkouts are wired from here, each skipped when not cloned: `waybar-theme-udt` (its own `install.sh`), `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 appearance shell (`ipc call appearance steps`), 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 on every run, whoever called it: 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 seven 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 Sublime's installed `Packages/User/udt.sublime-color-scheme`. It then starts `udt-homepage` and `udt-grub` detached, for the two outputs that need a root step on another filesystem or host. **Sublime's accent is replaced, not substituted into a placeholder.** Its 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. **Extraction is deliberately isolated from pywal's cache.** It calls `pywal.backends.colorz.get()` directly, which returns a list 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. Not everything tracks the wallpaper. rofi, quickshell, Hyprland borders, hyprlock's border (through `~/.cache/udt/hyprlock-border.conf`), Sublime's colour scheme and Firefox 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. - **The bar is not in `~/.config/waybar/`.** It is `waybar-theme-udt`, a separate repository with its own config directory, `~/.config/waybar-udt/`, and hyprland's autostart launches that. The stock directory has been deleted; `install.sh` never wrote into it. See `templates/waybar/README.md`. ## 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` carries 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 | | waybar | `killall -SIGUSR2 waybar` | | 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 |