Skip to main content

max / alloy

greeter: put the console palette on Akari before tuigreet draws tuigreet runs on VT1, a raw Linux console, and can only name ANSI colors, so the first surface a user sees on boot rendered in the kernel's stock palette and jumped into Akari only at session start. Add tools/vtrgb.py to render a setvtrgb table from a makeover theme (no hex hard-coded; the greeter-critical slots resolve to Akari's container/text/prompt/border tones), generate it in the build stage from akari-dawn.toml, and apply it at boot via alloy-vtrgb.service before greetd and any getty. The table is generated, not committed, and asserted 3x16 at build; setvtrgb's presence is asserted too so a missing kbd fails the build rather than no-op at boot. Light-mode only for now: the tuigreet theme string is written for a light palette, and a dark greeter also needs that string flipped, which rides with the dark-mode default task. Runtime application is unverified on hardware.
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 02:12 UTC
Signed with PGP, not checked
Commit: c7fa795fd61d767695f8e3d707ba98662fe3d81e
Parent: cc838f3
5 files changed, +212 insertions, -2 deletions
M Containerfile +32 -1
@@ -27,7 +27,11 @@
27 27 # committed rather than whatever resolves that day.
28 28 FROM quay.io/fedora/fedora:43 AS rust-build
29 29
30 - RUN dnf install -y cargo rust && dnf clean all
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
31 35
32 36 WORKDIR /src
33 37
@@ -86,6 +90,24 @@
86 90 mkdir -p /staged-themes; \
87 91 cp -a "$1/akari-dawn.toml" "$1/akari-night.toml" /staged-themes/
88 92
93 + # The greeter's console palette, derived from the default light theme.
94 + #
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).
100 + #
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
105 + 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
110 +
89 111 # =====================================================================
90 112 # Runtime image — the bootable container itself.
91 113 # =====================================================================
@@ -536,6 +558,15 @@
536 558 # console, it is one that exits on launch.
537 559 COPY --from=rust-build /staged-themes /usr/share/alloy/themes
538 560
561 + # The greeter's console palette (generated in the build stage) and the tool
562 + # that applies it. alloy-vtrgb.service reads the table; setvtrgb ships in kbd,
563 + # which systemd's vconsole setup already pulls in. Assert it here rather than
564 + # let the unit's ConditionPathExists turn a missing binary into a silent
565 + # no-op that drops the greeter back to the stock console palette.
566 + COPY --from=rust-build /staged-vtrgb/vtrgb /usr/share/alloy/vtrgb
567 + RUN command -v setvtrgb >/dev/null \
568 + || { echo "setvtrgb (kbd) is missing; alloy-vtrgb.service would no-op" >&2; exit 1; }
569 +
539 570 # =====================================================================
540 571 # bootc validation — fails the build if the image isn't a valid
541 572 # bootable container.
@@ -1,7 +1,10 @@
1 1 # Alloy greetd configuration
2 2 #
3 3 # Boots into tuigreet on VT1. tuigreet prompts for user and password,
4 - # then execs sway. Alloy palette applied via tuigreet's --theme.
4 + # then execs sway. tuigreet's --theme below names ANSI slots (white,
5 + # yellow, dark_gray ...); those slots carry Akari's tones because
6 + # alloy-vtrgb.service repaints the console palette with setvtrgb before
7 + # this greeter draws. The named colors here are meaningless without it.
5 8 #
6 9 # Command must stay on a single line: greetd's TOML parser rejects the
7 10 # multi-line basic-string / backslash-continuation form ("expected
@@ -17,6 +17,10 @@
17 17 # a match fall through to /usr/lib/systemd/system-preset/ defaults.
18 18
19 19 enable greetd.service
20 + # Paints the console's ANSI slots with Akari before greetd's tuigreet draws on
21 + # them, so the first surface a user sees on boot is themed rather than stock.
22 + # See alloy-vtrgb.service; the map it applies is generated in the Containerfile.
23 + enable alloy-vtrgb.service
20 24 # Inert unless the kernel command line carries `alloy.installer`, which only
21 25 # the installer ISO sets, so enabling it on every install is safe and keeps
22 26 # the live medium from needing a modified copy of the image.
@@ -1,0 +1,45 @@
1 + # Alloy console palette — point the Linux console's sixteen ANSI slots at Akari.
2 + #
3 + # The greeter (tuigreet, on VT1) runs on a raw Linux virtual console, before
4 + # any Wayland session or terminal emulator exists. tuigreet can only name ANSI
5 + # colors (container=white, text=black, prompt=yellow, ...), so the first
6 + # surface a user sees on boot is themed entirely by what those sixteen console
7 + # slots hold. Left alone they are the kernel's stock palette, and login renders
8 + # in generic console colors that then jump into Akari at session start.
9 + #
10 + # setvtrgb rewrites the console color map, which is a single global map the VT
11 + # subsystem shares across every console including ones allocated later, so one
12 + # invocation before the first text surface is enough. The map file is generated
13 + # at image-build time from the default light theme (tools/vtrgb.py over
14 + # akari-dawn.toml); this unit only applies it.
15 + #
16 + # systemd-vconsole-setup owns the console font and keymap and does not touch
17 + # the color map, so ordering after it is for cleanliness, not conflict. The
18 + # Before= list covers every text surface that can paint on a console: greetd on
19 + # VT1, and any getty (other VTs, and the alloy-debug-shell, which also orders
20 + # before getty.target).
21 + #
22 + # Not yet verified on hardware: that setvtrgb, run from a unit with no
23 + # controlling terminal, reaches the console it opens on this image, and that
24 + # the greeter comes up on the Akari tones. Both are the live-boot checks the
25 + # GoingsOn greeter task carries.
26 +
27 + [Unit]
28 + Description=Alloy console palette (Akari)
29 + Documentation=man:setvtrgb(8)
30 + After=systemd-vconsole-setup.service
31 + Before=greetd.service getty.target
32 + # The map is a pure build artifact and setvtrgb ships in kbd; if either is
33 + # absent the palette silently stays stock, which the build-time asserts in the
34 + # Containerfile exist to prevent. The conditions here keep the unit from
35 + # failing loudly on a hand-modified image where they were removed.
36 + ConditionPathExists=/usr/bin/setvtrgb
37 + ConditionPathExists=/usr/share/alloy/vtrgb
38 +
39 + [Service]
40 + Type=oneshot
41 + RemainAfterExit=yes
42 + ExecStart=/usr/bin/setvtrgb /usr/share/alloy/vtrgb
43 +
44 + [Install]
45 + WantedBy=multi-user.target
@@ -1,0 +1,127 @@
1 + #!/usr/bin/env python3
2 + """Emit a Linux-console palette (setvtrgb table) from a makeover theme.
3 +
4 + The greeter runs tuigreet on VT1, a raw Linux virtual console, before any
5 + Wayland session and any terminal emulator exist. tuigreet takes only named
6 + ANSI colors (container=white, text=black, prompt=yellow, ...), so the login
7 + screen's look is entirely decided by what those sixteen console slots are set
8 + to. Left alone they are the kernel's stock palette, and the first surface a
9 + user sees on boot renders in generic console colors instead of Alloy's.
10 +
11 + This reads a makeover .toml and prints the sixteen RGB triples in setvtrgb's
12 + file format, so `setvtrgb <(vtrgb.py theme.toml)` (or a generated file applied
13 + before greetd) lands tuigreet's named colors on the theme's own tones. No hex
14 + is hard-coded here: every value comes from the theme file, the same discipline
15 + tools/wcag_audit.py and the console itself follow.
16 +
17 + Usage:
18 + python3 tools/vtrgb.py <path/to/theme.toml>
19 +
20 + Example:
21 + python3 tools/vtrgb.py ../Libraries/makeover/themes/akari-dawn.toml
22 + """
23 + import sys
24 +
25 + # ---------------------------------------------------------------- toml load
26 +
27 + def _load_toml(path):
28 + try:
29 + import tomllib
30 + except ImportError:
31 + try:
32 + import tomli as tomllib # noqa
33 + except ImportError:
34 + sys.exit("need tomllib (Python 3.11+) or `pip install --user tomli`")
35 + with open(path, "rb") as f:
36 + return tomllib.load(f)
37 +
38 + # ---------------------------------------------------------------- color
39 +
40 + def hex_to_rgb(h):
41 + """#rrggbb -> (r, g, b) ints in [0, 255]."""
42 + h = h.strip().lstrip("#")
43 + if len(h) != 6:
44 + raise ValueError(f"expected 6-hex color, got {h!r}")
45 + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
46 +
47 + # ---------------------------------------------------------------- mapping
48 +
49 + # The sixteen console slots, each named by the makeover token it draws from.
50 + #
51 + # The hue slots (red/green/blue/yellow/magenta/cyan and their bright twins)
52 + # are semantic and mode-independent: they carry status and category meaning,
53 + # so terminal apps sharing this palette (ls --color, git) land on Alloy's
54 + # information colors rather than raw ANSI. This matches rio's own ANSI mapping
55 + # in etc/skel/.config/rio/config.toml, so the console and the emulator agree.
56 + #
57 + # The four greyscale anchors (black/white and their bright twins) are chosen
58 + # by variant so black stays the dark end and white the light end in both
59 + # modes. This is what keeps ANSI legible: tuigreet's text=black must be dark
60 + # against its container=white, whichever theme is loaded. The console default
61 + # background is slot 0, so slot 0 is always the darkest tone and the greeter's
62 + # surround is dark by construction; a light-card-on-dark-field greeter is the
63 + # result in light mode, which is the intended v0 look.
64 + HUE_SLOTS = {
65 + 1: ("status", "danger"), # red
66 + 2: ("status", "success"), # green
67 + 3: ("status", "warning"), # yellow (tuigreet prompt/button)
68 + 4: ("status", "info"), # blue
69 + 5: ("category", "five"), # magenta
70 + 6: ("category", "six"), # cyan
71 + 9: ("action", "primary"), # bright red (the lantern accent)
72 + 10: ("status", "success"), # bright green
73 + 11: ("status", "warning"), # bright yellow
74 + 12: ("status", "info"), # bright blue
75 + 13: ("category", "five"), # bright magenta
76 + 14: ("category", "six"), # bright cyan
77 + }
78 +
79 + # Greyscale anchors per variant. Ordered dark -> light within each mode.
80 + GREY_SLOTS = {
81 + "light": {
82 + 0: ("content", "primary"), # black -> darkest text tone
83 + 8: ("content", "muted"), # bright black -> muted chrome (dark_gray)
84 + 7: ("surface", "raised"), # white -> the login card
85 + 15: ("surface", "overlay"), # bright white -> lightest surface
86 + },
87 + "dark": {
88 + 0: ("surface", "sunken"), # black -> darkest surface
89 + 8: ("content", "muted"), # bright black -> muted chrome
90 + 7: ("content", "secondary"), # white -> readable light tone
91 + 15: ("content", "primary"), # bright white -> lightest text tone
92 + },
93 + }
94 +
95 +
96 + def build_palette(theme):
97 + variant = theme.get("meta", {}).get("variant", "light")
98 + if variant not in GREY_SLOTS:
99 + variant = "light" # high-contrast and anything else follow the light anchors
100 + slots = dict(HUE_SLOTS)
101 + slots.update(GREY_SLOTS[variant])
102 +
103 + palette = []
104 + for i in range(16):
105 + section, key = slots[i]
106 + try:
107 + palette.append(hex_to_rgb(theme[section][key]))
108 + except (KeyError, ValueError) as e:
109 + sys.exit(f"slot {i} ({section}.{key}): {e}")
110 + return palette
111 +
112 + # ---------------------------------------------------------------- output
113 +
114 + def main(argv):
115 + if len(argv) != 2:
116 + sys.exit(__doc__)
117 + theme = _load_toml(argv[1])
118 + palette = build_palette(theme)
119 +
120 + # setvtrgb's file format: three whitespace-separated lines of sixteen
121 + # decimal values each -- all reds, then all greens, then all blues.
122 + for channel in range(3):
123 + print(" ".join(str(rgb[channel]) for rgb in palette))
124 +
125 +
126 + if __name__ == "__main__":
127 + main(sys.argv)