# DESIGN.md — How the theme icons were created

This covers the two separate icon-theming efforts in this repo: the
one-off Tux status-bar glyph, and the much larger pipeline that re-colors
every app/tray icon on the system to match the active ML4W/matugen
wallpaper palette. The pipeline's implementation lives in `scripts/09`
through `17` plus `scripts/lib/*.py`; `scripts/README.md` has the
one-line-per-script index. This document explains the *why* behind its
design, not just what each script does.

## 1. The status-bar glyph (one-off, not part of the pipeline)

ML4W's Quickshell status bar ships an "ml." text glyph as a button that
toggles the sidebar. `07-fix-ml4w-icon-color.sh` first fixed that glyph's
idle/hover colors to follow the active color scheme instead of ML4W's
hardcoded brand cyan. `08-set-tux-statusbar-icon.sh` then replaced the
glyph entirely with a Tux penguin silhouette, keeping the same click
behavior.

The shape wasn't hand-drawn: CachyOS already ships a clean single-path
Tux silhouette at `/usr/share/icons/char-white/apps/16/tux.svg`
(16×16, originally `fill="currentColor"`). That path was extracted
as-is and re-exported with a hardcoded `fill="#ffffff"`, because
`currentColor` has no meaning inside a plain QML `Image` element — white
is the convention every other `BarButton` icon in this dotfiles profile
uses, and it's what the bar's luminance-based `MultiEffect` colorize
step needs to render at full brightness instead of a dimmed/invisible
shade (the same bug `07` fixed for the old glyph).

This is a single hand-picked asset, not something the generation
pipeline below produces or needs to reproduce.

## 2. The app-icon pipeline: goals and constraints

The actual problem: CachyOS/Hyprland desktop has dozens of app and tray
icons, all drawn by their original icon packs in arbitrary colors. The
goal is to re-skin all of them in the *current* accent color derived
from the wallpaper (via `matugen`), and to do it again automatically
every time the wallpaper changes — without re-deriving each icon's
artwork from scratch on every switch, and without needing network access
or an LLM on the machine actually being themed.

That constraint shaped the whole architecture: a hard split between a
**generate** phase (expensive, run once per icon, cached into this repo
as portable, palette-independent "masters") and a **deploy** phase
(cheap, run on every theme switch, just a color substitution). Everything
numbered `09`–`14`/`16` is generate; `11`/`15`/`17` are deploy;
`06-switch-wallpaper.sh` chains wallpaper change → matugen → deploy
automatically.

## 3. Establishing a visual language (`09`, `svg_samples/`)

Before generating anything, `09-build-icon-style-guide.sh` /
`lib/svg_style_guide.py` measures a folder of reference SVGs
(`svg_samples/`, a set of icons in the target visual style) to derive
numeric targets: monochrome fraction, stroke-free fraction, flat-fill
fraction, an average padding inset (via `rsvg-convert` + ImageMagick
`-trim`), and a corner-roundedness ratio mapped onto potrace's
`--alphamax`. The output, `svg_samples/style_guide.json`, is the spec
the first generation method (below) conforms new traces to — a
measured style, not a guessed one.

## 4. Finding the candidates (`10` / `icon_remote.py resolve`)

Running on the *target* machine (VM or laptop), `resolve` enumerates
every `Icon=` name from installed `.desktop` files plus a curated
tray-icon list (`tray-icon-candidates.txt`), then resolves each name to
a real on-disk file by walking the actual XDG icon-theme lookup chain
(configured GTK theme → fallback themes installed on this system,
breeze-dark/breeze/Adwaita → hicolor), recording which theme and
relative path it resolved through. That theme+relpath pair matters later:
overriding an icon means shadowing it at the *exact* path a real
consumer resolves it through, not just dropping a file somewhere generic.

Each candidate is hashed (`source_hash`) so a driver script can tell,
on a later run, which icons are new or changed and need re-tracing —
everything else is skipped, making the whole pipeline idempotent and
cheap to re-run.

## 5. First method: flat silhouette + potrace (`10`/`11`, superseded)

The original approach reduced every icon to a single flat
`__ACCENT__`-placeholder silhouette:

