# grub-theme-udt 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:** A udt-coloured GRUB theme: `theme.txt.in` rendered by udt-palette, plus a `build` script that assembles an installable `dist/udt/`. **Architecture:** udt-palette (other repo, already done in its commit 0e2aefc) renders `theme.txt.in` to `theme.txt`. `build` (stdlib Python, one file) reads `theme.txt`, writes PNG slices with its own encoder, builds `.pf2` fonts with `grub-mkfont`, converts the wallpaper with `magick`, and swaps the finished tree into `dist/udt/`. Root copies that tree to `/boot/grub/themes/udt`. **Tech Stack:** Python 3 stdlib, GRUB 2.14 gfxmenu, `grub-mkfont`, `fc-match`, `magick`. Spec: `docs/superpowers/specs/2026-09-29-grub-theme-udt-design.md`. Contract with udt (fixed, do not change without messaging the udt session): - placeholders `@SCHEME@ @BG@ @BG_ALT@ @BG_DEEP@ @SURFACE@ @FG@ @FG_DIM@ @FG_FAINT@ @ACCENT@`, substituted as `#rrggbb`; - no other `@WORD@` token anywhere in `theme.txt.in`; - `build` is executable, at the repo root, independent of cwd; udt's `install.sh` runs it and prints the root commands only on exit 0. Verified facts: `grub-mkfont` records names `Noto Sans Regular 24`, `Noto Sans Bold 32`, `Inconsolata Nerd Font Mono Regular 18`; `fc-match` on a missing family returns a fallback (Liberation Sans), so the family must be checked. --- ### Task 1: theme template **Files:** - Create: `theme.txt.in` - [ ] **Step 1: Write `theme.txt.in`** ``` # GRUB theme for the unified-desktop-theme family, scheme @SCHEME@. # Copyright (C) 2026 Danilo M. # Licensed under the GNU General Public License v2 only. # # udt-palette renders this to theme.txt; ./build assembles dist/udt/ from # that. The next line is read by ./build for the PNG colours; GRUB skips it. # udt: bg_alt=@BG_ALT@ bg_deep=@BG_DEEP@ surface=@SURFACE@ accent=@ACCENT@ title-text: "" desktop-image: "background.jpg" desktop-image-scale-method: "crop" desktop-color: "@BG@" terminal-font: "Inconsolata Nerd Font Mono Regular 18" terminal-box: "terminal_box_*.png" terminal-left: "0" terminal-top: "0" terminal-width: "100%" terminal-height: "100%" terminal-border: "0" # The panel: bg_alt at ~85% alpha, stretched over the left quarter. + image { left = 0 top = 0 width = 25% height = 100% file = "panel.png" } # %TITLE% is filled in by ./build from /etc/os-release. + label { left = 3% top = 8% width = 19% text = "%TITLE%" font = "Noto Sans Bold 32" color = "@FG@" } + boot_menu { left = 2% top = 18% width = 21% height = 60% item_font = "Noto Sans Regular 24" item_color = "@FG_DIM@" selected_item_color = "@FG@" icon_width = 0 icon_height = 0 item_icon_space = 0 item_height = 48 item_padding = 12 item_spacing = 6 selected_item_pixmap_style = "select_*.png" scrollbar = false } + label { left = 3% top = 82% width = 19% id = "__timeout__" text = "Booting in %d seconds" font = "Noto Sans Regular 24" color = "@FG_FAINT@" } + progress_bar { id = "__timeout__" left = 3% top = 87% width = 19% height = 4 fg_color = "@ACCENT@" bg_color = "@SURFACE@" border_color = "@SURFACE@" } ``` - [ ] **Step 2: Check the only `@WORD@` tokens are the contract's** Run: `grep -oE '@[A-Z_]+@' theme.txt.in | sort -u` Expected exactly: `@ACCENT@ @BG@ @BG_ALT@ @BG_DEEP@ @FG@ @FG_DIM@ @FG_FAINT@ @SCHEME@ @SURFACE@` (one per line). - [ ] **Step 3: Commit** ```bash git add theme.txt.in git commit -m "Add theme.txt template" ``` ### Task 2: PNG encoder with selftest **Files:** - Create: `build` (mode 755) - [ ] **Step 1: Write the skeleton with the selftest first** ```python #!/usr/bin/env python3 # build: assemble the installable GRUB theme into dist/udt/. # Copyright (C) 2026 Danilo M. # Licensed under the GNU General Public License v2 only. """Assemble dist/udt/ from the theme.txt udt-palette rendered. Usage: build [--selftest] Writes the PNG slices, the .pf2 fonts and a GRUB-readable copy of the current wallpaper next to theme.txt. Everything is staged in a temporary directory and swapped in at the end, so a failed run leaves the previous dist/udt/ untouched. Never touches /boot: root copies dist/udt/ there. """ import struct import sys import zlib PNG_SIG = b"\x89PNG\r\n\x1a\n" def selftest(): w, h, px = unpng(png(3, 2, (0x12, 0x34, 0x56, 0xd9))) assert (w, h, px) == (3, 2, {(0x12, 0x34, 0x56, 0xd9)}), (w, h, px) print("selftest ok") def main(): if sys.argv[1:] == ["--selftest"]: return selftest() if __name__ == "__main__": main() ``` - [ ] **Step 2: Run, expect failure** Run: `chmod +x build && ./build --selftest` Expected: `NameError: name 'unpng' is not defined` (or `png`). - [ ] **Step 3: Add the encoder and the decoder (decoder is test-only) above `selftest`** ```python def chunk(tag, data): return (struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data))) def png(w, h, rgba): """A w x h PNG of one colour: 8-bit RGBA, not interlaced, which GRUB reads.""" row = b"\0" + bytes(rgba) * w # filter byte 0, then the pixels return (PNG_SIG + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(row * h)) + chunk(b"IEND", b"")) def unpng(data): """Decode what png() writes: (width, height, set of RGBA pixels).""" assert data[:8] == PNG_SIG pos, idat = 8, b"" while pos < len(data): n, tag = struct.unpack(">I4s", data[pos:pos + 8]) body = data[pos + 8:pos + 8 + n] assert struct.unpack(">I", data[pos + 8 + n:pos + 12 + n])[0] == zlib.crc32(tag + body) if tag == b"IHDR": w, h = struct.unpack(">II", body[:8]) elif tag == b"IDAT": idat += body pos += 12 + n raw = zlib.decompress(idat) stride = 1 + 4 * w px = set() for y in range(h): row = raw[y * stride:(y + 1) * stride] assert row[0] == 0 px |= {tuple(row[1 + 4 * x:5 + 4 * x]) for x in range(w)} return w, h, px ``` - [ ] **Step 4: Run, expect pass** Run: `./build --selftest` Expected: `selftest ok` - [ ] **Step 5: Commit** ```bash git add build git commit -m "Add build script with PNG encoder" ``` ### Task 3: reading the rendered theme **Files:** - Modify: `build` - [ ] **Step 1: Extend `selftest` (append before the `print`)** ```python good = "# udt: bg_alt=#1e2030 accent=#b7bdf8\ncolor = \"#cad3f5\"\n" assert read_colours(good) == {"bg_alt": (0x1e, 0x20, 0x30), "accent": (0xb7, 0xbd, 0xf8)} for bad in (good + 'color = "@FG@"\n', 'color = "#cad3f5"\n'): try: read_colours(bad) except SystemExit: pass else: raise AssertionError(f"accepted: {bad!r}") tpl = (REPO / "theme.txt.in").read_text() assert set(re.findall(r"@[A-Z_]+@", tpl)) <= PLACEHOLDERS, "template token udt does not fill" for _, _, name in FONTS.values(): assert f'"{name}"' in tpl, f"template does not use font {name}" ``` - [ ] **Step 2: Run, expect failure** Run: `./build --selftest` Expected: `NameError: name 'read_colours' is not defined`. - [ ] **Step 3: Implement** Imports become: ```python import re import struct import sys import zlib from pathlib import Path ``` Constants, after `PNG_SIG`: ```python REPO = Path(__file__).resolve().parent PLACEHOLDERS = {f"@{r}@" for r in ( "SCHEME", "BG", "BG_ALT", "BG_DEEP", "SURFACE", "FG", "FG_DIM", "FG_FAINT", "ACCENT")} # file name: (fc-match pattern, size, the name grub-mkfont records and theme.txt uses) FONTS = { "noto-sans-regular-24.pf2": ("Noto Sans:regular", 24, "Noto Sans Regular 24"), "noto-sans-bold-32.pf2": ("Noto Sans:bold", 32, "Noto Sans Bold 32"), "inconsolata-18.pf2": ("Inconsolata Nerd Font Mono", 18, "Inconsolata Nerd Font Mono Regular 18"), } ``` Functions, before `selftest`: ```python def die(msg): sys.exit(f"build: {msg}") def read_colours(text): """The colours on the '# udt:' line, as RGB tuples, from a rendered theme.""" left = sorted(set(re.findall(r"@[A-Z_]+@", text))) if left: die(f"theme.txt still has placeholders {' '.join(left)}; rerun udt-palette") m = re.search(r"^# udt: (.+)$", text, re.M) if not m: die("theme.txt has no '# udt:' colour line") return {k: tuple(bytes.fromhex(v.lstrip("#"))) for k, v in (kv.split("=") for kv in m.group(1).split())} ``` - [ ] **Step 4: Run, expect pass** Run: `./build --selftest` Expected: `selftest ok` - [ ] **Step 5: Commit** ```bash git add build git commit -m "build: read colours from rendered theme.txt" ``` ### Task 4: assembling dist/udt **Files:** - Modify: `build` - [ ] **Step 1: Add imports** ```python import os import shutil import subprocess import tempfile ``` - [ ] **Step 2: Add constants after `FONTS`** ```python RENDERED = REPO / "theme.txt" BUILD = REPO / "dist" OUT = BUILD / "udt" FONT_CACHE = BUILD / "fonts" # fonts do not follow the scheme, so built once WALLPAPER = Path.home() / ".cache/udt/wpaper" PANEL_ALPHA = 0xd9 # ~85%, enough to read text over any wallpaper MAX_SIDE = 2560 # widest mode in use; GRUB crops, a bigger image only costs /boot ``` - [ ] **Step 3: Add the builders before `selftest`** ```python def run(*cmd): p = subprocess.run(cmd, capture_output=True, text=True) if p.returncode: die(f"{cmd[0]} failed:\n{p.stderr.strip()}") return p.stdout def pf2_name(path): """The NAME section of a .pf2, which is what theme.txt refers to.""" data = path.read_bytes() pos = 0 while pos < len(data): tag, n = struct.unpack(">4sI", data[pos:pos + 8]) if tag == b"NAME": return data[pos + 8:pos + 8 + n].rstrip(b"\0").decode() pos += 8 + n die(f"{path.name} has no NAME section") def font(fname, pattern, size, name): """A cached .pf2, built from the TTF fontconfig resolves the pattern to.""" cached = FONT_CACHE / fname if cached.exists(): return cached family = pattern.split(":")[0] # fc-match never fails: a missing family comes back as a fallback font. if family not in run("fc-match", "-f", "%{family}", pattern).split(","): die(f"font {family!r} is not installed") ttf = run("fc-match", "-f", "%{file}", pattern) FONT_CACHE.mkdir(parents=True, exist_ok=True) tmp = cached.with_suffix(".tmp") run("grub-mkfont", "-s", str(size), "-o", str(tmp), ttf) if pf2_name(tmp) != name: die(f"{fname} is named {pf2_name(tmp)!r}, theme.txt expects {name!r}") tmp.rename(cached) return cached def os_name(): for line in Path("/etc/os-release").read_text().splitlines(): if line.startswith("NAME="): return line[5:].strip('"') return "GNU/Linux" def assemble(stage, text, c, wallpaper, fonts): (stage / "theme.txt").write_text(text.replace("%TITLE%", os_name())) slices = { "panel.png": (8, 8, (*c["bg_alt"], PANEL_ALPHA)), # The selected item: a 4px accent bar on the left, surface behind. "select_w.png": (4, 1, (*c["accent"], 255)), "select_c.png": (1, 1, (*c["surface"], 255)), "select_e.png": (8, 1, (*c["surface"], 255)), } for s in ("nw", "n", "ne", "w", "c", "e", "sw", "s", "se"): slices[f"terminal_box_{s}.png"] = (1, 1, (*c["bg_deep"], 255)) for fname, (w, h, rgba) in slices.items(): (stage / fname).write_bytes(png(w, h, rgba)) for f in fonts: shutil.copy(f, stage / f.name) # [0]: first frame only. Baseline JPEG, alpha flattened: GRUB reads # neither progressive JPEG nor webp, and has no use for transparency here. bg = "#%02x%02x%02x" % c["bg_deep"] run("magick", f"{wallpaper}[0]", "-auto-orient", "-background", bg, "-alpha", "remove", "-resize", f"{MAX_SIDE}x{MAX_SIDE}>", "-strip", "-interlace", "none", "-quality", "90", str(stage / "background.jpg")) ``` - [ ] **Step 4: Replace `main`** ```python def main(): if sys.argv[1:] == ["--selftest"]: return selftest() for tool in ("magick", "grub-mkfont", "fc-match"): if not shutil.which(tool): die(f"{tool} not found") if not RENDERED.exists(): die("theme.txt missing; run udt-palette first") text = RENDERED.read_text() c = read_colours(text) try: wallpaper = WALLPAPER.resolve(strict=True) except FileNotFoundError: die(f"no wallpaper at {WALLPAPER}") fonts = [font(f, *spec) for f, spec in FONTS.items()] BUILD.mkdir(exist_ok=True) stage = Path(tempfile.mkdtemp(dir=BUILD, prefix=".udt-")) try: assemble(stage, text, c, wallpaper, fonts) os.chmod(stage, 0o755) # mkdtemp makes it 0700; root copies it as is shutil.rmtree(OUT, ignore_errors=True) stage.rename(OUT) finally: shutil.rmtree(stage, ignore_errors=True) print(f"build: {OUT}") ``` - [ ] **Step 5: Selftest still passes** Run: `./build --selftest` Expected: `selftest ok` - [ ] **Step 6: Failure path leaves nothing behind** Run: `rm -f theme.txt; ./build; echo "exit $?"; ls -A dist 2>/dev/null` Expected: `build: theme.txt missing; run udt-palette first`, `exit 1`, and no `.udt-*` directory listed. - [ ] **Step 7: Real render through udt, then build** Run: `../unified-desktop-theme/bin/udt-palette` (renders every consumer including ours; it writes only gitignored outputs), then `./build`. If udt-palette needs arguments, use whatever `../unified-desktop-theme/install.sh` passes it; do not edit that repo. Expected: `build: /home/.../grub-theme-udt/dist/udt`, then: Run: `ls dist/udt; file dist/udt/background.jpg dist/udt/panel.png; grep -c '@' dist/udt/theme.txt; grep -n 'Slackware\|%TITLE%' dist/udt/theme.txt` Expected: 19 files (theme.txt, background.jpg, 3 .pf2, panel, 3 select, 9 terminal_box); JPEG "baseline"; PNG "8-bit/color RGBA, non-interlaced"; the `@` count is 1 (the copyright email); the title line shows the OS name, no `%TITLE%`. - [ ] **Step 8: Second run reuses cached fonts** Run: `ls -l --time-style=+%T dist/fonts; ./build; ls -l --time-style=+%T dist/fonts` Expected: font timestamps unchanged. - [ ] **Step 9: Commit** ```bash git add build git commit -m "build: assemble dist/udt with fonts and wallpaper" ``` ### Task 5: README **Files:** - Create: `README.md` - [ ] **Step 1: Write `README.md`** ````markdown # grub-theme-udt A GRUB 2 theme for the unified-desktop-theme (udt) family. The background is a snapshot of the current wallpaper; a translucent panel on the left carries the menu, in the active udt scheme's colours. ## How it is wired `theme.txt.in` is a template. udt's `udt-palette` renders it to `theme.txt` with the scheme's colours, then udt's `install.sh` runs `./build`, which assembles the complete theme in `dist/udt/`: `theme.txt`, the PNG slices in the same colours, the `.pf2` fonts and `background.jpg`, a GRUB-readable copy of `~/.cache/udt/wpaper`. Both outputs are gitignored. The accent is the scheme's fixed `accent` role, not the wallpaper-tracking one: nothing at boot can read that. ## Install GRUB reads `/boot` before anything else is mounted, so the theme is copied, not linked. In a root shell: rm -rf /boot/grub/themes/udt && cp -r /dist/udt /boot/grub/themes/ Once, set this in `/etc/default/grub`: GRUB_THEME="/boot/grub/themes/udt/theme.txt" then regenerate the config: grub-mkconfig -o /boot/grub/grub.cfg udt's `install.sh` prints these commands after a successful build; it never runs them. ## After a scheme or wallpaper change Rerun udt's `install.sh`, then the `cp` line above. `grub-mkconfig` is only needed when `GRUB_THEME` changes. The theme on `/boot` stays as it was until root copies the new one. ## Requirements GRUB 2.14 or later (for `desktop-image-scale-method`), Python 3, `grub-mkfont`, `fc-match`, ImageMagick's `magick`, and the fonts Noto Sans (Regular, Bold) and Inconsolata Nerd Font Mono. ## Test ./build --selftest There is no GRUB emulator here, so the visual check is a reboot. Keep the previous `GRUB_THEME` line to go back to. ## License GPLv2 only. See `LICENSE`. ## Development Approach This project is developed using AI-assisted tools. Code is generated with the help of AI based on human-provided specifications, design decisions, and iterative feedback. All contributions are reviewed, tested, and curated by the maintainer before being included in the codebase. AI is used as a productivity and exploration tool, while human oversight remains central to all decisions. The goal is to combine the flexibility of AI-assisted development with standard open-source practices such as transparency, review, and accountability. ```` - [ ] **Step 2: Commit** ```bash git add README.md git commit -m "Add README" ``` ### Task 6: end to end through udt - [ ] **Step 1: Run udt's installer** Run: `../unified-desktop-theme/install.sh` Expected: our `build: .../dist/udt` line, then the three root lines printed. Nothing run as root. - [ ] **Step 2: Report to the user** the printed root commands, and that `grub-mkconfig` also picks up the GFXMODE fix they made. The reboot is theirs.