aboutsummaryrefslogtreecommitdiffstats
path: root/AGENTS.md
blob: df16d3d4b80d0822719efa9d90cb5299746e6959 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# 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 and neovim.

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`,
`install.sh` runs its `build` as the user, then prints the root copy. A copy,
not a link, because GRUB reads `/boot` before `/home` is mounted; it needs
rerunning after a scheme or wallpaper change.

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/<scheme>.conf` holds them under the
scheme's own names and `palette/roles-<scheme>.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` 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 and Typora are installed by `bin/udt-appthemes`, not by a 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. Every vault under `~/Documents/Obsidian` is found by looking for
`.obsidian` directories, nested ones included. Typora's theme is derived from
its Dracula theme, whose 350 rules all route through `:root` variables, so only
that block is templated.

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.<ext>` 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 command arrives as a desktop
notification. 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 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 six 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), and the accent substitution in
Sublime's installed `Packages/User/udt.sublime-color-scheme`.

**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
Macchiato 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 `<cache>/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: a fixed Macchiato
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 stays fixed no matter what the wallpaper looks like. 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 <absolute-path> -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 is still on disk
  and nothing reads it, so `install.sh` deliberately writes no theme into it:
  its stylesheets reference the per-module role names this generator no longer
  emits, and a new theme there would leave it pointing at colours that do not
  exist. 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 |