- A simple flat SVG (≤3 colors, no gradients) is stripped in place:
  every fill becomes the placeholder, strokes are dropped.
- Anything else (raster icons, or complex SVGs) is rasterized, alpha-
  thresholded, and traced with **potrace**, using the alphamax from the
  style guide. Before thresholding, `flatten_background_badge` finds the
  single most common pixel color (the "badge" background most icon packs
  draw behind the glyph) and makes it transparent, since a fully-opaque
  badge and a fully-opaque glyph are otherwise indistinguishable to a
  pure alpha threshold.
- `11-deploy-icon-theme.sh` then stamps the live accent color into the
  cached placeholder and installs into `~/.local/share/icons/`.

This is still in the repo (kept as the flat-silhouette alternative) but
is no longer the default, for two reasons covered next.

### 5a. The badge/glyph problem and LLM refinement (`13`)

Some icon packs draw the background badge and the foreground glyph both
fully opaque, differentiated only by fill color/opacity rather than
alpha — exactly what the dominant-color heuristic above can't always
separate cleanly, producing a degenerate trace (a solid blob, or a thin
hollow outline). `13-llm-refine-icons.sh` / `lib/icon_llm_refine.py`
fixes these specific cases by asking a local coder LLM (Ollama,
`qwen2.5-coder:7b`) to do the separation *structurally*: read the SVG's
actual elements and decide which ones are badge (drop) vs. glyph
(keep + recolor to `__ACCENT__`).

The model is deliberately scoped to **selection, not generation** — it
is never asked to invent geometry. Every kept element's geometry
attributes (`d`, `points`, `cx/cy/r`, `transform`, etc.) are verified
byte-for-byte against the source afterward; if the model altered a
single coordinate, the whole result is rejected and the existing
algorithmic trace is left untouched. That validation is what makes a
7B model's unreliability tolerable here: a bad response is a safe
no-op, never silent corruption. This step runs only on the host that
has Ollama installed (never on the deployed machine), and its output is
cached into `generated_icons/silhouettes/` like everything else.

**The LLM is a generation-time-only, one-time fixup for a handful of
icons — never a theme-switch dependency.** It's invoked once per
problem icon, the validated result is written into the cache, and
`13` never runs again for that icon afterward. Nothing from `06`
onward (wallpaper switch → `15`/`17` deploy) calls Ollama, or needs it
installed, on the VM or any laptop being themed — those steps only ever
substitute real hex colors into the already-cached, already-validated
masters. This is also why the current default method (`14`, duotone
lightness-recoloring, below) needed it even less: it sidesteps the
badge/glyph ambiguity entirely by recoloring per-element lightness
instead of picking glyph vs. badge, so most icons never went through an
LLM at any point.

## 6. Current method: duotone recoloring (`14`/`15`)

