Skip to main content

max / alloy

Generate the desktop skeleton from the theme instead of transcribing it Seventeen files under etc/skel and usr/ carried the Akari palette as hex literals typed in by hand -- 397 of them, GTK and sway and yazi and helix and rio and the kernel cmdline -- with nothing that would notice when one stopped agreeing with themes/akari-dawn.toml. kargs.d/10-alloy.toml said so outright: "there is no build step that derives them, and nothing will report the drift." It had drifted. Three separate copies of the ANSI slot mapping existed -- kargs.d, rio's [colors], and tools/vtrgb.py -- and no two agreed. kargs claimed in a comment to match rio and did not, on three of sixteen slots. Only vtrgb.py varied the achromatic slots by theme polarity, which is the thing that decides whether "black" is the ink or the deepest surface, and it was also the copy that won at runtime, since alloy-vtrgb.service applies its table after the kernel has applied kargs'. Its arrangement is now the only one. crates/skelgen renders the tree at image-build time from the same theme file the console loads. Derived tokens are asked of the crate that owns them rather than recomputed: border.subtle and border.strong from alloy_tui (newly public for this), bevel.light and bevel.dark from makeover. Templates keep the config files' structure and comments and name tokens where the hex was, so they still read as the files they produce. The two Helix themes are fully derived now, including the 41 syntax accents that were picked by eye. Their bright tier is expressed as a mix toward content.primary, which darkens on a light theme and lightens on a dark one, so one expression is right on both polarities. Two real bugs fell out: `comment` resolved to a near-foreground tone on the dark theme, and `bright-white` landed lighter than the page on the light one. tools/vtrgb.py is gone; skelgen emits its table byte-for-byte, asserted against the output captured before deleting it. python3 leaves the build stage with it. Visible changes, all in the terminal: rio's bright-cyan moves from content.secondary to category.six, its dim tier becomes an actual dim instead of five slots duplicating their normal counterparts, and the kernel's vt.default_* arrays move to the table the greeter was already being repainted with a moment later at boot.
Author: Max Johnson <me@maxj.phd> · 2026-07-27 02:15 UTC
Signed with PGP, not checked
Commit: de08f48a0806df6f45bf9badd6b7540ffa5f310b
Parent: f3e1016
44 files changed, +3364 insertions, -2311 deletions
M Cargo.lock +10
@@ -1582,6 +1582,16 @@
1582 1582 source = "registry+https://github.com/rust-lang/crates.io-index"
1583 1583 checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
1584 1584
1585 + [[package]]
1586 + name = "skelgen"
1587 + version = "0.0.0"
1588 + dependencies = [
1589 + "alloy_tui",
1590 + "anyhow",
1591 + "clap",
1592 + "makeover",
1593 + ]
1594 +
1585 1595 [[package]]
1586 1596 name = "slab"
1587 1597 version = "0.4.12"
M Containerfile +61 -27
@@ -27,24 +27,31 @@
27 27 # committed rather than whatever resolves that day.
28 28 FROM quay.io/fedora/fedora:43 AS rust-build
29 29
30 - # python3 for tools/vtrgb.py, which renders the greeter's console palette from
31 - # the theme file below. It is in the base already; naming it keeps the palette
32 - # step from breaking silently if a future base drops it. This stage is
33 - # discarded, so the cost is nil.
34 - RUN dnf install -y cargo rust python3 && dnf clean all
30 + # No python3 any more. It was here for tools/vtrgb.py, which rendered the
31 + # greeter's console palette; skelgen emits that table now, along with the rest
32 + # of the desktop skeleton, so there is one implementation of the ANSI mapping
33 + # instead of three.
34 + RUN dnf install -y cargo rust && dnf clean all
35 35
36 36 WORKDIR /src
37 37
38 38 # The dependency graph first, against a stub main. Without this split
39 39 # every console edit re-downloads and rebuilds every crate underneath it,
40 40 # and the console is the part of this image that changes most often.
41 + #
42 + # Both workspace members are named, or cargo cannot resolve the workspace and
43 + # the whole split silently degrades to a full rebuild every time.
41 44 COPY Cargo.toml Cargo.lock ./
42 45 COPY crates/alloy/Cargo.toml crates/alloy/Cargo.toml
43 - RUN mkdir -p crates/alloy/src \
46 + COPY crates/skelgen/Cargo.toml crates/skelgen/Cargo.toml
47 + RUN mkdir -p crates/alloy/src crates/skelgen/src \
44 48 && echo 'fn main() {}' > crates/alloy/src/main.rs \
49 + && echo 'fn main() {}' > crates/skelgen/src/main.rs \
45 50 && cargo build --release --locked \
46 51 && rm -rf target/release/alloy target/release/deps/alloy-* \
47 - target/release/.fingerprint/alloy-*
52 + target/release/.fingerprint/alloy-* \
53 + target/release/alloy-skelgen target/release/deps/alloy_skelgen-* \
54 + target/release/deps/skelgen-* target/release/.fingerprint/skelgen-*
48 55
49 56 # Then the real source. The removals above are what make cargo rebuild
50 57 # the binary rather than find the stub's artifact already in place.
@@ -68,6 +75,12 @@
68 75 RUN /src/target/release/alloy --version | grep -q '^alloy ' \
69 76 || { echo "built console is the stub; the cache-split cleanup matched nothing" >&2; exit 1; }
70 77
78 + # skelgen gets the same assertion for the same reason. Its stub would exit 0
79 + # having written nothing, and `--help` is the one thing clap gives the real
80 + # binary and not `fn main() {}`.
81 + RUN /src/target/release/alloy-skelgen --help | grep -q -- '--templates' \
82 + || { echo "built skelgen is the stub; the cache-split cleanup matched nothing" >&2; exit 1; }
83 +
71 84 # The console's themes, staged at a path the runtime stage can name.
72 85 #
73 86 # theme.rs has no built-in palette on purpose (docs/TOKENS.md: no hex in
@@ -90,23 +103,33 @@
90 103 mkdir -p /staged-themes; \
91 104 cp -a "$1/akari-dawn.toml" "$1/akari-night.toml" /staged-themes/
92 105
93 - # The greeter's console palette, derived from the default light theme.
106 + # The desktop skeleton, rendered from the two staged themes.
94 107 #
95 - # tuigreet runs on VT1, a raw Linux console with no terminal emulator under it,
96 - # and can only name ANSI colors. alloy-vtrgb.service applies this table with
97 - # setvtrgb at boot so those names land on Akari's tones (see the unit for the
98 - # mechanism). The table is a pure function of akari-dawn.toml, so it is
99 - # generated here, not committed (CLAUDE.md: never store regenerables).
108 + # Everything in the image that carries a color — GTK, sway, yazi, helix, rio,
109 + # zathura, the greeter's console palette, the kernel's own vt.default_* table —
110 + # comes out of this one step. The templates hold structure and token names; the
111 + # theme files hold the colors. Nothing here is committed, because it is a pure
112 + # function of the two (CLAUDE.md: never store regenerables).
100 113 #
101 - # Asserted three lines of sixteen values, for the same reason the theme glob
102 - # above is asserted: generating nothing would ship the greeter back on the
103 - # stock console palette without a word.
104 - COPY tools/vtrgb.py /src/tools/vtrgb.py
114 + # This replaces about four hundred hex literals that were transcribed by hand
115 + # across seventeen files, three of which held their own copy of the ANSI slot
116 + # mapping and no two of which agreed.
117 + #
118 + # skelgen fails the build on an unknown token or an empty render, so the
119 + # assertions the vtrgb and theme-glob steps needed are inside it. The one worth
120 + # keeping outside is the count: a template tree that silently matched nothing
121 + # would copy an empty overlay and every program in the image would fall back to
122 + # its own defaults, with the build log saying nothing at all.
123 + COPY templates/ /src/templates/
105 124 RUN set -eux; \
106 - set -- /root/.cargo/registry/src/*/makeover-*/themes; \
107 - mkdir -p /staged-vtrgb; \
108 - python3 /src/tools/vtrgb.py "$1/akari-dawn.toml" > /staged-vtrgb/vtrgb; \
109 - awk 'NF != 16 { exit 1 } END { if (NR != 3) exit 1 }' /staged-vtrgb/vtrgb
125 + /src/target/release/alloy-skelgen \
126 + --templates /src/templates \
127 + --out /staged-skel \
128 + --theme default=/staged-themes/akari-dawn.toml \
129 + --theme night=/staged-themes/akari-night.toml; \
130 + [ "$(find /staged-skel -type f | wc -l)" -ge 17 ] \
131 + || { echo "skelgen produced fewer files than the tree has templates" >&2; exit 1; }; \
132 + awk 'NF != 16 { exit 1 } END { if (NR != 3) exit 1 }' /staged-skel/usr/share/alloy/vtrgb
110 133
111 134 # =====================================================================
112 135 # Runtime image — the bootable container itself.
@@ -549,9 +572,18 @@
549 572 # etc/skel/.mozilla/ (Firefox first-launch profile seed); system-
550 573 # wide config under etc/; Firefox autoconfig + mozilla.cfg under
551 574 # usr/lib64/firefox/. See docs/IMAGE.md for the layout.
575 + #
576 + # Two sources, in this order. The repo tree is everything whose content is
577 + # fixed; the rendered tree is everything that carries a color, which is not in
578 + # the repo at all — templates/ holds those files with their palette left as
579 + # tokens. The generated tree mirrors `/` the same way, so it lands by the same
580 + # 1:1 rule and simply completes the tree rather than patching it: no file
581 + # appears in both, and a stale copy of a themed file cannot shadow its
582 + # generated version because there is no copy to go stale.
552 583 # =====================================================================
553 584 COPY etc/ /etc/
554 585 COPY usr/ /usr/
586 + COPY --from=rust-build /staged-skel/ /
555 587
556 588 # =====================================================================
557 589 # polkit rules — assert the grant is not inert.
@@ -742,14 +774,16 @@
742 774 # console, it is one that exits on launch.
743 775 COPY --from=rust-build /staged-themes /usr/share/alloy/themes
744 776
745 - # The greeter's console palette (generated in the build stage) and the tool
746 - # that applies it. alloy-vtrgb.service reads the table; setvtrgb ships in kbd,
747 - # which systemd's vconsole setup already pulls in. Assert it here rather than
748 - # let the unit's ConditionPathExists turn a missing binary into a silent
749 - # no-op that drops the greeter back to the stock console palette.
750 - COPY --from=rust-build /staged-vtrgb/vtrgb /usr/share/alloy/vtrgb
777 + # The tool that applies the greeter's console palette. The table itself arrives
778 + # with the rest of the rendered tree above, at /usr/share/alloy/vtrgb.
779 + # alloy-vtrgb.service reads it; setvtrgb ships in kbd, which systemd's vconsole
780 + # setup already pulls in. Assert it here rather than let the unit's
781 + # ConditionPathExists turn a missing binary into a silent no-op that drops the
782 + # greeter back to the stock console palette.
751 783 RUN command -v setvtrgb >/dev/null \
752 784 || { echo "setvtrgb (kbd) is missing; alloy-vtrgb.service would no-op" >&2; exit 1; }
785 + RUN test -s /usr/share/alloy/vtrgb \
786 + || { echo "the console palette did not arrive with the rendered tree" >&2; exit 1; }
753 787
754 788 # =====================================================================
755 789 # bootc validation — fails the build if the image isn't a valid
@@ -733,7 +733,13 @@
733 733 fn the_shipped_schema_describes_the_shipped_config() {
734 734 let schema =
735 735 Schema::parse(include_str!("../../../schemas/rio.toml.schema")).expect("parses");
736 - let config = include_str!("../../../etc/skel/.config/rio/config.toml");
736 + // The template rather than a rendered copy. What this test is about is
737 + // structure — which keys exist, and that an edit rewrites one line and
738 + // not two hundred — and the template carries the file's structure
739 + // exactly; only the color values are still expressions. Reading the
740 + // template also means the check keeps working now that the rendered
741 + // file is a build artifact and is not in the repo at all.
742 + let config = include_str!("../../../templates/etc/skel/.config/rio/config.toml.in");
737 743 let mut bind = FileBind::new(schema, PathBuf::from("rio.toml"), config).expect("parses");
738 744
739 745 assert_eq!(