max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 files changed,
+212 insertions,
-2 deletions
| @@ -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) |