The flat-silhouette approach throws away all of an icon's internal
shading — every element becomes one solid color, so a multi-tone icon
loses its depth. The method that replaced it as the default instead
recolors each icon **by its own original lightness**, mapped onto a
two-stop accent gradient (dark→light), preserving whatever shading the
original artist drew instead of discarding it. (Comparison that led to
this choice: https://claude.ai/code/artifact/898c6a99-d891-498c-91ed-57406c6086ab)

Two cases, handled differently depending on whether a real vector
source exists:

- **SVG-sourced icons** (the common case — most come from `kora`) get a
  **pure-vector master**. `lib/icon_duotone_vector.py`'s `tag` mode walks
  every drawable element, resolves its *effective* color (following
  `currentColor` + KDE Breeze-style CSS class rules, blending
  `fill-opacity` toward white, and approximating a `url(#gradient)` ref
  as the average of its stops), computes perceptual lightness
  (`0.2126R + 0.7152G + 0.0722B`), and replaces the fill with a
  placeholder like `fill="__L0.734__"` — not a real color yet, so one
  cached file serves *any* future palette. A handful of icons (e.g.
  CachyOS Hello's accent dots) encode small details as an embedded
  base64 `<image>` instead of a vector shape; those are decoded,
  averaged to a single lightness, and flattened into an equivalent solid
  `<rect>` so they aren't left at their original hardcoded color.
- **PNG-sourced icons** (no vector source to recolor) fall back to a
  **grayscale-with-alpha raster master**: rasterize at a fixed size,
  extract alpha, replace RGB with plain luminance, recombine — same
  effective idea, just raster.

At deploy time (`15-deploy-duotone-theme.sh` /
`lib/icon_duotone_deploy.py`), the target's live palette is fetched
(`~/.config/ml4w/colors/colors.json`, regenerated by matugen on every
wallpaper switch) and two of its stops — `primary_container` (dark) and
`on_primary_container` (light) by default — become the gradient
endpoints. Vector masters get their `__L<f>__` placeholders replaced by
plain string substitution, interpolating dark→light at that lightness;
raster masters go through an ImageMagick `-clut` against a generated
256×1 gradient swatch. Both are then installed as the exact
theme+path(s) the icon resolves through, plus generic `hicolor` and
`breeze-dark` fallback slots, at several raster sizes.

### Why vector-only, no embedded raster

An earlier version of this step embedded the colorized raster as a
base64 `<image>` inside an SVG wrapper, so every consumer would see "one
SVG file" regardless of source. That rendered correctly standalone
(confirmed with `rsvg-convert` and `GdkPixbuf` directly) but silently
failed to display in **rofi's own icon list** — confirmed by a live A/B
test where a plain solid-color `<rect>` showed up instantly and the
`<image>`-wrapped version never did. Real vector shapes with real
`fill="#hex"` attributes render everywhere, so the vector master format
was changed to plain shape elements and the embedded-raster path is only
kept as the PNG-source fallback, which was never affected by that bug
(it installs genuine raster files, not an SVG wrapper).

### Icon aliasing

Icon packs like `kora` ship the same logical icon under several theme
contexts (`apps/`, `devices/`, `status/`, `panel/`, `actions/`, ...). A
`.desktop` file might resolve one context while something else in the
UI resolves another — overriding only the originally-resolved path left
some consumers showing the un-themed original (e.g. Avahi's tools,
which resolve `network-wired` via `devices/`, not `apps/`). `14` scans
the resolved theme's directory tree for every file whose stem matches a
themed icon's name and records all of them as `alias_relpaths` in the
manifest; `15` installs the colorized result at every one of those
paths, not just the first.

## 7. Icons with hardcoded absolute paths: deliberately left alone

A few `.desktop` files set `Icon=` to an absolute path rather than a
themed name (e.g. `xgps`/`xgpsspeed` → gpsd's own
`/usr/share/gpsd/icons/gpsd-logo.png`). No icon-theme override can
intercept that — the app reads the exact file, usually root-owned under
`/usr`. There used to be a `16`/`17` pair here that rasterized and
colorized that file and overwrote it **in place**, using `sudo` — removed
after it caused a real infinite-reload loop: `17` runs non-interactively
whenever `06` or the `19` hook triggers it, and a non-interactive `sudo`
has no tty to prompt on, so it just failed (or, with a password piped
in, failed because the wrong password was being reused across targets)
every time a theme switch tried to retint it. These icons are now simply
left untouched: not worth a sudo-requiring special case for one Tux
penguin logo nobody notices.

## 8. Caching, portability, and safety

- `generated_icons/` (manifests + `silhouettes/`, `duotone/`,
  `duotone_vector/`) is the one-time, expensive output
  of tracing/recoloring every icon, and it is **palette-independent** —
  the lightness/silhouette placeholders mean this single cache serves
  every wallpaper/theme and travels with the repo to a new machine
  without ever needing Ollama, potrace, or re-tracing again (see
  `README.md`'s "Deploying the icon theme to a real CachyOS laptop").
- Every deploy script backs up any pre-existing icon file the first time
  it would overwrite it, to
  `~/.config/timhyperland-icons/original-icons-backup/`, before writing.
  `12-restore-original-icons.sh` reverts by restoring those backups and
  deleting the generic `hicolor`/`breeze-dark` overlay files outright.
- Re-generating duotone masters always re-fetches source art from that
  backup directory first (falling back to `source_path`), because
  `source_path` can itself point at a file an earlier deploy already
  overwrote in place — trusting it blindly after a redeploy would feed
  the pipeline's own prior output back into itself.
