aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-16 09:16:46 +0200
committerDanilo M. <danix@danix.xyz>2026-09-16 09:16:46 +0200
commit3df07af56c286a823b28ebe83cd82d90f72f9f12 (patch)
tree0a22240181e0675aa1266d2c030b617fb5e2ee51 /docs/superpowers
parentb970a0deaad3a6723fe5727a3db7b653ebb3e7a3 (diff)
downloadunified-desktop-theme-3df07af56c286a823b28ebe83cd82d90f72f9f12.tar.gz
unified-desktop-theme-3df07af56c286a823b28ebe83cd82d90f72f9f12.zip
docs: add the GTK palette-consumer implementation plan
GTK was listed in AGENTS.md as a covered consumer but nothing generated or installed a theme for it. Every GTK app sat on a hardcoded upstream catppuccin-macchiato-lavender while the rest of the desktop ran tokyo-night. The plan templates the upstream stylesheets and assets the way Kvantum already is, rendering into ~/.themes/udt under one fixed name per scheme. Substitution is whole-file rather than confined to the @define-color block: 338 palette hexes in GTK3 and 328 in GTK4 sit outside it, which was measured before the approach was chosen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-09-16-gtk-palette-consumer.md831
1 files changed, 831 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-16-gtk-palette-consumer.md b/docs/superpowers/plans/2026-09-16-gtk-palette-consumer.md
new file mode 100644
index 0000000..1bc05ef
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-16-gtk-palette-consumer.md
@@ -0,0 +1,831 @@
+# GTK as a Palette Consumer Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make GTK3 and GTK4 follow the selected scheme, by generating `~/.themes/udt` from the palette instead of pointing at the hardcoded upstream `catppuccin-macchiato-lavender-standard+default`.
+
+**Architecture:** The same shape as the existing Kvantum consumer. The upstream theme's two stylesheets and 67 SVG assets become templates with `@ROLE@` placeholders where a palette colour appears; `udt-palette` renders them via new `TARGETS` entries; `install.sh` writes the result to `~/.themes/udt` under one fixed name for every scheme, so gsettings and the GTK4 symlink are set once and never change on a scheme switch. Neutral greys are left exactly as upstream drew them, the rule Kvantum already follows.
+
+**Tech Stack:** Python 3 (`bin/udt-palette`), bash (`install.sh`), GTK3/GTK4 CSS, gsettings.
+
+---
+
+## Background the engineer needs
+
+The upstream theme is `~/.themes/catppuccin-macchiato-lavender-standard+default`, from `catppuccin/gtk` v1.0.3 (archived upstream; we are knowingly forking it). It contains:
+
+- `gtk-3.0/gtk.css` (8539 lines) and `gtk-3.0/gtk-dark.css`, which are **byte-identical**.
+- `gtk-4.0/gtk.css` (8502 lines) and `gtk-4.0/gtk-dark.css`, also byte-identical.
+- `gtk-3.0/assets/` and `gtk-4.0/assets/`, 67 SVGs each, the two directories **byte-identical**.
+- `index.theme`, a metatheme descriptor.
+- Other desktop dirs (`cinnamon/`, `gnome-shell/`, `metacity-1/`, `xfwm4/`, `plank/`) that Hyprland never reads. **Do not template these**; they are not copied to `~/.themes/udt` at all.
+
+Colours appear in two forms, both of which must be substituted:
+
+1. Hex, e.g. `#b7bdf8`. 11 distinct palette hexes in GTK3, 12 in GTK4.
+2. Decimal triples inside `rgba(...)`, **with spaces**, e.g. `rgba(239, 241, 245, 0.5)`. Note `udt-palette`'s existing `rgba()` helper emits *without* spaces, so it cannot be reused here; substitution is on the literal triple text.
+
+A `@define-color` block sits at the tail of each file (GTK3 lines 8407-8539, GTK4 lines 7017-8502), but **the body above it also hardcodes palette colours** (338 occurrences in GTK3, 328 in GTK4). Substitution is therefore whole-file, not block-scoped. This was verified; do not assume the body routes through the variables.
+
+### The colour map
+
+Applies to both stylesheets and the assets. Left column is the literal text in the upstream file; right column is the role placeholder to write in the template.
+
+| Upstream | Placeholder | Role | Note |
+| --- | --- | --- | --- |
+| `#eff1f5` | `@FG@` | `fg` | Latte `text`. Upstream's near-white foreground. Mapped to `fg` so GTK text matches rofi, kitty and waybar. |
+| `239, 241, 245` | `@FG_RGB@` | `fg` | Same colour, decimal form. |
+| `#24273a` | `@BG@` | `bg` | |
+| `36, 39, 58` | `@BG_RGB@` | `bg` | |
+| `#b7bdf8` | `@ACCENT@` | `accent` | |
+| `183, 189, 248` | `@ACCENT_RGB@` | `accent` | |
+| `#1e2030` | `@BG_ALT@` | `bg_alt` | |
+| `18, 19, 29` | `@BG_ALT_RGB@` | `bg_alt` | Near-miss of mantle; upstream rounding. Treated as `bg_alt`. |
+| `#181926` | `@BG_DEEP@` | `bg_deep` | |
+| `24, 25, 38` | `@BG_DEEP_RGB@` | `bg_deep` | |
+| `17, 17, 27` | `@BG_DEEP_RGB@` | `bg_deep` | Latte `crust`, used as text-on-accent. |
+| `#363a4f` | `@SURFACE@` | `surface` | |
+| `54, 58, 79` | `@SURFACE_RGB@` | `surface` | |
+| `#494d64` | `@SURFACE_ALT@` | `surface_alt` | GTK4 only. |
+| `#ed8796` | `@CRITICAL@` | `critical` | |
+| `237, 135, 150` | `@CRITICAL_RGB@` | `critical` | |
+| `182, 106, 119` | `@CRITICAL_RGB@` | `critical` | Darkened red, close-button hover. |
+| `#eed49f` | `@WARNING@` | `warning` | |
+| `238, 212, 159` | `@WARNING_RGB@` | `warning` | |
+| `#a6da95` | `@SUCCESS@` | `success` | |
+| `166, 218, 149` | `@SUCCESS_RGB@` | `success` | |
+| `#c6a0f6` | `@MAUVE@` | `highlight` | Uses the existing `[conky] highlight` role. |
+| `#91d7e3` | `@INFO@` | `info` | |
+| `#8aadf4` | `@BLUE@` | `blue` | GTK4 only. Uses `[terminal] blue`. |
+| `#f5a97f` | `@PEACH@` | `peach` | GTK4 only. See Task 1 note. |
+| `245, 169, 127` | `@PEACH_RGB@` | `peach` | GTK4 only. |
+
+**Left alone deliberately** (neutral shading, not theme colour, same rule as the Kvantum SVG): `#3e4152`, `#2e3143`, `#2a2d40`, `#383b4d`, `#393b49`, `#3b3c47`, `#424556`, `#494c60`, `#52535c`, `#616472`, `#6e7297`, `0, 0, 0`, `255, 255, 255`, `#ffffff`, `#666666`, `#424242`, `#07080c`, and every `*_500`/`*_700`/`*_900` Material swatch constant (`BLUEBERRY_500`, `STRAWBERRY_500`, `GRAPE_500`, etc). Also left alone: `#e55267`, `#eea2ae`, `#eedbb5`, `#eaca89`, `#e5bd6b`, `#ea7183`, `#b8e0ad`, `#a0a8f6`, `#bfc5f8`, `#d3d7fb`, `#e5e8fd`, `#94A6FF`, `#6A7CE0`, and the other derived tints, which are upstream-computed shades of colours we do substitute.
+
+**`peach` is not currently a role.** Task 1 adds it. Every scheme must define it or `--selftest` fails, which is the point of that check.
+
+---
+
+## File Structure
+
+**Created:**
+- `templates/gtk/gtk3.css.in` — GTK3 stylesheet template (~8539 lines, from upstream, placeholders substituted).
+- `templates/gtk/gtk4.css.in` — GTK4 stylesheet template (~8502 lines).
+- `templates/gtk/index.theme.in` — metatheme descriptor template.
+- `templates/gtk/assets/*.svg.in` — 67 asset templates (only 36 actually contain a placeholder; the rest are copied through unchanged for simplicity of the install step).
+- `templates/gtk/README.md` — why this exists, what is left alone, the archived-upstream caveat.
+- `docs/superpowers/plans/2026-09-16-gtk-palette-consumer.md` — this file.
+
+**Modified:**
+- `palette/roles-*.conf` (all nine) — add `peach` to `[ui]`.
+- `bin/udt-palette` — add `rgb_triple()` helper, `GTK_ROLES`, `gen_gtk()`, `gen_gtk_asset()`, four `TARGETS` entries, and a selftest assertion.
+- `install.sh` — install `~/.themes/udt`, set gsettings, repoint the GTK4 symlink, write `gtk-3.0/settings.ini`'s theme name.
+- `AGENTS.md` — GTK is now a real consumer; correct the reload table and the "Qt and GTK" section.
+- `templates/qt-gtk/README.md` — GTK half is now generated; Qt half unchanged.
+- `.gitignore` — ignore the generated `templates/gtk/gtk3.css`, `gtk4.css`, `index.theme`, `assets/*.svg`.
+
+**Generated (gitignored):** `templates/gtk/gtk3.css`, `templates/gtk/gtk4.css`, `templates/gtk/index.theme`, `templates/gtk/assets/*.svg`.
+
+---
+
+### Task 1: Add the `peach` role to all nine schemes
+
+GTK4 uses `#f5a97f` (macchiato `peach`) for a handful of chart and badge colours. No role currently exposes it. The selftest enforces an identical role set across schemes, so all nine role maps must gain it together.
+
+**Files:**
+- Modify: `palette/roles-macchiato.conf`, `roles-frappe.conf`, `roles-mocha.conf`, `roles-tokyo-night.conf`, `roles-nord.conf`, `roles-dracula.conf`, `roles-material-ocean.conf`, `roles-material-palenight.conf`, `roles-material-darker.conf`
+
+- [ ] **Step 1: Check what each scheme calls its orange**
+
+```bash
+cd ~/Programming/GIT/unified-desktop-theme
+for f in palette/*.conf; do
+ case "$f" in *roles*) continue;; esac
+ echo "=== $f ==="
+ grep -iE "^(peach|orange|amber|yellow) " "$f"
+done
+```
+
+Expected: each palette file names an orange-ish colour. Catppuccin schemes call it `peach`; tokyo-night, nord, dracula and the material schemes use their own names. Note the name each one uses — Step 2 needs it.
+
+- [ ] **Step 2: Add the role to every roles file**
+
+In each `palette/roles-<scheme>.conf`, inside the `[ui]` section, immediately after the `accent = ...` line, add:
+
+```ini
+
+# GTK uses an orange for chart fills and badge backgrounds. Named here rather
+# than reused from [terminal] because [ui] is what a non-terminal consumer
+# reads, and a flat namespace means one definition serves both.
+peach = <that scheme's orange name>
+```
+
+For `roles-macchiato.conf`, `roles-frappe.conf` and `roles-mocha.conf` the value is `peach`. For the others use the name found in Step 1.
+
+- [ ] **Step 3: Run the selftest to verify every scheme resolves**
+
+Run: `./bin/udt-palette --selftest`
+Expected: PASS, with every scheme now reporting **69 roles** (was 68), and the line `selftest OK`. If one scheme reports 68, that file was missed.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add palette/roles-*.conf
+git commit -m "feat(palette): add a peach role for GTK's chart and badge colours"
+```
+
+---
+
+### Task 2: Add the `rgb_triple` helper and the GTK role list
+
+**Files:**
+- Modify: `bin/udt-palette` (helper near `rgb_array`, around line 109; role list near `KVANTUM_ROLES`, around line 332)
+
+- [ ] **Step 1: Add the helper**
+
+Immediately after the `rgb_array` function (around line 113), add:
+
+```python
+def rgb_triple(c):
+ """`r, g, b` with spaces, which is how GTK's CSS writes an rgba() colour.
+
+ The existing rgba() helper emits without spaces and wraps in rgb()/rgba(),
+ neither of which matches what the upstream GTK theme contains, so the
+ substitution is on the bare triple and the template keeps the rgba() call.
+ """
+ r, g, b, _ = c
+ return f"{r}, {g}, {b}"
+```
+
+- [ ] **Step 2: Add the role list**
+
+Immediately after `KVANTUM_ROLES` (around line 335), add:
+
+```python
+# The roles GTK's stylesheets reference. Both a hex and a decimal-triple form
+# of each is substituted, because the upstream theme writes colours both ways.
+GTK_ROLES = ["fg", "bg", "bg_alt", "bg_deep", "surface", "surface_alt",
+ "accent", "critical", "warning", "success", "highlight", "info",
+ "blue", "peach"]
+```
+
+- [ ] **Step 3: Verify it imports cleanly**
+
+Run: `python3 -c "import sys; sys.path.insert(0, 'bin'); exec(open('bin/udt-palette').read().split('def main')[0]); print(rgb_triple((239, 241, 245, 100)))"`
+Expected: `239, 241, 245`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add bin/udt-palette
+git commit -m "feat(palette): add an rgb_triple helper and the GTK role list"
+```
+
+---
+
+### Task 3: Build the stylesheet templates
+
+This is a mechanical transformation of two large upstream files. It is done with a script so it is reproducible and reviewable, not by hand.
+
+**Files:**
+- Create: `templates/gtk/gtk3.css.in`, `templates/gtk/gtk4.css.in`
+
+- [ ] **Step 1: Write the conversion script**
+
+Create `/tmp/mkgtk.py`:
+
+```python
+#!/usr/bin/env python3
+"""Turn an upstream catppuccin/gtk stylesheet into a udt template.
+
+Order matters: the longest literals are replaced first, so a shorter one that
+is a prefix of a longer one cannot corrupt it. Hex matching is case-insensitive
+because upstream mixes cases; decimal triples are matched literally.
+"""
+import re
+import sys
+
+HEX = [
+ ("#eff1f5", "@FG@"),
+ ("#24273a", "@BG@"),
+ ("#b7bdf8", "@ACCENT@"),
+ ("#1e2030", "@BG_ALT@"),
+ ("#181926", "@BG_DEEP@"),
+ ("#363a4f", "@SURFACE@"),
+ ("#494d64", "@SURFACE_ALT@"),
+ ("#ed8796", "@CRITICAL@"),
+ ("#eed49f", "@WARNING@"),
+ ("#a6da95", "@SUCCESS@"),
+ ("#c6a0f6", "@MAUVE@"),
+ ("#91d7e3", "@INFO@"),
+ ("#8aadf4", "@BLUE@"),
+ ("#f5a97f", "@PEACH@"),
+]
+
+TRIPLE = [
+ ("239, 241, 245", "@FG_RGB@"),
+ ("183, 189, 248", "@ACCENT_RGB@"),
+ ("237, 135, 150", "@CRITICAL_RGB@"),
+ ("238, 212, 159", "@WARNING_RGB@"),
+ ("166, 218, 149", "@SUCCESS_RGB@"),
+ ("182, 106, 119", "@CRITICAL_RGB@"),
+ ("245, 169, 127", "@PEACH_RGB@"),
+ ("36, 39, 58", "@BG_RGB@"),
+ ("18, 19, 29", "@BG_ALT_RGB@"),
+ ("24, 25, 38", "@BG_DEEP_RGB@"),
+ ("17, 17, 27", "@BG_DEEP_RGB@"),
+ ("54, 58, 79", "@SURFACE_RGB@"),
+]
+
+text = open(sys.argv[1]).read()
+for literal, placeholder in HEX:
+ text = re.sub(re.escape(literal), placeholder, text, flags=re.IGNORECASE)
+for literal, placeholder in TRIPLE:
+ text = text.replace(literal, placeholder)
+open(sys.argv[2], "w").write(text)
+
+left = re.findall(r"#(?:b7bdf8|24273a|eff1f5|1e2030|181926|363a4f|494d64"
+ r"|ed8796|eed49f|a6da95|c6a0f6|91d7e3|8aadf4|f5a97f)",
+ text, re.IGNORECASE)
+print(f"{sys.argv[2]}: {text.count('@')} placeholders, {len(left)} palette hexes left")
+```
+
+- [ ] **Step 2: Generate both templates**
+
+```bash
+cd ~/Programming/GIT/unified-desktop-theme
+mkdir -p templates/gtk
+T=~/.themes/catppuccin-macchiato-lavender-standard+default
+python3 /tmp/mkgtk.py $T/gtk-3.0/gtk.css templates/gtk/gtk3.css.in
+python3 /tmp/mkgtk.py $T/gtk-4.0/gtk.css templates/gtk/gtk4.css.in
+```
+
+Expected: two lines reporting a few hundred placeholders each and **0 palette hexes left**. A non-zero count means a colour is missing from the map; add it and re-run.
+
+- [ ] **Step 3: Verify no palette colour survived in either form**
+
+```bash
+grep -ciE "eff1f5|24273a|b7bdf8|1e2030|181926|363a4f|494d64|ed8796|eed49f|a6da95|c6a0f6|91d7e3|8aadf4|f5a97f" templates/gtk/gtk3.css.in templates/gtk/gtk4.css.in
+grep -c "239, 241, 245\|183, 189, 248\|36, 39, 58\|17, 17, 27" templates/gtk/gtk3.css.in templates/gtk/gtk4.css.in
+```
+
+Expected: `0` for every file on both commands.
+
+- [ ] **Step 4: Verify the neutrals were NOT touched**
+
+```bash
+grep -c "3e4152\|2e3143\|BLUEBERRY_500\|rgba(0, 0, 0" templates/gtk/gtk3.css.in
+```
+
+Expected: a non-zero count. These must survive; a `0` means the script over-matched.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add templates/gtk/gtk3.css.in templates/gtk/gtk4.css.in
+git commit -m "feat(gtk): template the upstream GTK3 and GTK4 stylesheets"
+```
+
+---
+
+### Task 4: Build the asset and index.theme templates
+
+**Files:**
+- Create: `templates/gtk/assets/*.svg.in` (67 files), `templates/gtk/index.theme.in`
+
+- [ ] **Step 1: Generate the asset templates**
+
+The 67 SVGs contain only three palette colours: `#24273a` (38 uses), `#b7bdf8` (36), `#1e2030` (2). The gtk-3.0 and gtk-4.0 asset directories are byte-identical, so one set serves both.
+
+```bash
+cd ~/Programming/GIT/unified-desktop-theme
+mkdir -p templates/gtk/assets
+T=~/.themes/catppuccin-macchiato-lavender-standard+default
+for f in $T/gtk-3.0/assets/*.svg; do
+ sed -e 's/#24273a/@BG@/gI' -e 's/#b7bdf8/@ACCENT@/gI' -e 's/#1e2030/@BG_ALT@/gI' \
+ "$f" > "templates/gtk/assets/$(basename "$f").in"
+done
+ls templates/gtk/assets/ | wc -l
+```
+
+Expected: `67`
+
+- [ ] **Step 2: Verify the substitution and the surviving neutrals**
+
+```bash
+grep -rlc "24273a\|b7bdf8\|1e2030" templates/gtk/assets/ | wc -l
+grep -rl "@ACCENT@" templates/gtk/assets/ | wc -l
+grep -rl "#666666\|#ffffff" templates/gtk/assets/ | wc -l
+```
+
+Expected: `0` palette hexes remaining; `36` files carrying `@ACCENT@`; a non-zero count of files still carrying the neutral greys.
+
+- [ ] **Step 3: Write the index.theme template**
+
+Create `templates/gtk/index.theme.in`:
+
+```ini
+[Desktop Entry]
+Type=X-GNOME-Metatheme
+Name=udt
+Comment=Generated by udt-palette. Do not edit.
+Encoding=UTF-8
+
+[X-GNOME-Metatheme]
+GtkTheme=udt
+MetacityTheme=udt
+IconTheme=Material-Black-Plum-Suru
+CursorTheme=hypr_bibata-modern-amber
+ButtonLayout=close,minimize,maximize:menu
+```
+
+Note the icon and cursor themes differ from upstream's (`Tela-circle-Dark` / `Macchiato-cursors`), which this desktop does not use. This file has no placeholders; it is a template only so it lives with the rest and is installed by the same code path.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add templates/gtk/assets templates/gtk/index.theme.in
+git commit -m "feat(gtk): template the GTK widget assets and metatheme descriptor"
+```
+
+---
+
+### Task 5: Wire GTK into the generator
+
+**Files:**
+- Modify: `bin/udt-palette` (generator function near `gen_kvantum` around line 340; `TARGETS` around line 566)
+
+- [ ] **Step 1: Add the generator functions**
+
+Immediately after `gen_kvantum` (around line 353), add:
+
+```python
+def gen_gtk(res, scheme, palette, template):
+ """GTK3/GTK4: substitute the palette into the upstream stylesheet.
+
+ Derived from catppuccin/gtk v1.0.3, which is archived upstream. The whole
+ file is substituted rather than just its @define-color block, because the
+ 8400 lines above that block hardcode palette colours too.
+
+ Two forms per role: the hex, and the bare `r, g, b` triple that the theme
+ writes inside rgba(). Neutral greys and upstream's derived tints are left
+ exactly as drawn, the same rule the Kvantum SVG follows.
+ """
+ out = template
+ for role in GTK_ROLES:
+ out = out.replace(f"@{role.upper()}_RGB@", rgb_triple(res[role]))
+ out = out.replace(f"@{role.upper()}@", hex6(res[role]))
+ # The mauve placeholder reads from the conky highlight role, which is the
+ # only role that names it; spelling it MAUVE keeps the template readable
+ # against the upstream colours it replaced.
+ out = out.replace("@MAUVE@", hex6(res["highlight"]))
+ return out
+
+
+def gen_gtk_asset(res, scheme, palette, template):
+ """One GTK widget asset. Only three palette colours appear in the SVGs."""
+ out = template
+ for role in ("bg", "bg_alt", "accent"):
+ out = out.replace(f"@{role.upper()}@", hex6(res[role]))
+ return out
+```
+
+**Important:** `_RGB@` is replaced before the bare `@ROLE@`, or `@FG@` would match the start of `@FG_RGB@` and corrupt it. Keep that order.
+
+- [ ] **Step 2: Add the stylesheet and index targets**
+
+In `TARGETS` (around line 566), after the kvantum entries, add:
+
+```python
+ ("templates/gtk/gtk3.css", gen_gtk, "templates/gtk/gtk3.css.in"),
+ ("templates/gtk/gtk4.css", gen_gtk, "templates/gtk/gtk4.css.in"),
+ ("templates/gtk/index.theme", gen_gtk, "templates/gtk/index.theme.in"),
+```
+
+- [ ] **Step 3: Add the assets to TARGETS**
+
+`TARGETS` is a static list, but the 67 assets are discovered from disk. Immediately after the `TARGETS = [...]` closing bracket, add:
+
+```python
+# The GTK widget assets, one target each. Discovered rather than listed: there
+# are 67 of them and they arrive as a set from upstream, so a hand-written list
+# would silently miss one added by a future theme refresh.
+TARGETS += [
+ (f"templates/gtk/assets/{p.name[:-3]}", gen_gtk_asset,
+ f"templates/gtk/assets/{p.name}")
+ for p in sorted((REPO / "templates" / "gtk" / "assets").glob("*.svg.in"))
+]
+```
+
+- [ ] **Step 4: Render and verify**
+
+Run: `./bin/udt-palette`
+Expected: the file count rises by 70 (2 stylesheets + index.theme + 67 assets). Then:
+
+```bash
+grep -c "@" templates/gtk/gtk3.css | head -1
+grep -oE "#[0-9a-f]{6}" templates/gtk/gtk3.css | sort -u | head -5
+```
+
+Expected: no `@ROLE@` placeholders left (the `@` count reflects only `@define-color`, `@import` and `@media`, which are CSS syntax), and the hexes present are the current scheme's, not macchiato's.
+
+- [ ] **Step 5: Verify the current scheme actually landed**
+
+```bash
+grep -m1 "@define-color theme_fg_color" templates/gtk/gtk3.css
+grep -m1 "@define-color theme_selected_bg_color" templates/gtk/gtk3.css
+```
+
+Expected under tokyo-night: `#c0caf5` for the fg and `#7aa2f7` for the selected background. Under macchiato it would be `#cad3f5` and `#b7bdf8`.
+
+- [ ] **Step 6: Run the selftest**
+
+Run: `./bin/udt-palette --selftest`
+Expected: PASS, `selftest OK`, every scheme at 69 roles.
+
+- [ ] **Step 7: Gitignore the generated files**
+
+Add to `.gitignore`, in the generated-files block:
+
+```gitignore
+/templates/gtk/gtk3.css
+/templates/gtk/gtk4.css
+/templates/gtk/index.theme
+/templates/gtk/assets/*.svg
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add bin/udt-palette .gitignore
+git commit -m "feat(palette): add GTK3 and GTK4 as palette consumers"
+```
+
+---
+
+### Task 6: Add a selftest assertion for the GTK templates
+
+The existing selftest catches a role a scheme cannot supply, and catches a rofi theme referencing a name the generator does not emit. The equivalent GTK failure is a template placeholder with no matching role: it would render literally into the CSS and GTK would silently drop the declaration.
+
+**Files:**
+- Modify: `bin/udt-palette` (inside `selftest`, after the role-set comparison)
+
+- [ ] **Step 1: Write the assertion**
+
+In `selftest()`, after the `missing`/`extra` role-set loop, add:
+
+```python
+ # Every placeholder in the GTK templates must correspond to a role, or it
+ # renders literally and GTK drops the declaration without an error. The
+ # stylesheets are too large to eyeball, so this is the only thing standing
+ # between a typo and a silently half-themed desktop.
+ gtk_dir = REPO / "templates" / "gtk"
+ known = {f"@{r.upper()}@" for r in GTK_ROLES}
+ known |= {f"@{r.upper()}_RGB@" for r in GTK_ROLES}
+ known.add("@MAUVE@")
+ for tpl in sorted(gtk_dir.glob("*.in")) + sorted((gtk_dir / "assets").glob("*.in")):
+ used = set(re.findall(r"@[A-Z][A-Z_]*@", tpl.read_text()))
+ unknown = used - known
+ assert not unknown, f"{tpl.name} uses unknown placeholders: {sorted(unknown)}"
+```
+
+- [ ] **Step 2: Run it**
+
+Run: `./bin/udt-palette --selftest`
+Expected: PASS, `selftest OK`.
+
+- [ ] **Step 3: Prove the assertion actually fires**
+
+```bash
+sed -i 's/@ACCENT@/@NOSUCHROLE@/' templates/gtk/index.theme.in
+./bin/udt-palette --selftest; echo "exit: $?"
+```
+
+Expected: FAIL with `index.theme.in uses unknown placeholders: ['@NOSUCHROLE@']` and a non-zero exit. (`index.theme.in` has no `@ACCENT@`, so if the sed is a no-op, use `templates/gtk/assets/checkbox-checked.svg.in` instead.) Then revert:
+
+```bash
+git checkout templates/gtk/index.theme.in
+./bin/udt-palette --selftest
+```
+
+Expected: PASS again.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add bin/udt-palette
+git commit -m "test(palette): assert every GTK template placeholder maps to a role"
+```
+
+---
+
+### Task 7: Install the generated theme
+
+**Files:**
+- Modify: `install.sh` (a new block after the Kvantum block, around line 122)
+
+- [ ] **Step 1: Back up the current GTK wiring**
+
+```bash
+cp -a ~/.config/gtk-3.0/settings.ini ~/.config/gtk-3.0/settings.ini.pre-udt-gtk
+gsettings get org.gnome.desktop.interface gtk-theme > ~/.config/gtk-3.0/gtk-theme.pre-udt-gtk
+ls -la ~/.config/gtk-4.0/theme
+```
+
+Expected: the backup files exist and the symlink currently points into `catppuccin-macchiato-lavender-standard+default`.
+
+- [ ] **Step 2: Add the install block**
+
+In `install.sh`, immediately after the Kvantum block (after the `printf '[General]\ntheme=udt\n'` line), add:
+
+```bash
+# GTK3 and GTK4. Installed under one fixed name for every scheme, like Kvantum:
+# the theme name is set in gsettings and in a symlink, so a per-scheme name
+# would mean rewriting both on every switch and leaving stale themes behind.
+#
+# Only the two GTK directories are installed. The upstream theme also ships
+# cinnamon, gnome-shell, metacity, xfwm4 and plank directories, none of which
+# anything on this desktop reads.
+gtk_theme="$HOME/.themes/udt"
+install -Dm644 "$repo/templates/gtk/index.theme" "$gtk_theme/index.theme"
+for v in 3.0 4.0; do
+ src="gtk3.css"
+ [ "$v" = "4.0" ] && src="gtk4.css"
+ install -Dm644 "$repo/templates/gtk/$src" "$gtk_theme/gtk-$v/gtk.css"
+ # gtk-dark.css is byte-identical to gtk.css upstream; apps ask for one or
+ # the other depending on the prefer-dark setting, so both must exist.
+ install -Dm644 "$repo/templates/gtk/$src" "$gtk_theme/gtk-$v/gtk-dark.css"
+ rm -rf "$gtk_theme/gtk-$v/assets"
+ mkdir -p "$gtk_theme/gtk-$v/assets"
+ cp "$repo"/templates/gtk/assets/*.svg "$gtk_theme/gtk-$v/assets/"
+done
+
+# GTK3 reads the theme name from gsettings on Wayland, not settings.ini: the
+# file is written too, but gsettings is what actually wins.
+sed -i 's/^gtk-theme-name=.*/gtk-theme-name=udt/' "$HOME/.config/gtk-3.0/settings.ini"
+if command -v gsettings >/dev/null 2>&1; then
+ gsettings set org.gnome.desktop.interface gtk-theme 'udt' 2>/dev/null || true
+fi
+
+# GTK4 ignores gtk-theme-name entirely; its stylesheet is imported by gtk.css
+# through this symlink.
+ln -sfn "$gtk_theme/gtk-4.0" "$HOME/.config/gtk-4.0/theme"
+```
+
+- [ ] **Step 3: Run the installer**
+
+Run: `./install.sh`
+Expected: exit 0, and the existing `reloaded:` line unchanged (GTK apps are not signalled; they reread on restart).
+
+- [ ] **Step 4: Verify what landed on disk**
+
+```bash
+ls -la ~/.themes/udt/ ~/.themes/udt/gtk-3.0/ | head -20
+ls ~/.themes/udt/gtk-3.0/assets | wc -l
+grep -m1 "@define-color theme_fg_color" ~/.themes/udt/gtk-3.0/gtk.css
+gsettings get org.gnome.desktop.interface gtk-theme
+readlink ~/.config/gtk-4.0/theme
+grep "^gtk-theme-name" ~/.config/gtk-3.0/settings.ini
+```
+
+Expected: `index.theme` plus `gtk-3.0/` and `gtk-4.0/`, each with `gtk.css`, `gtk-dark.css` and 67 assets; the fg matching the current scheme; gsettings reporting `'udt'`; the symlink pointing at `~/.themes/udt/gtk-4.0`; and `gtk-theme-name=udt`.
+
+- [ ] **Step 5: Verify a GTK app actually renders**
+
+Launch any GTK3 app that is not already running (`gtk3-widget-factory` if installed, otherwise a file chooser from a restarted app) and confirm it is not macchiato lavender. GTK apps only reread a theme at startup, so anything already open keeps the old colours until restarted. Ask the user to confirm visually; a screenshot of a transient dialog is a race.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add install.sh
+git commit -m "feat(install): install the generated GTK theme as ~/.themes/udt"
+```
+
+---
+
+### Task 8: Verify a scheme switch actually moves GTK
+
+This is the whole point of the change: the bug was that a scheme switch left GTK behind.
+
+- [ ] **Step 1: Record the current GTK colours**
+
+```bash
+grep -m1 "@define-color theme_fg_color" ~/.themes/udt/gtk-3.0/gtk.css
+grep -m1 "@define-color theme_selected_bg_color" ~/.themes/udt/gtk-3.0/gtk.css
+```
+
+Note both values.
+
+- [ ] **Step 2: Switch to a different scheme and reinstall**
+
+```bash
+cp ~/.config/udt/roles.conf /tmp/roles.conf.bak
+sed -i 's/^scheme = .*/scheme = nord/' ~/.config/udt/roles.conf
+./install.sh
+```
+
+Expected: exit 0.
+
+- [ ] **Step 3: Verify GTK moved**
+
+```bash
+grep -m1 "@define-color theme_fg_color" ~/.themes/udt/gtk-3.0/gtk.css
+grep -m1 "@define-color theme_selected_bg_color" ~/.themes/udt/gtk-3.0/gtk.css
+grep -c "b7bdf8\|cad3f5" ~/.themes/udt/gtk-3.0/gtk.css
+```
+
+Expected: both values differ from Step 1 and are Nord colours; the macchiato-hex count is `0`.
+
+- [ ] **Step 4: Verify the assets moved too**
+
+```bash
+grep -l "$(grep -m1 -oE '#[0-9a-f]{6}' <<< "$(grep -m1 'theme_selected_bg_color' ~/.themes/udt/gtk-3.0/gtk.css)")" ~/.themes/udt/gtk-3.0/assets/*.svg | wc -l
+```
+
+Expected: `36`, the accent-bearing assets, now carrying Nord's accent.
+
+- [ ] **Step 5: Restore the original scheme**
+
+```bash
+cp /tmp/roles.conf.bak ~/.config/udt/roles.conf
+./install.sh
+grep -m1 "@define-color theme_fg_color" ~/.themes/udt/gtk-3.0/gtk.css
+```
+
+Expected: back to the Step 1 value.
+
+- [ ] **Step 6: No commit**
+
+This task changes nothing tracked. `git status` should be clean.
+
+---
+
+### Task 9: Documentation
+
+**Files:**
+- Create: `templates/gtk/README.md`
+- Modify: `AGENTS.md`, `templates/qt-gtk/README.md`
+
+- [ ] **Step 1: Write the GTK README**
+
+Create `templates/gtk/README.md`:
+
+```markdown
+# GTK3 and GTK4
+
+Generated from the palette, installed as `~/.themes/udt`. One fixed name for
+every scheme, like Kvantum: the name is referenced by gsettings and by the
+GTK4 symlink, so a per-scheme name would mean rewriting both on every switch.
+
+Derived from `catppuccin/gtk` v1.0.3, which is **archived upstream**. Forking
+it is deliberate: it is the theme this desktop already ran, and it will not
+track future GTK4/libadwaita changes. Replacing it means replacing these
+templates, not editing a consumer.
+
+## What is substituted
+
+Colours appear in two forms and both are replaced: the hex (`#b7bdf8`) and the
+bare decimal triple GTK writes inside `rgba()` (`183, 189, 248`). Substitution
+is whole-file, not confined to the `@define-color` block at the tail, because
+the 8400 lines above it hardcode palette colours too. That was measured, not
+assumed: 338 occurrences in GTK3 and 328 in GTK4 sit outside the block.
+
+`#eff1f5` is upstream's foreground, which is Latte `text`, a near-white it
+pairs with a Macchiato background. It maps to the `fg` role so GTK text matches
+rofi, kitty and waybar rather than being brighter than all of them.
+
+## What is left alone
+
+Neutral greys and upstream's derived tints: `#3e4152`, `#2e3143`, the
+`rgba(0, 0, 0, ...)` shadows, `#ffffff`, and the `*_500`/`*_700`/`*_900`
+Material swatch constants, which ship with every build and are not theme
+colour. Same rule as the Kvantum SVG: shading is not palette.
+
+Two derived values are mapped anyway, because they are semantic rather than
+shading: `17, 17, 27` (Latte crust, used as text on the accent) becomes
+`bg_deep`, and `182, 106, 119` (a darkened red on the close button) becomes
+`critical`.
+
+## Assets
+
+67 SVGs, carrying only three palette colours. The upstream `gtk-3.0/assets`
+and `gtk-4.0/assets` directories are byte-identical, so one templated set is
+rendered and copied into both.
+
+## Gotchas
+
+- **GTK3 takes its theme name from gsettings on Wayland**, not `settings.ini`.
+ The file is written too, but gsettings wins; it was once pinned to `Breeze`,
+ silently overriding the file for who knows how long.
+- **GTK4 ignores `gtk-theme-name` entirely.** Its stylesheet is imported by
+ `~/.config/gtk-4.0/gtk.css` through the `theme` symlink.
+- **`gtk-dark.css` must exist** alongside `gtk.css`. Apps request one or the
+ other depending on the prefer-dark setting; upstream ships them identical.
+- **GTK apps only reread a theme at startup**, like Qt. A running app keeps the
+ old colours, which is why the accent here is the fixed fallback rather than
+ the wallpaper-tracking one.
+```
+
+- [ ] **Step 2: Correct AGENTS.md's Qt/GTK section**
+
+In `AGENTS.md`, find the paragraph beginning `Qt and GTK apps reach the palette through Kvantum.` and replace its first sentence with:
+
+```markdown
+Qt apps reach the palette through Kvantum; GTK has its own generated theme.
+```
+
+Then, after that paragraph, add:
+
+```markdown
+**GTK is generated, not installed from upstream.** It used to sit on a
+hardcoded `catppuccin-macchiato-lavender` from `catppuccin/gtk`, which meant
+every GTK app stayed macchiato while the rest of the desktop moved to another
+scheme. The stylesheets are now templates rendered into `~/.themes/udt`; see
+`templates/gtk/README.md` for what is substituted and what is deliberately
+left as upstream drew it.
+```
+
+- [ ] **Step 3: Correct the reload table**
+
+In `AGENTS.md`, the reload table's `Qt / GTK apps` row is still accurate (both reread only on restart). Leave it. Verify by reading the table.
+
+- [ ] **Step 4: Correct the qt-gtk README**
+
+In `templates/qt-gtk/README.md`, replace the `## Upstream themes` table's GTK row and the sentence above it, so it reads:
+
+```markdown
+## Upstream themes
+
+Qt's theme is installed from upstream. GTK's is generated by this repo; see
+`templates/gtk/README.md`.
+
+| | Theme | Source |
+| --- | --- | --- |
+| Qt5 + Qt6 | `udt` | generated, derived from `catppuccin/Kvantum` |
+| GTK3 + GTK4 | `udt` | generated, derived from `catppuccin/gtk` v1.0.3 |
+```
+
+And replace the `## Reproducing` gsettings line for the theme with:
+
+```bash
+ gsettings set org.gnome.desktop.interface gtk-theme 'udt'
+```
+
+- [ ] **Step 5: Verify the docs match reality**
+
+```bash
+grep -n "catppuccin-macchiato-lavender-standard" AGENTS.md templates/qt-gtk/README.md
+```
+
+Expected: no hits outside a historical note. Any remaining reference describes the old wiring and must be corrected or marked as history.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add templates/gtk/README.md AGENTS.md templates/qt-gtk/README.md
+git commit -m "docs(gtk): record GTK as a generated consumer"
+```
+
+---
+
+### Task 10: Final verification
+
+- [ ] **Step 1: Full selftest**
+
+Run: `./bin/udt-palette --selftest`
+Expected: PASS, nine schemes at 69 roles each, `selftest OK`.
+
+- [ ] **Step 2: Clean install from scratch**
+
+```bash
+rm -rf ~/.themes/udt
+./install.sh
+ls ~/.themes/udt/gtk-3.0/assets | wc -l
+```
+
+Expected: exit 0, and `67`. This proves the install block creates everything it needs rather than relying on what an earlier run left behind.
+
+- [ ] **Step 3: Confirm the working tree is clean and nothing generated is tracked**
+
+```bash
+git status --short
+git ls-files templates/gtk/ | grep -v "\.in$" | grep -v README
+```
+
+Expected: clean tree; the second command returns nothing (only `.in` templates and the README are tracked).
+
+- [ ] **Step 4: Ask the user to confirm visually**
+
+GTK apps must be restarted to pick up the theme. Ask the user to open a GTK3 app and a GTK4/libadwaita app and confirm both match the current scheme. This is the one check that cannot be automated, and the same reason the Sublime chrome needed eyes.
+
+---
+
+## Self-Review
+
+**Spec coverage.** The ask was: make GTK follow the scheme, whole-file substitution, accept the archived-upstream risk. Task 3 does the stylesheets, Task 4 the assets and metatheme, Task 5 the generator wiring, Task 7 the install and rewiring, Task 8 proves a scheme switch moves GTK. Task 1 adds the one missing role. Task 6 adds the regression guard. Task 9 corrects the two documents that currently state the opposite.
+
+**Placeholders.** Every code step contains the actual code; every verification step names the command and the expected output. The one judgement left to execution is which colour name each non-Catppuccin scheme uses for its orange, which Task 1 Step 1 discovers rather than guesses.
+
+**Type consistency.** `GTK_ROLES` (Task 2) is consumed by `gen_gtk` (Task 5) and the selftest (Task 6). `rgb_triple` (Task 2) is called only by `gen_gtk`. The `_RGB@`-before-`@ROLE@` ordering is stated in Task 5 Step 1 and matters; `@FG@` is a prefix of `@FG_RGB@`. `gen_gtk_asset` handles only the three roles the SVGs contain, which Task 4 Step 2 verifies.
+
+**Known risk.** `@MAUVE@` is spelled for the upstream colour it replaces but reads the `highlight` role; `gen_gtk` handles it after the loop and the selftest whitelists it explicitly. If a future role named `mauve` appears, that special case should go.