aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-11-unified-desktop-theme-design.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-11 09:17:17 +0200
committerDanilo M. <danix@danix.xyz>2026-09-11 09:17:17 +0200
commitec0dacff5f9a273c2614c50e307db9fc245baf22 (patch)
tree798054b5bb48cd02868070af133f16686634b47b /docs/superpowers/specs/2026-09-11-unified-desktop-theme-design.md
parent3bfe81ea6ec7779558c08e08755893ca700a83e5 (diff)
downloadunified-desktop-theme-ec0dacff5f9a273c2614c50e307db9fc245baf22.tar.gz
unified-desktop-theme-ec0dacff5f9a273c2614c50e307db9fc245baf22.zip
docs: correct accent pipeline premise and add implementation plan
Investigation while planning showed the spec's accent model could not work as written. wallp invokes pywal with --theme, not -i, so colors come from a fixed preset and never track the wallpaper: snapping would have mapped a constant to a constant. The obvious fix, switching to wallpaper-derived pywal, would have regressed a problem the user had already solved. Wallpaper colors land in kitty's sixteen ANSI slots, where nothing guarantees color4 stays legible against color0, which is what made neovim comments disappear. Rofi is not exposed to that, since it uses one accent against a fixed base. So extraction now calls pywal.backends.colorz.get() directly. It returns a list and writes nothing, leaving the preset cache that kitty, dunst and neovim read untouched. Verified across six wallpapers with colors.json byte-identical after. Also replaces HLS hue matching with CIELAB, after prototyping showed HLS snapping orange to yellow and red to flamingo, and narrows the candidate set to nine accents, dropping near-neutral tints and duplicate hues that captured saturated inputs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G6aeE37K4GHBsaM51yLTTq
Diffstat (limited to 'docs/superpowers/specs/2026-09-11-unified-desktop-theme-design.md')
-rw-r--r--docs/superpowers/specs/2026-09-11-unified-desktop-theme-design.md131
1 files changed, 92 insertions, 39 deletions
diff --git a/docs/superpowers/specs/2026-09-11-unified-desktop-theme-design.md b/docs/superpowers/specs/2026-09-11-unified-desktop-theme-design.md
index 8db59e5..3aa46dd 100644
--- a/docs/superpowers/specs/2026-09-11-unified-desktop-theme-design.md
+++ b/docs/superpowers/specs/2026-09-11-unified-desktop-theme-design.md
@@ -7,8 +7,8 @@ Status: approved, phase 1 not yet implemented
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 through pywal, but is
-always snapped to a real Macchiato accent so the result is never unreadable.
+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.
@@ -37,8 +37,7 @@ background is `#1E1D2F` and its `selected` is `#7AA2F7`, a Tokyo Night blue.
## Decisions
-Four decisions were made during brainstorming, each with alternatives
-considered:
+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
@@ -46,11 +45,11 @@ considered:
wallpaper tie-in, and the pywal pipeline already exists); fully
pywal-driven (readability not guaranteed).
-2. **Accent selection: snap to the nearest Macchiato accent.** pywal's dominant
- color is matched by hue against the 14 named Macchiato accents and the
- closest one wins. 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).
+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
@@ -60,10 +59,14 @@ considered:
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/wal/templates/udt-accent.rasi # pywal template, emits raw dominant color
~/.config/rofi/udt/
palette.rasi # Macchiato, fixed, hand-written
accent.rasi # symlink -> ~/.cache/wal/udt-accent.rasi (generated)
@@ -71,21 +74,47 @@ considered:
launcher.rasi # grid + search
menu.rasi # small, fixed options
list.rasi # tall searchable list
-~/bin/udt-accent # snaps pywal dominant color to nearest Macchiato accent
+~/bin/udt-accent # extracts wallpaper color, snaps to nearest Macchiato accent
```
Data flow on wallpaper change:
-1. pywal renders `udt-accent.rasi` into `~/.cache/wal/` with the raw dominant
- color.
-2. `udt-accent` reads it, snaps to the nearest Macchiato accent, rewrites the
- file in place.
-3. Next rofi invocation picks it up. Rofi reads its theme per launch, so no
+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. `udt-accent` is called from
-`wal.sh`, which already performs the symlink-and-restart sequence for dunst and
-kitty.
+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
@@ -105,31 +134,49 @@ blue #8aadf4 lavender #b7bdf8
```
`accent.rasi` defines exactly one variable, `@accent`, always equal to one of
-the 14 accent colors above.
+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 only (`colorsys`, `re`).
+`~/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).
-- Reads the raw color from `~/.cache/wal/udt-accent.rasi`.
-- Converts it and the 14 candidate accents to HLS.
-- Picks the candidate with the smallest circular hue distance.
-- Writes `* { accent: #rrggbb; }` back to the same path.
-- Falls back to `mauve` when the cache file is missing or unparseable, so a
- fresh machine or a failed pywal run still yields a working theme.
+**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.
-Hue alone is the comparison metric: the Macchiato accents are already
-normalized for lightness and saturation against the base, so matching hue is
-what selects the perceptually right one. A near-grey wallpaper color has an
-unstable hue, so colors below a small saturation threshold 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.
-Leaves one runnable self-check (`udt-accent --selftest`) asserting that a known
-orange input snaps to `peach`, a known green to `green`, and a grey to 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
@@ -176,7 +223,9 @@ 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.
+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.
@@ -186,15 +235,19 @@ 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.
-- **kitty**: already pywal-templated, same.
+- **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 (to be confirmed). Needs `LICENSE`, per-file header notices with
+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.