diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-29 10:11:18 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-29 10:11:18 +0200 |
| commit | df0639ef214d21a621a3893afed25f1923898730 (patch) | |
| tree | 0a8e6b1106bb1ca5031f41bc007b4df2f21106ed | |
| parent | 26298e6bcca0ece78a76f735fcd4feba21895abb (diff) | |
| download | grub-theme-udt-df0639ef214d21a621a3893afed25f1923898730.tar.gz grub-theme-udt-df0639ef214d21a621a3893afed25f1923898730.zip | |
Add implementation plan; output to dist/udt
The script is named build, so its output directory cannot also be
build/ at the repo root.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-09-29-grub-theme-udt.md | 555 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-09-29-grub-theme-udt-design.md | 12 |
3 files changed, 562 insertions, 7 deletions
@@ -1,2 +1,2 @@ theme.txt -build/ +dist/ diff --git a/docs/superpowers/plans/2026-09-29-grub-theme-udt.md b/docs/superpowers/plans/2026-09-29-grub-theme-udt.md new file mode 100644 index 0000000..f8d7544 --- /dev/null +++ b/docs/superpowers/plans/2026-09-29-grub-theme-udt.md @@ -0,0 +1,555 @@ +# 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. <danix@danix.xyz> +# 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. <danix@danix.xyz> +# 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 <this repo>/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. diff --git a/docs/superpowers/specs/2026-09-29-grub-theme-udt-design.md b/docs/superpowers/specs/2026-09-29-grub-theme-udt-design.md index f8bc517..ed97584 100644 --- a/docs/superpowers/specs/2026-09-29-grub-theme-udt-design.md +++ b/docs/superpowers/specs/2026-09-29-grub-theme-udt-design.md @@ -51,10 +51,10 @@ entry class; adding them later is additive. |---|---|---| | `theme.txt.in` | yes | the template, `@ROLE@` placeholders | | `theme.txt` | no | rendered by udt-palette | -| `build` | yes | stdlib Python script, assembles `build/udt/` | -| `build/udt/` | no | the complete theme, what root copies | +| `build` | yes | stdlib Python script, assembles `dist/udt/` | +| `dist/udt/` | no | the complete theme, what root copies | | `README.md`, `LICENSE` | yes | GPLv2 only | -| `.gitignore` | yes | `theme.txt`, `build/` | +| `.gitignore` | yes | `theme.txt`, `dist/` | ## Placeholders @@ -80,7 +80,7 @@ GRUB ignores comment lines. 3. `build`: - reads `theme.txt`, fails if any `@...@` remains or the `# udt:` line is missing; - - substitutes the title from `/etc/os-release` into `build/udt/theme.txt`; + - substitutes the title from `/etc/os-release` into `dist/udt/theme.txt`; - writes `panel.png`, `select_{w,c,e}.png`, `terminal_box_{nw,n,ne,w,c,e,sw,s,se}.png` with an in-script PNG encoder (`zlib` + `struct`, 8-bit RGBA, not interlaced); @@ -92,7 +92,7 @@ GRUB ignores comment lines. side, metadata stripped. 4. udt's `install.sh` prints the root commands: - rm -rf /boot/grub/themes/udt && cp -r <repo>/build/udt /boot/grub/themes/ + rm -rf /boot/grub/themes/udt && cp -r <repo>/dist/udt /boot/grub/themes/ # once: set GRUB_THEME="/boot/grub/themes/udt/theme.txt" in /etc/default/grub grub-mkconfig -o /boot/grub/grub.cfg @@ -116,7 +116,7 @@ names against the template. `build` exits non-zero with a one-line message when: `theme.txt` is missing or unrendered, the wallpaper link is missing, `magick`/`grub-mkfont`/`fc-match` is absent, or `fc-match` resolves to a fallback font rather than the requested -family. Nothing is written to `build/udt/` until all inputs are valid, so a +family. Nothing is written to `dist/udt/` until all inputs are valid, so a failed build never leaves a half theme for root to copy. ## Testing |
