aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-11-unified-desktop-theme-design.md
blob: 3aa46dd4028fdd653521a856f43a253b6c33ad17 (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
# Unified Desktop Theme, Design

Date: 2026-09-11
Status: approved, phase 1 not yet implemented

## Goal

One consistent visual identity across the desktop: Catppuccin Macchiato as the
fixed base, Noto Sans as the UI font, Inconsolata Nerd Font Mono as the
monospace font. The accent color follows the wallpaper, but is always snapped
to a real Macchiato accent so the result is never unreadable.

Phase 1 covers rofi only. Later phases extend the same mechanism to waybar,
dunst, kitty, conky, hyprland and quickshell.

## Problem

The rofi setup has grown five independent theme systems with three different
color sources and three different fonts:

| Theme system | Color source | Used by |
| --- | --- | --- |
| `darknix/` | pywal (`~/.cache/wal/darknix-colors.rasi`) | appsmenu, emoji, notes, qemu, scrotmenu |
| `launchers/type-2/style-1` (adi1090x) | `colors/catppuccin.rasi` | sshmenu, github-repos, hypr-windows, cliphist |
| `launchers/type-1` (adi1090x) | `colors/catppuccin.rasi` | ALT+F2 launcher |
| `powermenu/type-4` (adi1090x) | `colors/catppuccin.rasi` | Mod+x powermenu |
| `elegantVagrant/` | self-contained | rofipass |
| (none) | rofi defaults | ddgr_search.py |

Fonts in play: `JetBrains Mono Nerd Font 10` (adi1090x shared/fonts.rasi),
`Mono 12` (rofi config.rasi), plus whatever each standalone theme sets. Neither
matches the Qt/GTK setting, which is already Noto Sans + Inconsolata Nerd Font
Mono.

The `colors/catppuccin.rasi` shipped by adi1090x is not Macchiato. Its
background is `#1E1D2F` and its `selected` is `#7AA2F7`, a Tokyo Night blue.

## Decisions

Five decisions were made during design, each with alternatives considered:

1. **Color model: Macchiato base with a pywal-driven accent.** Structural
   colors (backgrounds, text) are fixed Macchiato. Exactly one color, the
   accent, follows the wallpaper. Rejected: fully fixed palette (loses the
   wallpaper tie-in, and the pywal pipeline already exists); fully
   pywal-driven (readability not guaranteed).

2. **Accent selection: snap to the nearest Macchiato accent**, matched by
   perceptual hue in CIELAB against a curated set of nine accents. Rejected:
   raw pywal color (can be muddy or dark against the base); raw color with a
   contrast floor (keeps more wallpaper fidelity but can emit colors outside
   the palette).

3. **Scope: rofi only in phase 1.** Rofi holds the actual inconsistency and
   exercises every part of the pipeline. Once proven, each further app is a
   single template file.

4. **Layouts: three shapes, one palette.** The scripts genuinely have three
   different jobs and forcing one shape on all of them would push the variation
   back into scattered `-theme-str` strings. Revisit if three proves wrong.

5. **Accent extraction is isolated from the pywal cache.** The accent is
   derived by calling the colorz backend directly on the wallpaper image, not
   by running `wal -i`. See "Why extraction is isolated" below: this is what
   keeps the change from regressing terminal readability.

## Architecture

```
~/.config/rofi/udt/
  palette.rasi     # Macchiato, fixed, hand-written
  accent.rasi      # symlink -> ~/.cache/wal/udt-accent.rasi (generated)
  common.rasi      # fonts, radii, spacing, element states; imports palette + accent
  launcher.rasi    # grid + search
  menu.rasi        # small, fixed options
  list.rasi        # tall searchable list
~/bin/udt-accent   # extracts wallpaper color, snaps to nearest Macchiato accent
```

Data flow on wallpaper change:

1. `wallp` sets the wallpaper and calls `udt-accent <wallpaper>`.
2. `udt-accent` extracts the image's signature color, snaps it to the nearest
   Macchiato accent, and writes `~/.cache/wal/udt-accent.rasi`.
3. The next rofi invocation picks it up. Rofi reads its theme per launch, so no
   daemon and no reload are needed.

Step 3 is why this needs no running process.

### Why extraction is isolated

`wallp` invokes pywal as `wal --backend colorz -nq --theme "$THEME"`, with
`THEME` currently `sexy-splurge`. The colors are therefore from a fixed preset,
not from the wallpaper: `background` and `foreground` are pure black and white,
and `color1`-`color15` never change when the wallpaper does.

This is deliberate. A previous wallpaper-derived setup made terminal text
unreadable. The cause is structural: the kitty template maps `{color1}` through
`{color15}` onto the terminal's sixteen ANSI slots, and every terminal program
(neovim, ls, git) picks colors by ANSI index. Nothing in a wallpaper-derived
palette guarantees that `color4` stays legible against `color0`, so low-contrast
images produce invisible comment text. Hand-tuned presets do guarantee it.

Rofi is not exposed to that failure. It uses a single accent as a highlight
against a fixed Macchiato base, and the snapper can only emit one of nine
Macchiato accents, all of which are designed to be readable on `@base`.
Contrast is guaranteed by construction, whatever the input color is.

So the accent must be derived without touching the pywal cache. `wal -i` has no
isolation flag and rewrites all of `~/.cache/wal/`, including
`colors-kitty.conf`, which would reintroduce exactly the old problem. Instead
`udt-accent` calls `pywal.backends.colorz.get()` directly. That function returns
a list of colors and writes nothing, so the preset-driven cache that kitty,
dunst and neovim depend on is left untouched.

Verified during design: extracting accents for six different wallpapers left
`~/.cache/wal/colors.json` byte-identical.

## Color contract

`palette.rasi` defines the full Macchiato palette by semantic name. Values are
from the official `catppuccin/palette` repository:

```
base #24273a   mantle #1e2030   crust #181926
text #cad3f5   subtext1 #b8c0e0   subtext0 #a5adcb
overlay2 #939ab7   overlay1 #8087a2   overlay0 #6e738d
surface2 #5b6078   surface1 #494d64   surface0 #363a4f

rosewater #f4dbd6   flamingo #f0c6c6   pink #f5bde6   mauve #c6a0f6
red #ed8796   maroon #ee99a0   peach #f5a97f   yellow #eed49f
green #a6da95   teal #8bd5ca   sky #91d7e3   sapphire #7dc4e4
blue #8aadf4   lavender #b7bdf8
```

`accent.rasi` defines exactly one variable, `@accent`, always equal to one of
the nine candidate accents listed under "The accent snapper".

Themes reference semantic names only, never literal hex. Everything structural
is fixed; only `@accent` moves.

## The accent snapper

`~/bin/udt-accent`, Python 3. Standard library (`math`, `sys`, `pathlib`) plus
`pywal.backends.colorz`, which is already installed as part of pywal.

Usage: `udt-accent <wallpaper-path>`.

- Calls `colorz.get(image, 16)` to extract the image's colors. Writes nothing.
- Takes the most chromatic of slots 1 through 6 as the image's signature color.
  Slot 0 and the upper slots tend toward near-black and near-white.
- Snaps it to the nearest of nine candidate accents by perceptual hue.
- Writes `* { accent: #rrggbb; }` to `~/.cache/wal/udt-accent.rasi`, atomically
  (write to a temporary file in the same directory, then rename) so a rofi
  launch concurrent with a wallpaper change cannot read a half-written file.
- Falls back to `mauve` when the image is missing or unreadable, so a failure
  still leaves a working theme rather than a broken one.

**Matching metric.** Distance is the circular difference of hue angle in
CIELAB, computed as `atan2(b, a)`. Plain HLS hue was tried first and rejected:
it is not perceptually uniform, and it mismatched obvious cases, snapping
orange to `yellow` and saturated red to `flamingo`.

**Candidate set, nine not fourteen:** pink, mauve, red, peach, yellow, green,
teal, sky, blue. Dropped are `rosewater` and `flamingo` (near-neutral tints
that carry a hue angle but almost no chroma, so they captured saturated inputs),
and `maroon`, `sapphire` and `lavender` (near-duplicate hues of `red`, `sky`
and `mauve`, adding ambiguity but no visible range).

**Grey guard.** A near-grey color has an unstable hue angle, so inputs below a
chroma of 10 fall back to `mauve` rather than snapping arbitrarily.

**Self-check.** `udt-accent --selftest` asserts that each of the nine accents
snaps to itself, that a mid orange gives `peach`, a mid green gives `green`,
and a grey gives the `mauve` fallback.

Known and accepted: fully saturated primaries such as `#ff0000` snap to
`peach` rather than `red`, because Macchiato has no vivid red. Colors that
extreme do not occur in colorz output from real images.

## Layouts

All three import `common.rasi`, which holds the shared identity: Noto Sans for
UI text, Inconsolata Nerd Font Mono for monospace fields, one border radius,
one border width, and one `element.selected` treatment (accent background,
`@base` text).

- **`launcher.rasi`**: centered, search bar, icon grid. App launching.
- **`menu.rasi`**: small centered box, no search, few fixed options.
- **`list.rasi`**: tall, search bar, single wide column. Long searchable lists.

## Migration

Eleven targets. Each gets its `-theme` argument pointed at one of the three new
files; `ddgr_search.py` has no `-theme` today and gains one.

| Layout | Targets |
| --- | --- |
| `launcher.rasi` | `blackpearl-appsmenu.sh`, `launchers/type-1/launcher.sh` (ALT+F2) |
| `menu.rasi` | `qar-scrotmenu.sh`, `blackpearl-notes.sh`, `powermenu/type-4/powermenu.sh` (Mod+x) |
| `list.rasi` | `blackpearl-sshmenu.sh`, `blackpearl-emoji.sh`, `rofi-qemu.sh`, `github-repos.sh`, `hypr-windows.sh`, `rofipass`, `ddgr_search.py`, cliphist bind |

The cliphist binding carries its `-theme` inline in
`~/.config/hypr/sections/keybindings.lua` rather than in a script, so that line
is edited directly.

`launchers/type-1` and `powermenu/type-4` are third-party adi1090x scripts.
They are treated like any other target, a `-theme` path change only, with no
attempt to restructure them.

The old theme directories (`darknix/`, `elegantVagrant/`, `launchers/`,
`applets/`, `powermenu/`) stay on disk untouched. Deleting them is a separate
decision to be made after the new themes are confirmed good.

Out of scope: `ronema` (rofi NetworkManager applet, no longer used, pending
archival by the user).

## Verification

Visual, performed by the user. Each of the eleven targets is launched and
eyeballed against three checks: colors are Macchiato, fonts are Noto Sans and
Inconsolata, and the shape suits the job.

The accent pipeline is verified separately by changing the wallpaper to images
with clearly different dominant hues and confirming the accent tracks and stays
readable. Critically, it also requires confirming that kitty and neovim colors
do NOT change, since leaving them alone is the whole point of isolating the
extraction.

`udt-accent --selftest` covers the snapping logic non-visually.

## Later phases

Each subsequent app reuses `palette.rasi` and `accent.rasi` through a
format-appropriate template, and needs no new mechanism:

- **waybar**: CSS, consumes a generated `colors.css`.
- **dunst**: already pywal-templated, template gets repointed. Note it draws
  from the preset theme, so moving it to Macchiato is a real change, not a
  repoint of the same colors.
- **kitty**: deliberately last, and possibly never. Its ANSI slots are the
  source of the readability problem described above. A Macchiato ANSI mapping
  is hand-tunable and safe, but wallpaper-derived values are not.
- **conky**: Lua config, reads generated values.
- **hyprland**: window borders use the accent.
- **quickshell**: new work, to be designed when the user starts on it.

## License

GPLv2 only. Needs `LICENSE`, per-file header notices with
`Copyright (C) 2026 Danilo M. <danix@danix.xyz>`, and a License section in the
README, added early rather than retrofitted. The README also needs the standard
Development Approach section disclosing AI-assisted development.