Skip to main content

max / alloy

112.1 KB · 802 lines History Blame Raw
1 # Alloy Stack
2
3 Every pick with reasoning and what was rejected. Companion to [MANIFESTO.md]MANIFESTO.md.
4
5 ## Base
6
7 **Fedora Silverblue / rpm-ostree.** Atomic image-based base. Riding an existing immutable base rather than building a parallel ecosystem is what makes Alloy a months-shaped project instead of a years-shaped one.
8
9 **ublue relationship: alongside, not downstream.** Preserves naming and architectural independence. The "Alloy" name was picked specifically to avoid the ublue "Blue\*" prefix. Tradeoff: no shared image-building infrastructure, so whatever Alloy ships for image composition is on Alloy to build/maintain. Upside: total architectural freedom and a clean brand.
10
11 **Composition strategy:** bootc + Containerfile, `FROM registry.fedoraproject.org/fedora-bootc:43`. Source on `makenot.work/git/max/alloy`, built locally and natively by `build/build-image.sh`. This is the ublue *convention* (Containerfile) without ublue's base image, which is what "alongside ublue" resolves to in practice. Full delivery strategy in [IMAGE.md]IMAGE.md. Containerfile at the repo root.
12
13 **No image is published, and none will be.** Distribution is the builder, not the artifact: you get the source, configure it, build the ISO on your own machine, and write it to a medium. `quay.io/alloy/alloy` is not a future address, there is no signing story to wait on, and hosted CI is not a gap. An always-on x86_64 builder is wanted for development and stands between nobody and running Alloy.
14
15 Two things follow that are easier to read here than to discover. Building needs Linux with podman, real disk, and a long wall clock, so there is no path to an Alloy ISO from macOS or Windows. And an installed machine does not fetch updates: the timer stays disabled unless the medium named an update target, which only a development build does.
16
17 ## Updates: **nothing automatic, and nothing checks on your behalf**
18
19 **An installed Alloy never checks whether it is behind.** No timer is enabled, no metadata is fetched, and `alloy setup` has no update row. `alloy update` is the only path, and a person has to think of it.
20
21 **The reason is the no-phone-home-before-consent principle, applied without an exception.** It is the same rule that keeps tailscaled opt-in. An installed machine makes no outbound request its owner did not ask for, and update metadata is an outbound request like any other. A metadata-only check is a smaller commitment than an auto-apply and is declined on the same grounds: the smaller version of a thing the principle forbids is still the thing.
22
23 **One nudge, and it is computed locally.** The console shows how long it has been since the last check: "last checked 34 days ago", or that it has never been checked. That is a stored timestamp from the last `alloy update` run subtracted from the clock, so it needs no network, contacts nothing, and reveals nothing. It is the one piece of the problem that can be solved without violating the principle, because the machine already knows when you last asked. What it cannot say, and must not imply, is whether an update exists; only that nobody has looked in a while.
24
25 **The remaining cost is real and belongs here rather than in a footnote.** A machine can sit unpatched indefinitely, and the strongest thing it will ever say is that you have not checked recently. It cannot tell you a patch is waiting, because finding that out is the outbound request. Someone who installs Alloy and ignores the staleness line is running whatever they installed, including the security fixes they did not get. That is the price of the principle, and it is charged to the user, so the user is owed a plain statement of it before they install rather than a discovery afterward.
26
27 `bootc-fetch-apply-updates.timer` and `fwupd-refresh.timer` both ship in the base image and neither is enabled by any preset, so this position costs no code. It is what the image already does, now written down. One consequence worth naming: `fwupd-refresh` being off means LVFS firmware metadata is stale too, on Framework hardware, which is the machine class Alloy claims to support. Firmware is checked when a person runs `fwupdmgr`, and not before. There is no first-boot firmware prompt: because no image is published, an ISO is built on the installer's own machine shortly before it is used, so firmware current at build time is still current at first boot.
28
29 Rejected: **a staleness line that phones out to say something useful**, which is the same request wearing a local-looking label. The line reports the age of your last check and nothing about the world. **Enabling the metadata check by default**, which is the outbound request the principle guards against. **Auto-apply via `bootc-fetch-apply-updates.timer`**, which additionally stages updates and carries reboot semantics onto a machine whose owner did not consent to either. **Asking once at first boot**, the runner-up: it resolves the tension rather than picking a side, and `alloy setup` already exists as the place opt-in questions get asked, but it buys explicit consent by adding a screen to a first-boot flow that is meant to be short, and the answer it would collect is one `alloy update` already collects every time it is run.
30
31 ## Hotfixes: **a repo carrying only our own components**
32
33 **Alloy is distributed as a builder, so a machine that is already installed has no way to receive a fix.** Rebuilding the ISO and writing a drive is the whole recovery path, which is not a thing to ask of somebody whose console has a bug. The answer is a signed RPM repo carrying only Make Creative's own components, layered with rpm-ostree. Builders-not-images is untouched: users still build their own ISO, they stop having to rebuild it to receive a fix.
34
35 **Scope is our own software and nothing else.** The console and shop, all MIT, all ours. Fedora's packages have their own update path and are not our business to re-ship, and shipping anyone else's work here would re-attach the source-availability obligations that distributing a builder rather than an image deliberately avoids.
36
37 **The two components version independently.** The console's number comes from `crates/alloy/Cargo.toml` and shop's from its own `--version`, which is what `build/rpm/build.sh` already reads and why it reads them from different places. A hotfix to one republishes only that one. The cost is that the console and the terminal on a machine can sit at unrelated numbers, so no component answers "what version of Alloy is this"; the image does. The alternative was one shared number, which buys that single answer and pays for it by republishing an unchanged component every time the other takes a fix, and that is the normal life of a hotfix rather than an edge case.
38
39 **`alloy --version` reports all three clocks, so a bug report does not have to know to ask.** The console's own version, then a line per component the image names in `/usr/share/alloy/components` other than the console itself, then the image. Each component is asked for its own `--version` rather than looked up in the image or in rpm, because a layered component is exactly the case where the image no longer knows and `build/rpm/build.sh` refuses to package a binary whose `--version` disagrees with what its RPM will claim. A component that is named but not installed is left out rather than reported as unknown, which is what a server profile (no shop) and a machine whose first boot has not laid the layers down both look like. The probes cost a process each, so they run only when the invocation asked for a version.
40
41 **What a hotfix is allowed to be.** A hotfix exists because the shipped software does not work as that release intended. Anything that changes how the software is intended to work is a release, however small the diff, and rides the ordinary path rather than this one. The second half is the channel test: a hotfix is shipped out of band, so a fix that can wait is an ordinary patch and never touches the repo. Security fixes are fixes, on the standing intent that no release be exploitable. Every hotfix is a patch bump; every intent change is at least a minor.
42
43 That definition is narrow on purpose, and here it is load-bearing rather than tidy. Every machine holding a layer is a machine carrying state outside its image, and the fewer of those there are at any moment the better.
44
45 **A hotfixable component cannot be in the base image.** This is measured, not chosen. A component the base carries cannot be replaced client-side: `rpm-ostree install` fails to depsolve against it, and `rpm-ostree override replace` records a request that never activates and survives a reboot as a silent no-op, so the machine reports a hotfix it is not running. A component that is a loose file owned by no package is worse still, and cannot be layered over at all. So the console and shop are layered at install time rather than built into the image. Measurements and how to re-run them: `build/layertest/README.md`.
46
47 **They ride inside the image as packages, and the first boot lays them down.** The image carries `alloy` and `shop` as RPM files in a `file://` repo at `/usr/share/alloy/rpm` and installs neither. Carrying the RPMs is not carrying the component: what blocks a layer is an installed package or a file at the path being layered over, and an uninstalled `.rpm` under `/usr/share` is neither. Because the ISO is the image, the packages travel on the medium, so an offline install produces a working machine and a machine whose owner has not consented to anything yet never reaches the network to become usable. `alloy-layer-components.service` installs what `/usr/share/alloy/components` names on the first boot after an install and reboots once; `/usr` is a read-only ostree tree, so there is no version of this that ends with the console usable in the session it ran from. The live installer environment is the exception and installs the console from the same repo, since it never gets a first boot and `alloy install` is the reason it exists.
48
49 That repo is enabled, and it is forced rather than chosen: `rpm-ostree install` on a booted system supports `--enablerepo` only in a container build, so a repo shipped disabled is one the first-boot unit could not turn on for its own transaction. It reaches no network, which is why this does not touch the position below.
50
51 **And the first boot is fenced to it, or the carried repo buys nothing.** rpm-ostree refreshes metadata for every *enabled* repo before it depsolves, so with Fedora's four enabled the unit needs name resolution to install packages that are already on the disk, and a machine with no route fails its first boot on `Could not resolve hostname for mirrors.fedoraproject.org` and comes up with no console. That is the offline install this design exists to make work. Neither flag can fix it, since `--disablerepo` is refused outside a container build exactly as `--enablerepo` is, and `--cache-only` refreshes nothing at all and cannot see the carried repo on a machine that has never refreshed. So `usr/bin/alloy-layer-repos` disables every other repo for the length of that one transaction and puts `/etc` back afterwards from the image's own copy under `/usr/etc`. The deployment it stages does not inherit the disabled files, measured by rebooting into it. Which repos an installed machine leaves enabled is deliberately unchanged: `alloy pkg` layers Fedora packages, and a machine that shipped them disabled would quietly find nothing. `build/vmtest/offline-first-boot.sh` is what keeps this measured rather than remembered: it installs a machine, takes the guest's link down from the monitor before the first boot, and asserts both ends of the paragraph above: that a console got laid down with no route, and that the enabled repo set afterwards is the ordinary one.
52
53 **The network repo ships disabled, and that is chosen rather than forced.** The carried `file://` repo above had to be enabled; this one did not, and it is `enabled=0` on every installed machine. A fresh Alloy has no line pointing at makenot.work that its owner did not add. Turning it on is one command and needs no extra package:
54
55 sudo sed -i 's/^enabled=0$/enabled=1/' /etc/yum.repos.d/alloy-hotfix.repo
56
57 The cost is real and is not papered over here: **a hotfix reaches only machines whose owner went looking for the channel.** Every other machine stays on what it installed until its owner rebuilds. That is the trade the opt-in default makes, and anyone announcing a fix has to assume most machines will not take it on their own.
58
59 **Our layers are disposable.** A layered package whose version the base later overtakes wedges the machine: every subsequent update fails to depsolve, permanently, and that is the normal life of a hotfix rather than an edge case. The rule that avoids it is to carry no layer of ours across an upgrade. Drop them, upgrade, re-apply only if the base still lacks the fix. It costs one reboot rather than two, because the two transactions compose into one deployment, and it hands `bootc upgrade` back a deployment it will consent to work on.
60
61 On the path Alloy takes today the rule costs nothing to apply, because `bootc switch` applies it for free. `switch` is not `upgrade`: only `upgrade` consults local modifications and refuses, and a switch discards the layer outright rather than carrying it. The machine comes up once with no console, `alloy-layer-components.service` sees no `/usr/bin/alloy` and lays the new image's copies down, and it reboots a second time into them. So a component tracks the image it shipped with, and nothing has to own the drop. The second boot is a blank screen on a machine that was working when its owner typed the command, so it says what it is doing: `alloy-layer-notice` writes to `/dev/console`, which `quiet` does not silence, and `alloy update` says the same thing before the command is typed.
62
63 Dropping a layer by hand has to be precise, and that is what a network repo will need. A request is recorded under the string that was typed to install it, so a package layered by full name-version-release cannot be removed by its bare name, and rpm-ostree reports that as nothing-to-do rather than as a failure. Whatever drives `rpm-ostree upgrade` reads `requested-packages` and removes exactly what it finds. Not `rpm-ostree reset`, which would also drop packages the user layered themselves, and those are not ours to remove.
64
65 **This does not weaken the no-phone-home position above.** The repo is configured on an installed machine, and configured is not consulted: nothing fetches its metadata until somebody runs `alloy update`. A hotfix channel changes what a check can deliver, not whether one happens unasked.
66
67 **Config fixes travel separately, and the RPM channel does not carry them.** `/etc/skel` seeds new users only, so a shipped config change reaches nobody who is already running. `alloy theme apply` is the mechanism that does reach a live home directory, and it records what it wrote so it can tell its own render from something the user edited even after the image has moved. Design half of the same problem, different delivery: see the module docs in `crates/alloy/src/theme_apply.rs`.
68
69 ## Compositor
70
71 **Sway.** Mature, i3-style manual tiling (workspaces plus split/tabbed/stacked containers), Wayland, well-packaged on Fedora. The i3 model is the tiling model Alloy wants: predictable, workspace-based, no infinite scroll. C rather than Rust, accepted as a knowing trade: no mature Rust i3-style tiler exists, and the model outweighs toolkit purity here. The whole sway* ecosystem (swaylock, swayidle, swayosd, swaybar) fits behind it with zero glue.
72
73 Rejected: Niri (scrolling-column tiling, which is not the model Alloy wants), Hyprland (governance contested, animation-forward against Alloy's understatement), river (tag-based/dwm-shaped, Zig, further from i3 than sway), dwl (tiny but too bare for a curated default).
74
75 ### Xwayland: **disabled**
76
77 The session ships `xwayland disable`. X11 has no isolation between clients: any X client can read every other client's keystrokes, window contents and clipboard, because that was the design. Running an X server inside a Wayland session hands that model back to anything that asks, and "anything that asks" includes a flatpak app that Alloy otherwise presents as sandboxed.
78
79 Breaking Steam, most Electron applications and older Java toolkits is the intent rather than a cost to be mitigated. Alloy is a Wayland-only session, and this makes the stack honest about it.
80
81 The package stays in the image. `xwayland enable` in `~/.config/sway/config.d/` restores it, which makes the escape hatch a config line rather than a rebuild, and the manual says so (chapter 7). Rationale for this and the rest of the hardening posture: wiki `alloy-hardening-posture`.
82
83 ## Authored toolkit
84
85 **ratatui.** Alloy authors one thing, the `alloy` console and its `alloy_tui` design-system crate, and it authors in ratatui. Immediate-mode: every frame the UI code runs top to bottom from current state, no bindings, no observables, no retained UI state to diverge. Picked because **Alloy's principle is to avoid the reactive pattern** (staged appearance, async UI trickling, state-divergence bugs, the "web-shaped feel"), and immediate-mode rules those out by construction. `alloy_tui` carries the palette, themed widgets, footer chrome, reserved keys and mock-or-real backend detection. See [CONSOLE.md]CONSOLE.md.
86
87 Conceptual coherence is a bonus: bottom, tuigreet, and the console all read as one ratatui family.
88
89 **No authored GUI.** The graphical pieces a TUI cannot serve are adopted rather than authored: swaylock, mako, swaybar. Rejected GUI toolkits, recorded in case authored GUI ever returns: Slint / iced / gpui / Floem / Dioxus (all reactive), gtk4-rs (couples to GNOME identity), Tauri (JS frontend conflicts with Rust-first).
90
91 ## Bar
92
93 **swaybar** (sway's built-in bar). Configured in the sway config's `bar {}` block with a `status_command`; no extra package or daemon.
94
95 The status line is **`alloy status --bar`**, a console verb rather than a script. It emits swaybar's JSON protocol and shows four blocks: battery percentage and charge state, the connected network, the default output's volume, and the clock.
96
97 A verb and not a shell script because the readers already existed: `alloy audio` fronts `pactl -f json` and `alloy net` fronts `nmcli`, both parsed into types, so a script would have been a second parse of the same two contracts. Battery is read from sysfs rather than through upower, which is in the image and would have worked; the kernel files are what upower itself reads and cost no subprocess on a loop that runs all day. Volume updates on a `pactl subscribe` event rather than on a poll, so the Fn keys move the bar immediately without spending a spawn a second to learn nothing.
98
99 Colors come through the theme's intent tokens, resolved to hex because swaybar's protocol takes nothing else, and are re-read every ten seconds so the bar follows a day/night switch instead of holding the palette it launched in. A theme that will not load leaves the blocks uncolored rather than taking the bar down.
100
101 Rejected: Ironbar (GTK), waybar (C++, and swaybar already covers the need), eww (Lisp/yuck config clashes with the stack), yambar (YAML, ruled out). Re-adopt a standalone bar only if swaybar's status protocol proves too limiting.
102
103 ## Lock
104
105 **swaylock** (C, canonical, minimal, security-audited). Bound Mod+Ctrl+L. A Wayland session-lock surface is inherently graphical and cannot be a TUI, so this is one of the pieces Alloy adopts rather than authors. Lock crash-recovery is swaylock/PAM/greetd's concern.
106
107 **Fingerprint unlock ships, and swaylock gains it without knowing.** `/etc/pam.d/swaylock` is `auth include login`, login includes system-auth, and system-auth is what authselect rewrites when `with-fingerprint` is enabled. So the lock screen, the greeter and `run0` all gain the same unlock from one switch, and Alloy still ships no `/etc/pam.d` file of its own. Two packages: `fprintd` and `fprintd-pam`, the second being the half that makes anything ask the reader.
108
109 `pam_fprintd` is installed `sufficient`, which is the property worth stating: a finger that does not match, a reader that is busy, and a user with nothing enrolled all fall through to the password prompt. Nothing enrols a finger at install time, so until someone runs `fprintd-enroll` this changes nothing at all.
110
111 ## Launcher
112
113 **`alloy-menu`: fzf over desktop entries, run in shop.** Bound `Mod+D`. A shell script in `usr/bin/`, about forty lines, that reads `.desktop` files from the system, user and Flatpak paths, pipes names through fzf, and hands the chosen `Exec` line to `swaymsg exec` so the app outlives the terminal that launched it.
114
115 The split with `Mod+Return` is the design. Typing a command is what a shell is for, and a shell is one keystroke away, so the launcher does not list every binary on `PATH`: doing that buries the browser under coreutils. It lists the graphical apps that have a name, an icon and a binary nobody remembers. Two keys, two jobs.
116
117 A launcher is the second door: a session whose `Mod+Return` binding is broken still has a way to open a terminal and edit the file that fixes it.
118
119 fzf carries both TUI menus: this one and `alloy-clipmenu` (`Mod+Shift+V`), the read half of [clipboard history]#clipboard-history. One package, and the picker stays a tool rather than a dependency Alloy authors around.
120
121 Rejected as graphical launchers: anyrun (GTK), fuzzel/tofi (C, and a graphical launcher is off-thesis). A dedicated `alloy` console verb was the other TUI candidate and is still open as a v1 move; it buys theming and a shared shell with the rest of the console, and it costs a Rust surface where a script already does the job.
122
123 ## Notification daemon
124
125 **mako** (C, by emersion, de facto Wayland notification daemon, lightweight). Notifications are a layer-shell surface, another graphical piece Alloy adopts rather than authors.
126
127 Rejected: swaync (C++), fnott (C), cosmic-notifications (drags libcosmic in; mako is lighter and already fits).
128
129 ## Screenshot stack
130
131 - **grim** for capture, **slurp** for region select. sway has no built-in screenshot; grim+slurp is the canonical wlroots pairing. The active-window grab reads the focused rect out of `swaymsg -t get_tree` with jq, a declared dependency of the image.
132 - **satty** for annotation when needed (Rust, modern, replaces swappy).
133 - **wl-clipboard** for clipboard plumbing.
134
135 The four binds go through [`alloy-shot`]../usr/bin/alloy-shot rather than calling grim directly: Print (full output), Shift+Print (region via slurp), Ctrl+Print (active window), Mod+Print (annotate the most recent capture in satty). **A capture raises a notification**, which is the reason that script exists: a capture that says nothing is indistinguishable from a dead keybind. Passing through one script also means the timestamp in the filename is expanded once rather than once per command, and cancelling a region select with Escape is not reported as an error.
136
137 Rejected: swappy (dated annotator; satty replaces it), wayshot (grim is the more standard wlroots grabber).
138
139 ## File manager
140
141 **yazi** (Rust, async, plugin system, sixel/kitty/iTerm image preview). TUI-only, no GUI fallback shipped. The browser and other GUI apps that need file dialogs go through xdg-desktop-portal, not a bundled file manager, so the daily case is covered.
142
143 Alloy authors no file manager. The scope (file ops, permissions, drag-drop, thumbnails, archives, mounts, trash, search, batch ops, associations) is far larger than the value of authoring it.
144
145 Rejected: broot (Rust TUI, useful as a complement but a different model), nautilus / dolphin (not Rust), cosmic-files (graphical). Users who want a graphical file manager install one themselves with `flatpak install com.system76.CosmicFiles` (or thunar, nautilus) from a remote they added: sandboxed apps are where ad-hoc applications come from, and Alloy provisions neither the apps nor the catalog they come from.
146
147 ## Text editor
148
149 **Helix.** Rust, modal (selection-first, Kakoune-shaped), LSP + tree-sitter + formatters batteries-included, TOML config, immediate-mode-stable, useful with zero config. On a terminal-first system the editor is a TUI; there is no graphical-editor default. Config and the Akari Dawn theme are covered under "system introspection" below.
150
151 **deox**, Alloy's own Helix fork, becomes the intended default once it has a public home to build from; until then, stock Helix ships.
152
153 Rejected: neovim (modernity comes from importing a config framework, the pattern Alloy rejects), vim/kakoune (older or ancestral), Gram/Zed/cosmic-edit (graphical editors, off-thesis). Users who want a different editor install it themselves.
154
155 ## The desktop background
156
157 **A shop surface printing the console's verbs and the sway keybindings.** The
158 permanent default, not a first-boot placeholder that a picker cycles away from.
159
160 The background is a `shop --layer background` surface, output-sized, with
161 `KeyboardInteractivity::None` and an empty input region, so it is a panel that
162 cannot be clicked or focused. It prints the 17 `alloy` console verbs and the 58
163 sway `bindsym` lines. A running Alloy machine ships no keybinding reference
164 anywhere else, and a reference nobody looks at is the state this replaces. The
165 panel's value is that it is read without being sought, which is a property a
166 picker would take away.
167
168 Consequences worth stating: the machine's first impression is a reference card
169 rather than a picture, and that is the intended impression for a terminal-first
170 system whose thesis is that the machine tells you what it can do.
171
172 ## Wallpapers
173
174 **No curated collection ships, and none is planned as the default.** An image
175 background is a thing the user sets, not a thing Alloy provisions.
176
177 **swww** stays installed (Rust, daemon + CLI, IPC-driven, soft-fade transitions,
178 per-output) so that setting one takes two commands and no package install:
179
180 swww-daemon &
181 swww img ~/Pictures/wallpaper.jpg
182
183 Rejected as the default: a curated public-domain impressionist collection. The
184 background is the one surface every user sees without asking, which makes it too
185 valuable to spend on decoration while the machine documents itself nowhere else.
186 The collection remains a defensible opt-in if a picker verb is ever wanted, and
187 the institutions to source it from are the ones that waive reproduction-photo
188 rights (Met Open Access and the Art Institute of Chicago are CC0, Rijksmuseum
189 and the National Gallery of Art are open access; Google Arts & Culture is mixed
190 rights and is a discovery tool, not a source). It is not on the roadmap.
191
192 Also rejected, as daemons: wpaperd, cosmic-bg (sway compatibility unverified),
193 swaybg (C, no cycling or transitions).
194
195 Honest gap: none of these handle ICC color management; that is a Wayland
196 compositor-level concern still being finalized.
197
198 ## Terminal
199
200 **shop.** Rust, WGPU-backed, Wayland-only, TOML config, kitty graphics protocol. Alloy's own, built from source into the image.
201
202 The pick is still driven by the yazi commitment above. Yazi is the TUI primary, "where keyboard-driven Alloy users will live by default." A terminal without graphics protocols neuters the primary tool, so the question isn't "are previews a nice-to-have" but "does the primary tool run at full capability." shop implements the kitty graphics protocol, which is the one yazi wants.
203
204 What an adopted terminal cannot satisfy is the rest:
205
206 - **The terminal is a surface Alloy designs.** Every other surface in the image renders from one makeover theme through one intent table. An adopted terminal renders from sixteen hex values transcribed into its own config, and the two drift the moment either side moves. shop takes a theme by id and resolves the slots itself, from the same `makeover::ansi_intent` table the kernel cmdline and setvtrgb render from. There is nothing left to transcribe, which is why `templates/etc/skel/.config/shop/config.toml.in` carries no colour at all.
207 - **First-party.** The console is authored. The terminal is the other surface a user is inside all day, and adopting it puts the most-used window in the image on someone else's roadmap.
208 - **Wayland-only.** The rest of the stack already assumes it. A terminal carrying an X11 path carries it for nobody here.
209
210 Secondary alignments:
211 - **TOML config.** No scripting language dependency, consistent with Alloy's rejection of Lua/yuck/similar config surfaces elsewhere in the stack.
212 - **No multiplexing.** Tabs, splits, panes are sway's job. A terminal that also tiles is duplicated budget. Alloy therefore ships no multiplexer either: tmux and zellij are not in the image, and a terminal's own docs should not send an Alloy user looking for them. A window is a window, and `Mod+H/J/K/L` moves between them.
213
214 **yazi should run with kitty graphics under shop, not sixel.** shop implements kitty graphics and no sixel, so there is no compat path to fall back to inside the terminal: it is inline previews or chafa half-blocks.
215
216 - `yazi.toml` default: leave image previews on with `image_filter = "triangle"` and `image_quality = 75`.
217 - **Detection is by query, not by name.** shop sets `TERM=xterm-256color` and no `TERM_PROGRAM`, and appears in no terminal list yazi carries. Yazi's fallback for an unrecognised name is to ask: it sends a kitty graphics query and waits on DA1, and shop answers both, so the name meaning nothing to yazi costs nothing. Still worth opening an image directory under shop once on real hardware and confirming inline previews render.
218
219 Cost of authoring rather than adopting, stated plainly:
220
221 - **No repo to ship from.** shop is packaged nowhere, so the image builds it from source at a pinned revision (`SHOP_REV` in the Containerfile). An image build needs network to fetch it, and bumping the pin is a deliberate act rather than something `dnf update` does. It is also an act that can break the image on its own: shop links its system libraries, so a revision can arrive wanting a `-devel` package the Containerfile does not install. `build/check-rust-stage.sh` builds the builder stage and nothing else, which is the check to run after a bump; the astra sweep runs it nightly.
222 - **It is new.** Absent at the pinned revision: scrollback search; reflow, so a resize clips scrollback to the new width rather than rewrapping it; sixel, which the kitty protocol covers the same ground as and which shop deliberately does not claim in its DA1 reply; OSC 8 hyperlinks.
223 - **Complexity is the standing objection.** Terminals are among the most complex userland software: ANSI, terminfo, graphics protocols, IME, OSC, performance under load. The scope is taken on deliberately, with a stated bar and a fallback.
224
225 Risk accepted, and the fallback is **alacritty**, if shop proves unworkable. The graphics gap becomes a knowing loss then.
226
227 Rejected:
228 - **rio.** A capable adopted terminal: Rust, WGPU, TOML, all three graphics protocols, in Fedora repos. Passed over for the reasons above, none of which are faults of rio's.
229 - **alacritty.** Upstream has firmly refused sixel/kitty graphics for years: a definitional stance, not a pending PR. Kills yazi previews permanently. Kept as the fallback anyway, with that loss named.
230 - **wezterm.** Lua config violates the "no scripting-language configs" line held elsewhere in the stack. Its central pitch (built-in multiplexing, tabs, SSH client) duplicates sway's tiling and is spent budget in this stack.
231 - **cosmic-term.** Uses `alacritty_terminal` as the backend (the same graphics gap as alacritty) and additionally couples Alloy's default terminal to libcosmic's visual identity, which the design-system stance rejects.
232
233 ## Browser
234
235 **Firefox baked into the image as the default, with one file of configuration.** Firefox is a pick Alloy defends, not a shrug: it is the only browser reachable on an image-based system whose defaults Alloy can stand behind, and standing behind them costs `/etc/firefox/pref/alloy.js` and nothing else. No policy file, no autoconfig, no `profiles.ini`, no `userChrome.css`. The wiki note is `alloy-byo-principle`.
236
237 Reachability is the load-bearing word. Firefox comes from Fedora's own repos, and those stay enabled after install, so a machine that chose `BROWSER=none` is one `rpm-ostree install firefox` from the browser the image would have carried. That property is a selection criterion for anything Alloy recommends: **a candidate needs support for image-based systems, stated or inherent.**
238
239 ### What the configuration does, and what it may never do
240
241 Three rungs, in order, and a lower one needs an argument for why the one above it did not work:
242
243 1. **Restore a control the vendor hid.** `browser.compactmode.show` ships false, which hides the density control in Customize. Alloy sets it true and stops there. The user is one visible click from compact and Alloy has not said which density is right.
244 2. **Remove an anti-feature.** The test is who the default serves. Telemetry serves the vendor, sponsored placements serve advertisers, tracking protection at "standard" serves the ad industry, and the AI block (`browser.ml.*`, `browser.tabs.groups.smart.*`) ships on, downloads models, and was never opted into.
245 3. **Set a value to Alloy's preference.** Forbidden here. `browser.uidensity` is the named example and the Containerfile asserts it is absent.
246
247 Every line is a `pref`, never a `lockPref`: these are starting positions the user can change in Settings. A locked pref would be Alloy hiding a control while objecting to hidden controls, which is the argument in wiki `irreducible-complexity-principle`.
248
249 ### What does not come back
250
251 No enterprise policy file, no `mozilla.cfg` and autoconfig pointer, no `profiles.ini`, no `userChrome.css`. Configuration is one enumerated `pref` file carrying only the lines that answer to the who-does-this-serve test. Chrome CSS stays out: a diff to the pref file with a single line about appearance in it has gone too far.
252
253 ### Helium, and why it is not here
254
255 Helium is ungoogled-chromium with the behaviour Alloy would otherwise hand-build already set upstream, packaged by Terra as `helium-browser-bin`. It is in neither the image nor the builder, for two reasons.
256
257 **It is unreachable.** Terra is disabled after install, because leaving it enabled breaks bootc-image-builder's installer depsolve, and `rpm-ostree install --enablerepo` works only in a container build. So a machine that chose no browser cannot add Helium by any supported route, which breaks the floor the whole bring-your-own arrangement rests on.
258
259 **And it is someone else's workaround.** Helium is a maintained patchset over another vendor's bad defaults. Shipping it as Alloy's answer adopts that mitigation as Alloy's position: Helium is doing its best to make a standard approach tolerable, and Alloy is doing its best to improve standard approaches. Anyone who wants it can enable Terra on a machine they own and layer it. That is their call, and not a shape this image carries.
260
261 ### This is a pick, and it is still not an engine claim
262
263 Alloy does not claim Gecko beats Blink; the engine question is not the deciding one. Applying the reachability criterion leaves Fedora's stock `chromium` as the only Blink candidate, which is the un-mitigated defaults Helium exists to fix, so there is no second candidate to defend. A role with one defensible candidate gets a pick.
264
265 **What still reaches the browser** is system appearance, through the portal and the toolkit rather than through per-app files: `org.freedesktop.appearance` `color-scheme` for light and dark, the GTK theme for system dialogs, the cursor theme, and the font stack including the Noto coverage packages. That is the whole of it. Anything past what a well-behaved app reads from the system is the user's to set in the browser's own settings.
266
267 Accepted costs:
268 - **The day/night switch reaching the browser is unverified.** The portal path has never been observed working here. If it turns out not to drive Firefox, that is a defect in the appearance propagation and is fixed there. Per-app chrome CSS does not come back for it.
269 - **No blocker is pinned.** Strict tracking protection is Firefox's own feature rather than a forced extension, so a user who turns it down has turned it down, with nothing in the image arguing.
270 - **A pref file is a maintenance surface.** Mozilla renames prefs, and a pref that no longer exists fails silently. The file records that its names were read out of Firefox's `omni.ja` rather than remembered, and re-measuring them is part of taking a Fedora major.
271 - **Updates come with the image.** A browser inside the image moves when the image is rebuilt rather than on its own channel. `alloy update` shows what is booted, what is staged, and how to roll back.
272
273 Rejected:
274 - **Helium.** See above. Unreachable after install, and a patchset over another project's defaults.
275 - **Ungoogled-chromium.** Packaged for neither Fedora nor Terra. Compiling it in the image costs hours of build time and roughly 100 GB of scratch space.
276 - **Stock `chromium` from Fedora.** Reachable, and it is the un-mitigated defaults. Recommending it unconfigured endorses defaults Alloy rejects; configuring it rebuilds the estate on a browser Alloy has no other reason to carry.
277 - **LibreWolf.** Ships uBlock Origin preinstalled, but its hardening (`resistFingerprinting`, letterboxing, cookie clears on close, WebGL off) breaks modern sites. Un-hardening it to pass the "runs all modern sites" bar erases the reason to choose it.
278 - **Zen Browser.** Gecko-based and design-forward, but its identity centers vertical tabs and sidebar-forward layout. Revisit only if Zen ships a first-class horizontal mode as a supported configuration.
279 - **Brave, Vivaldi, Arc.** Each arrives with a business model attached to the browser, which is the class of default Alloy is least willing to hand a user.
280
281 ## Containers
282
283 **podman as the runtime, distrobox at the `host` level only, flatpak as the `sandboxed` backend.** These are not three tools the user chooses between. They are the three backends behind the isolation dial in [CONSOLE.md]CONSOLE.md, and the user picks a level rather than a tool. Design in the wiki note `alloy-package-ux`.
284
285 **distrobox** was picked over toolbx for `host`. Both are plain Fedora main-repo RPMs, no COPR. The deciding factor is Alloy-specific: the usual answer is that the choice does not matter because Ptyxis speaks to podman, toolbx, and distrobox directly, but Ptyxis is GTK and Alloy ships no GTK stack, so nothing else in Alloy's stack does container integration and the wrapper has to. `distrobox-export --bin foo --export-path <dir>` writes a host wrapper script into whatever directory it is pointed at, which is what lets Alloy give every box its own. Toolbx has no export mechanism at all: its verbs are create, enter, run, list, rm, rmi, help, and getting a container tool onto the host PATH under it has no command, only a paragraph telling you to hand-write a wrapper. That cannot be taught in the console's own idiom. Distrobox also takes arbitrary images, where toolbx requires images to satisfy a checklist most upstream images fail.
286
287 **podman directly** for `workspace`, because distrobox cannot express that level. Checked against distrobox-create's own documentation: `--home` relocates HOME but does not stop the host home being mounted, and the five unshare flags cover devices and sysfs, groups, ipc, netns, and process, none of them the mounts. There is no flag that withholds the host filesystem paths or the host D-Bus session socket. That is not an oversight to work around. Host integration is what distrobox is for. Two of the six rows `workspace` promises are unreachable through it, so `workspace` is podman with the wrapper's opinions left off, and Alloy writes the export wrapper itself at that level: a generated script in the box's export directory that re-enters the box and forwards its arguments, starting it first if stopped. A few lines doing what `distrobox-export --bin` does. `host` keeps `distrobox-export` because `host` keeps distrobox.
288
289 **Exports land in a directory per box, `~/.local/bin/<box>/`, and nothing is refused.** A single shared directory makes a name a scarce resource: two boxes declaring `export.bin = ["rg"]` write one file, and a name the host already has gets shadowed, because `~/.local/bin` precedes `/usr/bin` and exporting is itself the act that shadows. A directory per box ends the first collision outright and defuses the second: a fresh directory is on nobody's PATH, so the export shadows nothing until its owner says otherwise. In place of a refusal there is a report. `alloy pkg` export says where the wrappers went, gives the nushell line that puts that directory on the PATH, and names any exported binary that already exists elsewhere on it, saying which copy would win. Wrappers written by earlier versions, which sit loose in `~/.local/bin`, are moved into the box's directory on its next export rather than left as a second copy that wins the search order.
290
291 **Not shipping both wrappers**, despite that being Universal Blue's choice. Principle 5 is opinionated defaults and principle 6 is legibility; two wrappers doing one job, in a distro whose headline claim is making the install model legible, works against both. Bluefin affords the ambiguity because Ptyxis hides it.
292
293 Alloy owns the model over these backends and does not own a container runtime, an image format, a registry, or a sandbox implementation. That boundary is load-bearing: a from-scratch container deployment system is out of scope, per [MANIFESTO.md]MANIFESTO.md. The build cost is also badly asymmetric. Unsandboxed containers are a shell script over podman. Sandboxing is flatpak's entire reason to exist (portals, seccomp, D-Bus proxying, device and filesystem policy), a decade of security engineering with a failure mode where 90% correct is worse than not trying, because users believe the sandbox is real.
294
295 **The Rust gap, named rather than papered over.** No Rust option exists in this space, mature or otherwise. The Rust projects nearby are GUIs over distrobox. Principle 1 says to name that honestly. Alloy's Rust wins here are one layer up and already held: bootc is Rust.
296
297 Risk to track: distrobox v2 is a Go rewrite. Its own announcement says v1 remains the production recommendation and that exported binaries and apps must be re-exported after upgrading. The Containerfile pins Fedora's 1.8.2.5. Do not chase the rc; keep a release note ready for whenever the pin moves.
298
299 ## Secrets
300
301 Two jobs that get confused for one. **gnome-keyring** is the API programs call; **gopass** is where a person keeps their logins. Neither substitutes for the other.
302
303 ### Secret Service provider: **gnome-keyring**
304
305 **Alloy ships the daemon that claims `org.freedesktop.secrets`, because software Alloy's own author writes depends on it.** The Rust `keyring` crate and Tauri's credential plumbing both resolve to the Secret Service on Linux, so without it GoingsOn and Balanced Breakfast have nowhere to put a credential and `synckit-client` has nowhere to persist its E2EE key, and the failure surfaces as a runtime error inside an app, four layers from the cause. This is the distro breaking its own software, which is the sharpest version of the dogfooding argument.
306
307 **Two packages, `gnome-keyring` and `gnome-keyring-pam`, and the second is the one that is easy to miss.** Fedora's `/etc/pam.d/greetd` already carries `-auth optional pam_gnome_keyring.so` and `-session optional pam_gnome_keyring.so auto_start`. The leading `-` means PAM skips a module it cannot load without logging anything, so installing the provider alone yields a working Secret Service and a keyring locked behind a second password prompt nobody asked for, with the config that was meant to prevent it sitting in place looking correct. The Containerfile asserts all three conditions (provider claimed, module loadable, stanzas still present) rather than trusting them.
308
309 Alloy ships no `/etc/pam.d` file. greetd owns that stack and it already says what Alloy needs; copying it in to add two lines it already has would mean owning a file upstream keeps editing.
310
311 Rejected: **KeePassXC**, which also provides the interface but drags Qt into a ratatui/egui design language. Rejected on that, not on capability. **`systemd-creds`** is not an alternative and stays: it handles service credentials for daemons, with no interactive component and no D-Bus interface for an app to call.
312
313 ### Password manager: **gopass**, age backend, synced by git
314
315 **The deciding question is what a house GUI would be built over.** The answer decides the format, and the format decides the tool. A directory of age-encrypted files is git-native, so a concurrent edit is a conflict on one secret with a diff to resolve, and the Rust side is one crate (`age`) over a directory walk. A single-blob vault gives interop with phone clients and browser extensions and costs the sync model this section already rejected Syncthing for: over git a KDBX file is a binary conflict with no diff, over Syncthing it is two silent copies. Fedora also packages no lean KDBX CLI — `keepassxc-cli` ships only inside the full `keepassxc` package, so the CLI drags Qt5 onto a GTK image, and the alternative `kpcli` is 103 Perl packages.
316
317 **Syncing the store through SyncKit rather than git is deferred.** It fits better than it looks: SyncKit is E2E by construction (the server never sees row contents), `SyncStore` supplies the schema, changelog, HLC ledger and a content-addressed blob engine, group scopes already model a shared vault, and a custom `ConflictResolver` could keep both versions rather than dropping one. What it costs is independence: git syncs through any host, a USB stick, or nothing, while SyncKit syncs through the MNW server, and a password manager is judged on exactly that. Alloy ships the tool that needs no service.
318
319 **A Make Creative password manager is a separate product, not this.** The intended shape is the Bitwarden/Vaultwarden model with the UX taken seriously, built on `quasi`, and it is not shipped with Alloy. That is a client-plus-server design, which is the shape SyncKit already is, so the SyncKit question returns there rather than here. What Alloy ships is a trusted CLI with git compatibility, and the useful property is that gopass's age backend keeps one encrypted file per secret, so a future house client could read a store users already have.
320
321 **Client-only.** It sits beside `gnome-keyring` for the same reason: a personal store is for the person at the machine, and a headless build host has no use for one. It costs `fish` on the client, 39.3 MiB that Fedora's gopass hard-requires for a single completion file, which no dependency flag can remove; the server profile does not pay it.
322
323 **The deciding factor is key-hierarchy sprawl, not features.** `_private/` is migrating to sops plus age, so an age-backed store reuses an identity that migration already requires. **rbw** against a self-hosted Vaultwarden was the runner-up and is the better answer on mobile and in a browser, but it introduces a second master secret with its own recovery story, and the failure mode of two hierarchies is that backup discipline silently covers one and not the other. **KeePassXC** was rejected for Qt, as above. **`pass`** was rejected for being GPG-centric when the whole direction is away from GPG toward age.
324
325 **git, not Syncthing, even though the store is encrypted.** Syncthing replicating age ciphertext is technically sound, and the confidentiality objection does disappear once everything is encrypted at rest. It is still wrong here: Syncthing resolves a concurrent edit by writing `secret.age.sync-conflict-<timestamp>-<id>` beside the original and carrying on. For a credential store that is two versions of a password, no signal about which is current, and nothing forcing you to look. git turns the same situation into a merge conflict that blocks until resolved. Loud beats silent when the file is what you are about to authenticate with. gopass is also git-native, so Syncthing would mean disabling the mechanism the tool is built around.
326
327 Because the store is ciphertext it can live in the same astra repo as the rest of `_private/`, with no separate remote and no special handling. Not `mnw`: no reason to publish it, and that push is one-way.
328
329 **The age identity never travels with the ciphertext.** Not in the repo it decrypts, not through Syncthing, not in the same channel by any route. Syncing it collapses the scheme into "one compromised device loses everything".
330
331 Accepted costs:
332
333 - **No zero-ceremony multi-machine editing.** Pull before, push after. That friction is real and is the thing Syncthing did better.
334 - **No usable phone access.** Credentials needed away from a machine (registrar, bank, Apple Developer) either live somewhere else or are deliberately not covered.
335
336 **The identity is placed by hand, and a fresh install does not provision it.** Two things could be meant by provisioning and only one of them is coherent. The identity already exists, since reusing the one the sops migration requires is why gopass won here, so generating a fresh one at install decrypts nothing you own and grows a recipient list that never shrinks as machines are retired. That leaves ingest, and ingest has no channel at first boot: the store arrives over the tailnet, tailnet enrollment is itself a row on the `alloy setup` screen, and carrying the identity through that same screen collapses the separation the whole scheme rests on. The two enrollments already there are also a different kind of object. Both authenticate a device against a service that can revoke it, where an age identity is a bearer secret with no service, no revocation and no audit, so a third row beside them would imply a property it does not have.
337
338 What the console does instead is notice when the identity is absent and say so, in the shown-and-not-settable idiom the locale row above uses. That covers the part of a fresh install that actually hurts, which is not typing a key but failing to learn you needed one until gopass refuses to decrypt. [Chapter 10]manual/10-the-shell.md is where the identity lives; [chapter 14]manual/14-troubleshooting.md is the case where the machine is gone and the age identity and the disk-encryption recovery phrase are both wanted at once.
339
340 ### Disk encryption: **LUKS by default, TPM-bound, with a recovery phrase**
341
342 **Alloy encrypts unless it is told not to.** The installer's encryption step starts ticked. A laptop is the machine most likely to be lost or stolen and the least likely to be encrypted by someone who had to go find the option, so the default carries the decision and declining is the deliberate act.
343
344 **Bound to the TPM, so an ordinary boot asks for nothing.** `bootc install` is passed `--block-setup tpm2-luks` and the volume unlocks against the TPM's measured state. That is what makes encryption survivable on a machine that reboots often, and what makes a headless build host possible at all: a box with no keyboard cannot answer a passphrase prompt, and an encrypted box that cannot boot unattended is a box nobody encrypts.
345
346 **Two fallbacks, because a TPM binding breaks for legitimate reasons.** A firmware update, a boot-order change or a moved disk all change the measurement and the TPM stops unsealing. The passphrase collected at install is the first fallback. The second is an eight-word recovery phrase generated at install from the BIP-39 English list, 88 bits, shown once. The wordlist is chosen for properties that matter when a phrase is copied off a screen under stress: 2048 words so a word is exactly 11 bits, and every word unique in its first four characters, so a misread cannot silently land on a different valid word.
347
348 **The installer enrolls both slots itself, because bootc leaves them empty.** `--block-setup tpm2-luks` runs `systemd-cryptenroll --wipe-slot=all`, which clears every keyslot it did not create. Enrolling the passphrase and the recovery phrase before that runs would enroll them into slots that are then wiped. The install path therefore enrolls after, and this ordering is the whole of why the code is more than a flag.
349
350 **Declining does not silently encrypt.** `usr/lib/bootc/install/00-alloy.toml` sets `block = ["direct", "tpm2-luks"]` with `direct` first, so an install that passes no `--block-setup` gets an unencrypted disk rather than falling through to the encrypted path. The flag is passed only when the wizard's box is ticked.
351
352 Rejected: **passphrase-only, no TPM**, which is the conventional answer and would mean no unattended reboot, so no headless build host and a laptop that prompts on every boot until its owner turns encryption off. **Off by default**, which is what most distros do and gets most users an unencrypted disk. **A TPM binding with no fallback**, which turns a firmware update into permanent data loss.
353
354 Implementation lives in `crates/alloy/src/install.rs` (`Step::Encryption`) and `crates/alloy/src/recovery.rs`. The user-facing halves are [chapter 3]manual/03-installing.md and [chapter 14]manual/14-troubleshooting.md.
355
356 ## Shell
357
358 **Nushell as the login shell; bash unchanged as `/bin/sh` and `/bin/bash`.** Full rationale, architecture, and interoperability rules in [SHELL.md]SHELL.md.
359
360 The short version: Alloy's audience writes their own scripts, so nu's structured pipelines earn their ecosystem cost. Every daily tool-glue interaction compounds the advantage. The `curl | sh` install pattern is unaffected because it invokes `/bin/sh`, not the login shell; Alloy leaves bash in place at both `/bin/sh` and `/bin/bash`, and nu is added as an option in `/etc/shells`. Reversible with `chsh -s /bin/bash`.
361
362 Config lives at [`etc/skel/.config/nushell/`]../etc/skel/.config/nushell/: `env.nu`, `config.nu`, `aliases.nu`, plus a README covering install and verification.
363
364 **Prompt:** starship (Rust, cross-shell). Cross-shell prompt engines are exactly the kind of tool where "boring, works" earns its keep. A custom Alloy nu prompt is deferred as a design-system exercise for later.
365
366 Rejected: fish (safer pick but Alloy's audience is script-writers), zsh (raw zsh unpleasant; adopting oh-my-zsh/zinit imports someone else's opinions), bash-as-login (un-opinionated at a layer where Alloy is opinionated everywhere else).
367
368 ### Directory jumping: **zoxide**
369
370 Rust, tiny, no daemon. Tracks the directories you actually visit and ranks them by frequency and recency, so `z alloy` lands in the project from anywhere. On a tiling system where every workspace holds a terminal, the alternative is retyping paths all day; this is one of the few adjuncts that pays for itself within a session of use.
371
372 The Containerfile generates the nu integration at build time (`zoxide init nushell` into `/usr/share/nushell/vendor/autoload/`), so it is live on first login with nothing for the user to wire up.
373
374 Rejected: autojump (Python, effectively unmaintained), z / z.lua (shell-script and Lua ancestors of the idea; zoxide is the modern rewrite of exactly this), fasd (unmaintained), fzf-driven `cd` (needs a config recipe rather than a default, which is the pattern Alloy rejects), and shipping nothing (nu's `cd` plus tab completion is the baseline zoxide beats).
375
376 ### Per-directory environment: **direnv**
377
378 Go, mature, the de facto answer for binding a project's environment to its directory: `cd` into a repo and its `.envrc` loads, leave and it unloads. Alloy's audience keeps toolchains, API endpoints, and per-project paths out of the global environment, and direnv is how that stays automatic instead of remembered.
379
380 Go rather than Rust, named per principle 1. Two Rust options exist and were weighed. **mise** does per-directory env, but its center of gravity is tool-version management (an asdf replacement), so adopting it for the env half means shipping a version manager Alloy has no position on. **shadowenv** (Rust, Shopify) is closer to the right scope, but configures in a Lisp dialect, which is the same scripting-language-config line the stack holds against wezterm and eww. direnv's `.envrc` is shell, which every user already reads.
381
382 Also rejected: autoenv (bare `source`, no security model, no unload), and per-project `nu` scripts sourced by hand (not automatic, which is the whole feature).
383
384 **The nu integration is Alloy-authored**, at [`usr/share/nushell/vendor/autoload/direnv.nu`]../usr/share/nushell/vendor/autoload/direnv.nu. direnv emits no nu init for the build-time trick used on starship and zoxide to call, so the hook is written against `direnv export json`, a supported export target. It registers a `pre_prompt` hook and a `direnv-reload` command for use by hand after editing an `.envrc`. PATH is applied apart from the rest of the diff, since nushell holds it as a list and direnv reports it colon-joined.
385
386 ## Command-line staples
387
388 **ripgrep, fd-find, bat, eza, and p7zip.** Alloy ships zoxide and direnv on the grounds that a developer expects them to be there, and the same argument carries the search tools. A distro whose thesis is a curated default cannot leave the most-reached-for search tool to the user.
389
390 The package name to know is `fd-find`, which installs the binary as `fd`. It is the only one in the group where the two differ.
391
392 **p7zip comes with `p7zip-plugins`, which is the half that matters.** The base package handles 7z; the plugins package is what adds rar. Rar is the format a person receives rather than chooses, so shipping only the base would cover the case nobody hits and miss the case they do.
393
394 Not shipped, and not an oversight: **the GNU coreutils replacements** (`sd`, `dust`, `procs`, `sk`). Alloy already ships `dua` for disk usage and `bottom` for process monitoring, and the rest are substitutions rather than additions. A curated default is worth something because it is short.
395
396 **`man` works and has nothing to read, on a default build.** The image installs no
397 documentation: `tsflags=nodocs` is set above every package install, and Fedora marks
398 man pages `%doc`, so they are excluded with the rest of it. This is not a pick about
399 `man`, it is the largest size cut in the image (~263 MB with the language macro beside
400 it), and rpm offers no switch that separates the two. What replaces it here: the
401 console carries its own help, and [the manual]manual/README.md is Alloy's own documentation.
402 What does not: `man` for a third-party tool installed after the image was built.
403 Building with `TRIM=keep` keeps the documentation and the translations both. Measured
404 and argued in [IMAGE.md]IMAGE.md#what-is-cut-from-the-image.
405
406 ## Secure Boot: **inherited from Fedora, stated, and read back**
407
408 **Alloy signs nothing. An installed machine boots Microsoft-signed shim to Fedora-signed grub to a Fedora-signed kernel under the firmware's stock keys, and Alloy's contribution is saying so and noticing when the firmware is not enforcing.** The image carries `shim-x64`, `grub2-efi-x64` and the stock kernel, staged at `/usr/lib/bootupd/updates/EFI/fedora/`, which is where `bootc install` writes the ESP from. There is no Make Creative key, no MOK enrollment, and no per-machine firmware confirmation step, because nothing in the image needs a signature we would have to hold a key for.
409
410 **The installer medium is the exception, and it is deliberate.** `build/make-iso.sh` builds its own GRUB as the removable-media binary with no shim in front of it. The signed shim and grub ship in RPMs whose files a bootc image strips out of `/boot`, so the bytes are not there to chain to, and a self-built GRUB is unsigned either way. The ISO therefore requires Secure Boot off to boot at all. Signing it is a distribution problem and distribution is not set up.
411
412 **Why the state is worth reporting rather than assuming.** TPM2-LUKS seals the keyslot against PCR 7, and PCR 7 measures the Secure Boot state. That measurement is just as stable when Secure Boot is off, so on a machine with it off the disk auto-unlocks for anything that boots it, including a USB with `init=/bin/sh`. Encryption the user asked for is not the encryption they got, and nothing on the machine said so.
413
414 So the console reads the firmware's own answer, from the EFI variable `SecureBoot` under `/sys/firmware/efi/efivars/`, and reports three states rather than two: enforcing, off, and unknown. Unknown covers a machine with no efivarfs, no such variable, or a legacy BIOS, and it is kept separate because reporting a firmware nobody could read as "off" invents a measurement.
415
416 Three places carry it. `alloy install`'s encryption pane says what the TPM binding is worth while the chain is unverified, at the point the checkbox is ticked. The summary repeats it beside the encryption answer, worded as a defect on the server profile and a warning on the client, since a headless box has nobody standing at the firmware screen. `alloy settings` carries a `security.secure_boot` row, shown and never settable: the setting belongs to the firmware's setup utility, and no privilege this console could ask for would reach it.
417
418 **None of the three refuses.** A refusal at install time on the server profile cannot ship: the installer medium requires Secure Boot off, so the machine running the wizard has it off in every case where it was ever on, and a refusal would refuse every install. The first moment the state is the user's to change is after the first reboot, on the signed chain, which is why the settings row is the one that can actually be acted on.
419
420 Argued in wiki `alloy-hardening-posture`, "Secure Boot".
421
422 ## Firewall: **firewalld**
423
424 **firewalld, on both profiles, with the stock `public` zone on the client and an Alloy-owned zone on the server.** Without a host firewall every listener the preset enables is reachable from whatever network the machine is on. On a laptop behind NAT that is survivable. On the server profile it is not, and converting the Hetzner boxes to Alloy is a live plan (wiki `host-base-images`).
425
426 Measured on `localhost/alloy:hardening` before picking. firewalld is 2.0 MiB and pulls eight dependencies for 7.3 MiB total, and none of it is a new stack: `python3` and `nftables` are already in the image, so the Python daemon everyone cites as firewalld's cost is a library load here rather than an interpreter. Against a 5 GB image that is not a size argument in either direction, which leaves the decision to the model rather than the byte count.
427
428 The model is what decides it. Fedora's stock `public` zone allows exactly `ssh`, `mdns` and `dhcpv6-client`, and that is precisely the set `50-alloy.preset` enables on a client: sshd on every install, avahi for the `<name>.local` path the headless flow depends on, and nothing else inbound. cups is a unix socket plus `localhost:631` and needs no rule, because loopback is not filtered. So the pick is not a policy someone has to write and maintain against the preset; it is the zone Fedora already ships, matching a listener set Alloy already documents.
429
430 **`FedoraWorkstation` is explicitly not the zone**, and it is the reason to state the zone rather than leave it at "firewalld". That zone opens `1025-65535` on both tcp and udp, which is most of what a firewall is for, and it is the default on Fedora's desktop spin.
431
432 **The server profile gets `alloy-server`: ssh and dhcpv6-client, no mdns.** The listener sets diverge by exactly one entry, and it is the entry that matters on a public IP. avahi stays installed and running on that profile because a headless box on a LAN is found by name, but a machine with an internet-facing address should not answer 5353 to the internet to buy that. Discovery does not depend on it alone: NetworkManager sends the same hostname in DHCP option 12, so the box appears in the router's lease table, and the baked hostname means the name is known in advance anyway. Granting it back on a LAN server is `firewall-cmd --permanent --add-service=mdns --reload`, and the manual says so.
433
434 **`tailscale0` is pinned to the `trusted` zone**, on both profiles, and this is the part that would silently break if it were left out. tailscaled writes its own nftables tables (`ts-input`, `ts-forward`, `ts-postrouting`) and has no firewalld awareness whatsoever: zero matching strings in the 1.102.3 binary in this image. Under nftables every base chain returns its own verdict, so firewalld rejecting a packet in its table is final no matter what tailscale accepted in its own, and the whole tree is addressed by tailnet name. Trusting the tailnet is not a new position: Tailscale SSH already bypasses `authorized_keys` on these hosts, so the ACL is the access control and has been for as long as the tailnet has existed.
435
436 **The installer medium is the one exception to the server zone, and it needs a unit.** The headless install flow is `ssh installer@<hostname>.local`, so a server-profile ISO whose zone refuses mDNS boots a machine that is waiting to be installed and cannot be found. `alloy-installer-firewall.service` opens mDNS at runtime, gated on the same `alloy.installer` kernel argument as the installer account and the tty1 wizard, so it is inert on every installed system. Runtime rather than permanent: the medium does not survive a reboot and the machine being installed must not inherit the opening.
437
438 Rejected: **a hand-written nftables ruleset in the image.** Smaller and more legible on the page, and it owns every interaction firewalld already handles. tailscale and netavark both write rules; NetworkManager knows about firewalld zones and knows nothing about a file; and every future service that needs a port becomes an image rebuild instead of a command. Alloy ships no hand-written ruleset anywhere else either, so this would be a bespoke mechanism with exactly one user.
439
440 Rejected: **a firewall on the server profile only.** It is the cheaper half of the work and it defends the machine that is already the least exposed to the case a firewall is for, which is an untrusted LAN. A laptop is on more networks than a server is.
441
442 Not settled here, and filed as its own work: whether `alloy net` should show the zone alongside the interface. firewalld is a NetworkManager-integrated daemon and `alloy net` is an nmcli front, so the state is one `nmcli` field away, but showing it is a console design question rather than part of the pick.
443
444 ## USB device authorization: **usbguard, installed, gated, and not yet armed**
445
446 **usbguard on both profiles, with the enforcement half deliberately switched off until the policy and the keyboard gate ship.** The view came first and is already here: `alloy usb` reads sysfs, needs no package, and works on an image that has never heard of usbguard. What the package adds is the ability to act on what that screen shows, which is deauthorizing an attachment and remembering the decision.
447
448 Measured on `localhost/alloy:firewall` and `:firewall-server` before taking it. usbguard is 1.3 MiB and the closure is 4.8 MiB on both profiles: `protobuf` (3.3), `libqb` (0.2), `usbguard-selinux` (0.01). The comparison worth keeping is against a bare `fedora-bootc:43`, where the same install costs 16.4 MiB, because `usbguard-selinux` pulls `policycoreutils-python-utils` and behind it `python3-policycoreutils` and `python3-setools`. This image already carries all three, so neither profile pays for them twice. Same shape as firewalld above: a package that looks expensive in isolation and is mostly already present.
449
450 **The daemon is one preset line from armed, and that line is the dangerous one.** Read off the fedora-43 package rather than its documentation: `/etc/usbguard/rules.conf` ships empty, `ImplicitPolicyTarget=block` blocks anything matching no rule, and `PresentDevicePolicy=apply-policy` and `InsertedDevicePolicy=apply-policy` apply that both to devices already attached at daemon start and to ones plugged in later. Together those four are "deauthorize every USB device at boot", which on a desktop is the keyboard, and the machine is then unrecoverable without a second one. So the package ships and the unit does not, and the Containerfile asserts both: that the knobs still read the way they were measured, and that `usbguard.service` is still disabled on the far side of `preset-all`.
451
452 **What has to land before the enable line does** is the ruling in GoingsOn alloy `63de3d4c`: deny unknown and prompt, activation from `alloy usb`, permanent allow rules keyed on vendor and product plus serial, and enforcement suspended whenever the machine has zero usable keyboards. That last clause is the one that makes the rest safe, and it has a measured subtlety. The internal keyboard on a Framework laptop is `AT Translated Set 2 keyboard` at `isa0060/serio0`, an i8042 device that usbguard has no jurisdiction over, so a client with every USB device denied still has a working keyboard and the gate must stay shut. A gate that counted only USB keyboards would open permanently on every Framework laptop, which is the inverted failure and is worth a test.
453
454 **The keyboard gate** is `usr/bin/alloy-usb-gate`, with `alloy-usb-gate.service` and a udev rule on the input subsystem, client profile only. Two measurements constrain it, and both rule out the obvious implementation.
455
456 The first is what to count. The property is `ID_INPUT_KEYBOARD`, which udev's own `input_id` builtin sets, and not `ID_INPUT_KEY`. On fw13 four things carry `ID_INPUT_KEY` and are not keyboards: the power button, the video bus, the wireless radio control and the consumer control. A gate counting those stays shut on a machine whose only real keyboard has just been denied, and reports itself working while doing it. Reading the input subsystem rather than the USB bus is also what answers the i8042 case by construction rather than by a special case, since the Framework's internal keyboard is present there no matter what the USB policy says. A denied USB keyboard is absent from the same list for the same structural reason, because deauthorizing a device unbinds its interfaces, so "how many keyboards can this person type on" and "how many does the kernel show" turn out to be one question.
457
458 The second is what suspending enforcement actually means. usbguard ships `RestoreControllerDeviceState=false` and its unit has no `ExecStop` at all, so nothing restores device authorization when the daemon goes away. A gate that stopped the daemon and stopped there would leave every denied device denied, the rescued keyboard included, and would do nothing whatever for the person it exists to rescue. Opening the gate is therefore stop, then authorize every device on the bus, then set `authorized_default` so a keyboard attached afterwards also comes up usable.
459
460 It cannot arm anything, which is what makes it safe to enable on every client install including the overwhelming majority where usbguard is not running. The script only ever starts usbguard to undo a stop it recorded itself in a stamp file under `/run`. On a machine where nobody turned enforcement on it reads the keyboard count and exits.
461
462 One case the gate refuses to loop on. A keyboard the policy has never heard of is denied the moment enforcement resumes, which returns the count to zero and reopens the gate, several times a second. A close followed by another open inside sixty seconds therefore latches the gate open and says so on VT1. Failing open is the right direction for a lockout valve. **The latch is not where it ends.** Once the gate has latched and the keyboard is back on the bus, the gate writes a permanent allow rule for it (from `usbguard generate-policy`, the one verb that does not need the daemon) and resumes enforcement, so the machine ends up protected and usable with no command for the user to find. Only while latched, which is the safety property: the latch means the machine has already demonstrated it has no other keyboard, so an attacker's prerequisite is already met and refusing buys nothing. The cost, taken deliberately: someone with physical access who can make every keyboard disappear gets a permanent allow for a keyboard-shaped device, which mostly bites desktops rather than laptops with an unremovable i8042 keyboard.
463
464 **The verbs are all in the CLI, so there is nothing to front but a CLI.** `usbguard list-devices`, `allow-device`, `block-device`, `append-rule`, and `generate-policy` cover the whole of it, and `allow-device --permanent` is exactly the "save this device" action the ruling describes. `usbguard watch --exec <path>` runs a program per plug event and is the plug-then-prompt hook, which means the console needs no D-Bus client: the same correction already made for `alloy bluetooth`, and it holds here for the same reason, that the log pane can only show an argv if there was one. `DeviceRulesWithPort=false` is already the default, so a permanent rule is keyed on the device rather than the socket it was in, which is what the ruling asks for and what a device that moves ports requires.
465
466 Measured against the image rather than read off the manual. `usbguard allow-device --help` states `(<id> | <rule> | <partial-rule>)`, so a rule composed from sysfs is an accepted argument and not only the device id the daemon hands out, and `usbguard generate-policy` runs in a container with no daemon at all, exits 0, and emits rules keyed on id, serial, name, via-port and with-interface. The join from a console row to a device usbguard acts on is therefore a pure function over fields the console already reads, and `alloy usb` composes it today. What needs a machine is applying a rule, and what it needs there is a writable `/sys`: with `/sys` mounted read-only the daemon starts and then rejects every device with `SysFSDevice: authorized: Permission denied`, and the verbs that answer from the device list fail behind it. Two fields of a generated rule are not composable from sysfs, `hash` and `parent-hash`, which are over the USB descriptor blob. A rule matches on the attributes it states, so the hash-free form is a rule usbguard takes.
467
468 Rejected: **shipping the package on `client` only.** A server-profile machine has a USB bus, and the ruling gives that profile the stricter half of the policy precisely because it has no keyboard to lock out and its recovery path is a provider console. It needs this more than a laptop does, not less.
469
470 Rejected: **enabling the unit now and writing the policy later.** It is one line and it would work on the bench for as long as the bench machine has a PS/2 keyboard, then take out the first machine that does not. There is no partial credit available here: an image that boots armed with an empty policy is worse than one with no usbguard at all, because the second is merely unprotected and the first is bricked.
471
472 ## VPN
473
474 **WireGuard, through NetworkManager, with no plugin.** NetworkManager 1.54 speaks WireGuard natively, and the plugin packages a search turns up (`NetworkManager-wireguard` and its `-gnome` variant) do not exist in Fedora 43. What is missing without help is key generation: `nmcli` will import a peer config but will not mint one. `wireguard-tools` supplies `wg` and `wg-quick` and is the whole of what this needed.
475
476 Rejected: **NetworkManager-openvpn**. It exists and it works, and it is still not here. OpenVPN is the protocol a person meets because an employer or a VPN vendor chose it, which makes it a compatibility obligation rather than a pick, and this document does not carry obligations it has not been handed. Anyone who needs it layers the package. The distinction from WireGuard is not preference: WireGuard needed no plugin at all, so shipping it cost one small package and no position.
477
478 **Tailscale is the mesh answer and is separate.** It is opt-in at first boot and stays that way; see the `alloy setup` flow. It does not make WireGuard redundant, since a hand-configured peer and a managed mesh are different jobs.
479
480 ## Accessibility: **not shipped, and that is not the same as supported**
481
482 **at-spi is not in the image, and no screen reader is either.** Shipping the accessibility bus without a reader behind it would be the worse outcome: it would put the plumbing in place, let the capability appear in a feature list, and leave anyone who relies on it discovering at the point of use that nothing reads anything. An honest absence is better than an implied capability.
483
484 The deeper reason is that Alloy is a terminal-first distro, and screen reader support in a TUI is not a package. It is a property of every view the console draws, and Alloy has not done that work or tested it. Claiming otherwise on the strength of one dependency would be a claim about software nobody has run with a reader.
485
486 This is stated rather than argued away. It is a real limit on who can use Alloy, and it stays until someone does the work rather than installs the package.
487
488 ## Utility defaults: content viewers
489
490 Alloy ships defaults for the three content types users open constantly. Not shipping them cedes the choice to whatever a user's first web search returns (VLC for video, nomacs for images), which conflicts with the curated-defaults principle everywhere else.
491
492 ### Video: **mpv**
493
494 C, mature, keyboard-driven, no chrome by default, extensible via Lua. Handles local files, streams, and YouTube via yt-dlp. Zero visual identity out of the box, which is exactly what a video player should be. Config at [`templates/etc/skel/.config/mpv/`]../templates/etc/skel/.config/mpv/: hardware decode, no OSC/OSD chatter, screenshots to `~/Pictures/Screenshots`, yt-dlp capped at 1080p.
495
496 Rejected: VLC (retained-mode GTK UI, oversized surface), celluloid (adds retained UI on top of mpv, defeating the point), Haruna (Qt/KDE-shaped). No production-quality Rust video player exists.
497
498 ### Images: **imv**
499
500 C, Wayland-native, tiny, dedicated to viewing. Config at [`templates/etc/skel/.config/imv/`]../templates/etc/skel/.config/imv/: the theme's page tone behind the image, overlay hidden by default, vi-like binds inherited from imv defaults.
501
502 Rejected: oculante (Rust and Alloy-toolkit-aligned via egui, but its RAW/EXIF/crop feature set drifts toward "photo tool" territory that overlaps GIMP/darktable's job; wrong scope for a base-image image viewer), swayimg (newer, less mature), nomacs (Qt, heavy), feh (X11).
503
504 Revisit oculante if daily-use workflow reveals gaps imv can't cover; the Rust-alignment case is real, outweighed here by focus-of-scope.
505
506 ### PDF: **zathura**
507
508 C, vim-like keybinds, MuPDF backend, tiny, extensible to djvu/ps/epub via plugins. Config at [`templates/etc/skel/.config/zathura/`]../templates/etc/skel/.config/zathura/: Alloy palette on chrome, statusbar-only chrome, and `i` toggles recolor. Recolor is off in the day render, where the page already agrees with the chrome, and on in the night one, where an unrecolored PDF is a floodlight in the middle of a dark session.
509
510 Rejected: sioyek (research-oriented, C++/Qt, great for papers but too specialized for base image; users who read lots of academic PDFs install it themselves), evince/okular (retained-mode, mouse-driven), mupdf viewer (even smaller than zathura but no config surface worth naming). No production-quality Rust PDF viewer exists.
511
512 **PDF annotation is not a shipped default.** zathura reads, and Alloy ships nothing that writes: a stylus GUI clashes with the TUI-first line. Do not add annotation to the zathura config.
513
514 ## Utility defaults: system introspection
515
516 The daily-use tools that replace legacy GNU-utils habits with keyboard-driven Rust equivalents. All three ship in the base image.
517
518 ### Editor: **helix**
519
520 Rust, modal (selection-first grammar, Kakoune-shaped), LSP + tree-sitter + formatters batteries-included, TOML config, zero-config to be useful. Config at [`templates/etc/skel/.config/helix/`]../templates/etc/skel/.config/helix/: editor UX in `config.toml`, Akari Dawn theme shipped verbatim at `themes/akari-dawn.toml` (with `akari-night.toml` alongside for dark mode) from Shu Kutsuzawa's [Akari]https://github.com/cappyzawa/akari-theme (MIT).
521
522 `EDITOR=hx` is set by `etc/skel/.config/nushell/env.nu`; git commit messages, `crontab -e`, and every other `$EDITOR`-respecting tool routes to helix.
523
524 Rejected:
525 - **neovim.** Ecosystem depth is real, but raw neovim isn't modern; modernity comes from importing a config framework (LazyVim, kickstart, AstroNvim), which is the same pattern Alloy rejected for zsh + oh-my-zsh. Alloy's curated-defaults ethos wants a tool whose defaults *are* the value; helix delivers that, neovim doesn't.
526 - **vim.** Older, less LSP-integrated, less tree-sitter-integrated. Zero Alloy alignment.
527 - **Kakoune.** Helix's direct ancestor in grammar; helix is the modern re-implementation with LSP built-in.
528 - **cosmic-edit.** Retained-mode iced UI, a different tool class (graphical editor). Not the shipped default; users install if wanted.
529
530 **Real cost:** vim muscle memory transfers imperfectly. Helix's selection-first grammar (`3wd` instead of `d3w`) is a genuine improvement but takes 1-2 weeks to internalize. Users who prefer vim install neovim via `dnf`.
531
532 ### Monitor: **bottom** (`btm`)
533
534 Rust, ratatui-based, TOML config. Config at [`templates/etc/skel/.config/bottom/`]../templates/etc/skel/.config/bottom/: Alloy palette on graphs and borders, click disabled (keyboard-only), CPU widget as the default focus.
535
536 Same ratatui toolkit family as the forthcoming `alloy_tui` crate, so bottom and `alloy console` read as design siblings.
537
538 Nu alias: `top = btm` (`etc/skel/.config/nushell/aliases.nu`).
539
540 Rejected: htop (C, canonical but less capable, no disk I/O), btop (C++, decorative; Alloy's aesthetic is understated), gtop (Node.js, not applicable).
541
542 ### Disk usage: **dua**
543
544 Rust, interactive terminal UI, vim-like navigation, marks-for-deletion. Minimal config surface (CLI flags at runtime). Nu alias: `du = dua interactive`; the interactive TUI is the daily-use mode.
545
546 Rejected: ncdu (C, works but not Rust), gdu (Go, no advantage over dua), dust (Rust, one-shot rather than interactive; a different tool for a different job, users install via `dnf` if they want scriptable disk summaries).
547
548 **dust deferred** rather than shipped: one tool per job is cleaner for the base image. Revisit if daily use reveals a real need for a scriptable companion.
549
550 ## Utility defaults: Wayland session glue
551
552 The small tools that translate keybinds and system state into user-visible behavior. Least aesthetic axis of the three utility clusters; picks are dictated by "plays well with sway" more than visual identity.
553
554 ### Clipboard history: **cliphist**
555
556 Go, wlroots-friendly, tiny. Text and image histories stored in a local DB, queryable via `cliphist list` and pasteable via `cliphist decode`.
557
558 **Sway integration:** two watchers `exec`'d from the sway config, and a picker bound to `Mod+Shift+V`:
559
560 ```
561 exec wl-paste --type text --watch alloy-clipstore
562 exec wl-paste --type image --watch alloy-clipstore
563 bindsym $mod+Shift+v exec $term --app-id=alloy-menu -e /usr/bin/alloy-clipmenu
564 ```
565
566 **The watchers call `alloy-clipstore` rather than `cliphist store`, and that is a fix rather than a wrapper for its own sake.** Alloy ships a password manager and a clipboard history, and a plain `cliphist store` watcher records the output of the first: `gopass show -c` puts a password on the clipboard, the watcher writes it into cliphist's database, and `Mod+Shift+V` reads it back later. gopass clearing the clipboard after its timeout does not help, because the copy has already been archived by then.
567
568 **The private clipboard, in three small scripts.** `alloy-secret-copy` writes a sentinel into `$XDG_RUNTIME_DIR` and then copies; `alloy-clipstore` reads the sentinel and drops the selection instead of storing it; `alloy-secret-clear` clears the clipboard and removes the sentinel. gopass is pointed at the first and last through `GOPASS_CLIPBOARD_COPY_CMD` and `GOPASS_CLIPBOARD_CLEAR_CMD` in `etc/skel/.config/environment.d/alloy.conf`, which are variables gopass names in its own diagnostics, so this is a documented seam rather than a discovered one.
569
570 **Why a sentinel and not the MIME convention.** The ecosystem already has one: an extra offer type `x-kde-passwordManagerHint` with value `secret`, which KeePassXC emits and KDE's Klipper honours. Two things stop it working here. The cliphist in the image carries no such string, so nothing would honour it, and `wl-copy` offers one type at a time, so a password offered under the hint alone could not be pasted into anything asking for `text/plain`. Honouring the hint is still the better long-term answer and belongs in a clipboard daemon Alloy owns, which would fix this for KeePassXC and Bitwarden users too.
571
572 **The failure direction is the design.** A sentinel left behind pauses clipboard history, which is a feature quietly not working. A sentinel that never appears writes a password to disk. Those are not the same size of mistake, so the sentinel is created before the clipboard is written and a 90-second backstop removes it even if nothing calls the clear command. The alternative considered and rejected was stopping the watcher for the duration, which fails the other way: a crash between stop and restart kills clipboard history until the next login, silently.
573
574 **What this does not fix:** any application that can read the clipboard can still read the password while it is there. This closes the durable archive on disk, not the live exposure, and those are different categories of problem.
575
576 The picker is `usr/bin/alloy-clipmenu`: `cliphist list` into fzf into `cliphist decode` into `wl-copy`, which is the pipeline this section already described. `cliphist decode` rather than the fzf line itself is what makes images and multi-line text survive the round trip.
577
578 No config file; cliphist's storage lives at `~/.local/share/cliphist/db` and needs no tuning.
579
580 Rejected: clipse (Go, a full TUI where a `cliphist list` pipe suffices), copyq (Qt, retained-mode GUI overkill).
581
582 ### Privilege prompts: **pkttyagent, no graphical agent**
583
584 **Alloy answers polkit prompts in a terminal.** `pkttyagent` ships in the polkit package, so the agent is already present and the Containerfile asserts it rather than assuming it. A graphical agent (`polkit-gnome`, `lxqt-policykit`, `mate-polkit`) is not installed and is not wanted: each is a GTK or Qt tray process running for the length of a session to serve a dialog that appears a few times a month, in a distro whose settings live in a TUI.
585
586 The cost is honest: a GUI app that triggers a polkit action with no agent running gets a failure rather than a prompt. In practice the actions Alloy grants are reached from the console, where `pkttyagent` is exactly right, and `run0` goes through polkit's own PAM stack rather than through an agent at all.
587
588 ### Screen recorder: **wl-screenrec**
589
590 Rust, hardware-accelerated (VAAPI), small. CLI-driven, no config file.
591
592 Rejected: wf-recorder (C++, older, no hardware accel by default), OBS (way overscope for base image; power users install).
593
594 **Not in the v0 image.** wl-screenrec is packaged in neither Fedora main nor Terra, so shipping it means a dedicated `cargo install` build stage pulling clang and the ffmpeg headers into every cold build for one binary. The pick stands; the packaging cost is what is refused. It returns when a repo carries it, or when a second cargo-built binary shares the stage's cost. The Containerfile's `rust-build` stage already pays for the Rust toolchain, so the remaining cost is clang and the ffmpeg headers, which that stage does not need. Re-cost the deferral rather than assume it when the screen recorder next comes up. Until then the image has no screen recorder. Users who want it now: `cargo install wl-screenrec`.
595
596 ### Volume/brightness OSD: **swayosd**
597
598 Rust, GTK-rendered overlays for volume/brightness/caps-lock/num-lock. Config at [`templates/etc/skel/.config/swayosd/`]../templates/etc/skel/.config/swayosd/: Alloy palette CSS with amber `accent-warn` progress bar.
599
600 Two processes, split across the session boundary, which is why the wiring looks uneven. `swayosd-server` draws the overlays and has no unit in the package, so the sway config exec's it. `swayosd-libinput-backend` watches the lock keys, needs the input devices, and is the package's one unit: a system unit enabled in [`etc/systemd/system-preset/50-alloy.preset`]../etc/systemd/system-preset/50-alloy.preset, with a Containerfile shim because Fedora installs it where systemd does not look.
601
602 **Sway integration:** Fn keys bound to `swayosd-client --output-volume raise` and similar in the sway config (see the config for the block).
603
604 **Volume and brightness do not travel the same path, and brightness needs two grants to work.** Volume goes through PipeWire, a userspace daemon, so nothing privileged is involved. Brightness is a direct write to `/sys/class/backlight/<dev>/brightness`, which ships root-owned `0644`. SwayOSD packages the udev rule that fixes it (chgrp to `video`, add group write) and installs it into `/usr/lib64/udev/rules.d/`, which udev does not read, so on Fedora the rule never fires. The Containerfile copies it onto udev's search path, the same shim and for the same reason as the libinput unit above, and the installer puts the account in `video` — a rule that chgrp's to an empty group grants nothing. Either half alone leaves the brightness keys dead.
605
606 The failure is silent when either half is missing: `swayosd-server` is started by the sway config's `exec`, so its stderr has no reader and a failed sysfs write is never logged. Volume keys show an OSD and brightness keys do nothing, with no error anywhere.
607
608 Rejected: avizo (Python, less maintained), custom mako notifications for the OSD (mako is for notifications, not indicator overlays; different job).
609
610 ### Media keys: **playerctl**
611
612 C, MPRIS client. CLI. No config; sway binds media keys directly to `exec playerctl play-pause` and similar. Handles Spotify, mpv, the browser, and any MPRIS-compliant source.
613
614 Rejected: playerctld (still a playerctl variant), no serious alternative.
615
616 ### Night mode: **gammastep**
617
618 C, small daemon, Wayland-native color-temperature adjustment. Config at [`etc/skel/.config/gammastep/`]../etc/skel/.config/gammastep/: 3700K night / 6500K day with fade, manual location placeholder the user edits.
619
620 Rejected: redshift (X11), wlsunset (simpler but time-only, no location provider; gammastep can do both).
621
622 ## Peripherals
623
624 ### Printing: **CUPS, driverless only**
625
626 **Alloy prints to IPP Everywhere devices and nothing else.** The printer advertises its own capabilities, so there is no driver to choose, install or match against a model number. Every printer sold since roughly 2015 speaks it. A printer that needs a vendor driver is not supported, and saying so is the point: the alternative is a user discovering it at the moment they need to print.
627
628 **Two packages, `cups` and `cups-filters`, and the position is visible in what is absent.** foomatic-db is thousands of PPDs, gutenprint and hplip are vendor driver estates, and a driverless-only stack can reach none of them. The Containerfile asserts all three stay out rather than trusting the dependency solver, because that is the assertion that encodes the decision. ghostscript does arrive, as a hard dependency of cups-filters for rasterizing, and is accepted.
629
630 **Discovery is already paid for.** Driverless printing finds printers over mDNS, and nss-mdns is installed with `/etc/nsswitch.conf` already resolving `.local` for the installer flow, which is the whole prerequisite here. A driverless printer on the same link appears by itself.
631
632 **`cups.socket`, never `cups.service`.** Nothing runs until something connects to the socket, so printing costs a unit file on the laptops that never print. Enabling the service outright would buy a daemon on every install for a feature most sessions never touch.
633
634 The surface is CUPS' own web UI on `localhost:631`. It needs no GTK, and with driverless discovery most users never open it. A console verb is not ruled out later; it is not worth authoring before anyone has wanted it.
635
636 Rejected: **system-config-printer**, the usual Fedora answer, which is GTK and drags a settings UI into a distro whose settings live in a TUI. **A `alloy print` console view**, deferred rather than rejected, on the grounds that driverless discovery leaves it very little to do.
637
638 ### Scanning: **not shipped**
639
640 **sane-backends is not in the image and this is a decision.** Scanning is per-device backends and USB permission work, its failure modes are opaque in a way driverless printing's are not, and a scanner is far rarer on a laptop than a printer. The honest version is that nobody has wanted one yet.
641
642 Anyone who needs it installs `sane-backends` through the package view. Most network scanners also scan to a folder or to email without a host driver at all, which is the path worth trying first.
643
644 ### Removable media: **udisks2, reached through `alloy disk`**
645
646 **`alloy disk` is how removable media is reached.** udisks2 comes from the base and nothing else drives it, and yazi ships no mount path, so without the verb a stick or a Framework expansion card stays invisible until somebody mounts it by hand as root. The verb is an `lsblk` inventory with `udisksctl` behind mount, unmount and eject.
647
648 **udisks rather than `mount(8)`, and the reason is the privilege prompt.** udisks answers a session user through polkit for removable media, so mounting a stick asks for nothing. Doing the same through `mount` means `run0`, which is an authentication prompt for the most ordinary thing a laptop does. Nothing in this verb escalates.
649
650 **Two tools, detected separately.** lsblk is util-linux and is on everything; udisks2 is a daemon that has to be answering. A machine with the first and not the second still gets the inventory, with the action keys shown as unavailable and a reason. The Containerfile asserts both rather than trusting the base to keep carrying udisks, on the same principle as the bluez preset entry: an inherited dependency nobody stated is one that disappears without a build failing.
651
652 **The removable list is a filter and says so.** The default tab is drives you can unplug, because that is the question the verb exists to answer, and the second tab is every volume on the machine. A user whose disk is simply missing from a list has no way to tell a filter from a hardware fault.
653
654 Worth recording, because it is not obvious and it is measurable: **the kernel's `RM` bit is not the same question as "can I unplug this".** A USB SSD reports `rm: false`, since that bit means the medium can leave the drive rather than the drive can leave the machine. Removability here is the flag or a hot-plug transport.
655
656 Rejected: **automounting on plug**, which needs a daemon to own and mounts filesystems nobody asked it to; the mount is a keypress. **A yazi mount plugin**, which binds the capability to one file manager and leaves the shell with nothing. **Writing installer media here**`alloy image` already does it, and it hands the terminal to `build/build-image.sh --write`, which refuses partitions, refuses anything mounted, and verifies with `cmp`. Two paths to writing a disk is one more than is safe.
657
658 ### Bluetooth: **bluez, kept and now chosen**
659
660 **The preset entry is Alloy's own.** Fedora's `90-default.preset` carries `enable bluetooth.*`, and Alloy states the choice in its own preset rather than inheriting it, the same as sshd and avahi.
661
662 **Kept on because Alloy is laptop-first**, and on a laptop wireless headphones and a wireless mouse are ordinary. The audio half needs nothing further: PipeWire's bluez5 SPA plugins ship in the image, LC3 and AAC included, so a paired headset plays.
663
664 **The surface is `alloy bluetooth`**, in the shape `alloy display` and `alloy pkg` already use.
665
666 It fronts `bluetoothctl` rather than talking to bluez's D-Bus. D-Bus buys live discovery updates and control over the pairing agent, and it costs an async runtime in a binary that has none plus the console's central promise, which is that the log pane shows the argv that ran. A D-Bus call has no argv to show. Pairing and scanning suspend onto the terminal instead, so `bluetoothctl`'s own agent has somewhere to ask about a passkey and the user watches real discovery output; everything else refreshes when asked. Manual refresh over background polling is the general preference, not a concession here.
667
668 **What the verb is for is legibility, not convenience.** `bluetoothctl` already pairs. The problem it does not solve is that bluez keeps five independent facts per device (paired, bonded, trusted, blocked, connected) and every graphical Bluetooth panel collapses them into one Connected toggle that then lies. The field that decides whether a device ever reconnects on its own is Trusted, and pairing does not set it. A device commonly sits at `Paired: yes, Bonded: yes, Trusted: no`, which is the exact state a user reads as Bluetooth having forgotten the mouse. The view shows all five raw, then says in a sentence what the combination means and which key changes it. It does nothing on its own, for the same reason: Bluetooth is worst where it tries to be magic, and a headset that seizes the default sink is the case in point, so `alloy bluetooth` offers that handoff on a key and does not take it.
669
670 The verb is hidden on the `server` profile, and not for the reason `display` and `status` are. It needs no compositor and would run; the server overlay disables `bluetooth.service`, so it would front a daemon that is off by design.
671
672 Rejected: **bluetuith**, the obvious ready-made TUI, which is in no Fedora repository and would mean adopting another COPR for a view this project builds itself anyway. **blueman** and **GNOME Settings**, both GTK, both off-thesis for the same reason system-config-printer is.
673
674 ## Cursor theme
675
676 **Bibata Modern Classic.** Classic tilted-arrow pointer shape (universally recognizable since Mac OS 1984), soft edges, subtle drop shadow, Wayland-friendly, actively maintained, Fedora repos. Its warmth pairs correctly with Akari Dawn's warm-clay palette without fighting the chrome.
677
678 Applied three ways because different apps read cursor state from different places:
679
680 - `~/.icons/default/index.theme` inherits from `Bibata-Modern-Classic`; most apps and sway itself resolve this.
681 - `gtk-cursor-theme-name` in `~/.config/gtk-{3.0,4.0}/settings.ini`: GTK apps.
682 - `XCURSOR_THEME` and `XCURSOR_SIZE` in `etc/skel/.config/nushell/env.nu`: everything that reads env vars.
683
684 Full config at [`etc/skel/.icons/default/`]../etc/skel/.icons/default/.
685
686 Rejected: DMZ-White/Black (the most historically classic Linux cursor, the X.Org/Ubuntu/Debian/Fedora default for 15+ years, but its high-contrast angularity fights Alloy's warm chrome; ship this only if the target aesthetic is "utilitarian Linux default"), Adwaita (GNOME's cool grey; fights warm palette), Bibata Modern Ice (too cool), Bibata Modern Amber (doubles with the accent-warn amber; reads too warm overall).
687
688 ## GTK theme: palette patch over adw-gtk3
689
690 **adw-gtk3 (base) + Alloy palette patch (layered).** Install `adw-gtk3-theme` from Fedora as the base, then override libadwaita's ~20 named color tokens (`window_bg_color`, `accent_color`, `card_bg_color`, etc.) with the Alloy light-mode ramp via `~/.config/gtk-{3.0,4.0}/gtk.css`.
691
692 Config at [`templates/etc/skel/.config/gtk-3.0/`]../templates/etc/skel/.config/gtk-3.0/ and [`templates/etc/skel/.config/gtk-4.0/`]../templates/etc/skel/.config/gtk-4.0/ with matching `gtk.css` and `settings.ini` per version.
693
694 **What this covers:** any GTK 3 or GTK 4 app that consumes libadwaita's named tokens: swayosd (GTK-rendered overlays), the portal file dialogs a browser opens, xdg-desktop-portal-gtk dialogs, etc.
695
696 **What this doesn't cover:**
697 - Legacy GTK 3 apps with their own token sets (rare; most have migrated).
698 - Qt apps. Different theming pipeline entirely; Alloy's Qt surface is near-zero, so left unaddressed.
699 - Dark-mode variant. Light-first. Add `@media (prefers-color-scheme: dark)` blocks when needed.
700
701 **Ongoing maintenance:** libadwaita renames or adds tokens across GNOME releases (~2x/year). Small deltas, not rewrites. Read the libadwaita release notes when Fedora ships a new GNOME version.
702
703 Rejected:
704 - **Custom Alloy GTK theme (fork adw-gtk3 with Alloy tokens baked in).** A real project: hundreds of selectors, ongoing GTK-version drift. Deferred to v2+ if Alloy has resources for it. The palette-patch approach captures 90% of the visual win for 10% of the work.
705 - **libadwaita's own accent-color mechanism (GNOME 47+).** Only touches accent, leaves chrome greys untouched. Insufficient for a full palette swap to a curated theme like Akari.
706 - **Kvantum, Materia, Arc, Yaru, WhiteSur.** All ship their own visual identity that would have to be re-styled to match Alloy: the same total work as building the custom theme, without the base of libadwaita compatibility.
707
708 ## Fonts
709
710 Optimized for readability. A single fontconfig at `~/.config/fontconfig/fonts.conf` routes every app that asks for a generic family to Alloy's picks. Config at [`etc/skel/.config/fontconfig/`]../etc/skel/.config/fontconfig/.
711
712 This section holds the reasoning behind each pick. The measurement lives in [FONTS.md]FONTS.md: which codepoints Alloy's surfaces actually emit, what the shipped fonts cover, and which links in the fallback chain exist. The headline is that there is no coverage gap in any cell-grid surface, and that every family the chain names is installed.
713
714 **Departure Mono, Alloy's brand mark, is not in the fontconfig chain.** It stays reserved for headers and brand elements and is invoked by name where wanted. Nobody reads code or long text in Departure.
715
716 **It is also not in the image, which is the more useful fact.** The font layer installs the two cut faces and nothing else, and the only copy in the repo is `docs/mockups/fonts/DepartureMonoNerdFontMono-Regular.otf`, a design-time asset. So Departure is a font for things rendered *outside* the running system: mockups, the README and social card, a swaylock background plate, a first-boot splash image. Anything that names the family at runtime gets a silent fontconfig substitution, so naming it in a theme is not a way to get it.
717
718 **Measured coverage, for whoever reaches for it in a mockup:** 11,468 glyphs across 169 declared languages. Latin, Cyrillic and Greek, the box-drawing range, and the Nerd Font private-use icons. It does not cover CJK, Arabic, Hebrew, Devanagari, Thai, Bengali or Tamil, and it does not cover Vietnamese, which is the one worth knowing: Vietnamese is Latin-script and looks like it should work, so a string with stacked diacritics falls back mid-word rather than failing visibly. For a brand mark set in English that is an acceptable limit rather than a gap, and it is the reason the font stays out of the generic-family chain where a page of arbitrary text would hit it.
719
720 **One limit does bite where Departure is legitimately used.** Of the eighteen non-ASCII codepoints Alloy's own surfaces emit, Departure lacks five: `` `` `` `` ``. Four are starship and helix prompt furniture, which is exactly what a terminal mockup renders, so a mockup set in Departure drops its prompt separators without saying so. It also carries no braille, sextants or octants, so no Departure render can show a `Canvas` or a sparkline. Mock a running surface in the house mono; keep Departure for headers and plates. Detail in [FONTS.md]FONTS.md.
721
722 ### The house faces, and why the image cuts its own
723
724 Wiki: `typography-standard`. Alloy ships **two faces from one superfamily, both cut in the build**: `Quasi Mono` for everything monospaced and `Quasi Body` for UI text. Neither is downloaded, because neither exists as a download: `quasi-type` takes a pinned base, draws the house glyph set into it, and emits the face. Built faces are deliberately not committed anywhere, so the pipeline is the only source of truth for what a face contains.
725
726 **Both faces are client-only, and so is fontconfig itself.** A headless machine rasterises nothing: no compositor, no GTK, and no terminal, since shop is client-only and the server carries only its uninstalled RPM. A TUI reached over ssh is drawn by the client's font stack out of the client's own faces, so the server end of that session never opens a font file. The server profile therefore installs neither face, no fontconfig and no font cache, and the build asserts their absence rather than skipping the check.
727
728 **The argument for cutting rather than picking.** A face chosen for the quality of its text comes from a designer who declined to ship UI symbols, so the two requirements pull apart: pick for legibility and you get no box drawing, pick for symbol coverage and you get a nerd-patched font carrying seventeen thousand codepoints to supply seven. Measured (FONTS.md), Alloy's own source and every config template it ships emit **zero** PUA codepoints, and yazi's built-in filetype icon set is off (`templates/etc/skel/.config/yazi/theme.toml.in`, an empty `[icon]` section). So the whole of what a nerd-patched face would carry is the box-drawing and block ranges plus a handful of marks, and those are drawings we can own.
729
730 **There is no separate terminal font.** One face answers the mono need everywhere: the terminal, the console, the installer, the pango surfaces.
731
732 #### Monospace: **Quasi Mono**, cut from Atkinson Hyperlegible Mono
733
734 Atkinson Hyperlegible is designed by the Braille Institute to maximize letter distinguishability (Il1, O0, cCG all made unambiguous), which is the same thesis as the rest of this section, taken literally. The Mono and Next families are one superfamily by one pair of designers, both variable on `wght` 200-800, both with matching italics, and OFL 1.1 with no Reserved Font Name declared.
735
736 Its cost is that it ships no box drawing and no block elements at all, so the house set grows to 176 glyphs, of which 160 are generated rather than drawn: box drawing is the most regular block in Unicode and both blocks must be cell-exact to tile, which is what hand-drawing gets wrong.
737
738 **Fallback cannot cover the gap, which is what makes the cut necessary rather than nice.** `shop/crates/shop-render/src/shaper.rs` takes the baseline from the primary face only, on purpose, so a fallback face with its own ascent would sit its glyphs on a different line. Box drawing arriving that way is every table border in every TUI drawn off-baseline: broken borders, not tofu.
739
740 **The ExtraLight trap.** The mono base's own default instance is `wght` 200 and its name record reads `Atkinson Hyperlegible Mono ExtraLight`, so a cut inherits that default. fontconfig enumerates a variable font's named instances and resolves an unweighted query to Regular, but a consumer that loads the file directly has to name a weight. The build asserts the resolved weight for exactly this reason.
741
742 Applied at:
743 - shop: it bundles `Quasi Mono`, cut at build time from the same pipeline, so the terminal draws the house marks with no font install at all.
744 - fontconfig `monospace` alias: `Quasi Mono`, for everything else that asks for mono.
745 - `templates/etc/skel/.config/mpv/mpv.conf.in`, `templates/etc/skel/.config/imv/config.in`, the sway bar and title font, mako, swaylock, satty: `Quasi Mono`, all pango.
746
747 Rejected: IosevkaTerm Nerd Font (60 MB and a Private Use Area to carry seven characters, and it is somebody else's design decision in the most-read font on the machine), IBM Plex Mono (ships box drawing complete; loses to Atkinson on legibility and on licence), JetBrains Mono (wider characters cost columns per line), Fira Code (older, ligatures on by default), Cascadia Code (Microsoft-shaped, fine but less Alloy-neutral), Berkeley Mono (paid, can't ship).
748
749 #### Sans: **Quasi Body**, cut from Atkinson Hyperlegible Next
750
751 The sans half of the same superfamily, on the same axis, by the same designers, carrying the same drawn marks. It is cut from upstream Atkinson Hyperlegible.
752
753 **It takes the marks and not the cell grid**: 16 glyphs rather than 176. Box drawing and block elements are sized against the cell and must be cell-exact to tile, and a proportional face has no cell, so a `` set in one draws a corner that joins nothing. Its coverage floor is Alloy's eighteen minus the four that exist to tile (`│ █ ▏ ░`), because a slot answers to the floor of the role it fills.
754
755 Applied at:
756 - `etc/skel/.config/gtk-{3.0,4.0}/settings.ini`: `gtk-font-name = Quasi Body 11`.
757 - fontconfig `sans-serif` alias: everything else that asks for sans, then Cantarell.
758
759 Rejected: upstream Atkinson Hyperlegible (the base: same letters, none of the marks), Inter (excellent but less legibility-maximized, and naming a face the image does not install repoints the whole UI font the moment somebody installs it for an unrelated app), IBM Plex Sans (corporate-shaped), Roboto/Cantarell (default-neutral, no advantage), Iosevka Aile (family coherence appealing, less readable at small sizes than a dedicated UI face).
760
761 ### Serif: **Liberation Serif, by inventory rather than by choice**
762
763 Alloy has no serif thesis. Nothing Alloy authors is serif, and no long-form reading surface it ships is either. The `serif` alias names Liberation Serif because that is the one serif the image installs, so the alias resolves somewhere known instead of somewhere unstated. Naming a face the image does not carry is a commitment with no fallback behind it. If Alloy ever wants a serif, this is the line to change and STACK.md is where the argument goes.
764
765 ### Non-Latin scripts: **Fedora's coverage metapackages**
766
767 `default-fonts-cjk-sans` and `default-fonts-other-sans`. Alloy's three picks above cover Latin, Greek and Cyrillic and nothing else, and Alloy ships a browser as the default. Without these, every CJK, Arabic, Hebrew, Indic and Thai page renders as rows of missing glyphs, which is a coverage gap rather than a curation position.
768
769 Fedora's metapackages rather than a hand-picked font list: the failure mode of hand-picking is that the script left out is the one nobody here reads. Cost is 62 MiB over 4 packages for CJK, whose faces are genuinely large, and 13 MiB over 95 for everything else.
770
771 No fontconfig change accompanies this. The aliases use `<prefer>`, so fontconfig falls through to a font that has the glyph; Atkinson stays the sans for Latin text and Noto covers what it cannot.
772
773 This does not disturb the emoji position below. Neither metapackage carries an emoji font.
774
775 ### Emoji: **not shipped**
776
777 No emoji font is installed, and the fontconfig `emoji` alias is gone rather than pointing at one. Alloy uses no emoji in its own copy, so nothing it draws needs the coverage. Emoji in a web page or a third-party notification render as missing glyphs; that is the accepted cost, not an oversight.
778
779 Declining to ship an emoji font is a position; naming an absent one in the alias is a defect, because a config naming something the image does not carry fails quietly. Anyone who wants emoji installs a font and fontconfig picks it up with no change here.
780
781 ## Greeter
782
783 **greetd + tuigreet.** Rust, minimal, ratatui-rendered. greetd is the daemon that owns VT1; tuigreet is the ratatui client that prompts for user/password and execs [`alloy-session`]../usr/bin/alloy-session on successful auth, which applies the chosen day/night skeleton and execs `sway`. Config at [`etc/greetd/`]../etc/greetd/: `/etc/greetd/config.toml` sets up VT1 with tuigreet and a `--theme` written in ANSI color names.
784
785 **The palette reaches it through the console, not through `--theme`.** tuigreet 0.9.1 parses only the sixteen ANSI names; a hex value is ignored rather than rejected, and the affected element falls back to a default, so `prompt=#8a4530` renders red. What delivers the palette is the console's own 16-color table, applied twice from one source. [`usr/lib/bootc/kargs.d/10-alloy.toml`]../templates/usr/lib/bootc/kargs.d/10-alloy.toml.in sets it via `vt.default_red/grn/blu` before userspace starts, and [`alloy-vtrgb.service`]../etc/systemd/system/alloy-vtrgb.service runs `setvtrgb /usr/share/alloy/vtrgb` after, ordered `Before=greetd.service getty.target`. Both render from the theme through skelgen, so the two cannot drift. tuigreet then asks for `gray` and gets `surface.raised`. The same substitution themes every VT, including the debug shell and any getty, and it costs a boot-time kernel argument plus a oneshot unit rather than a patch to tuigreet.
786
787 **The console has no night table yet, only a night file.** skelgen renders `vtrgb.night` beside `vtrgb` and the image asserts both are present, but nothing reads it: `alloy-vtrgb.service` names the day table outright, and the kargs template has no `variants` directive at all, so the kernel only ever carries Akari Dawn. The per-user mode file cannot decide this, because the unit runs before any user exists and the kernel argument is fixed at build time. Choosing between the two tables needs a system-scoped setting that does not exist. Until it does, boot and login are Dawn whatever the session that follows is.
788
789 **Which name lands on which slot is the whole design.** tuigreet hands each `--theme` value to ratatui's `Color::from_str`, so the names carry ratatui's numbering: `gray` is slot 7 and `white` is slot 15, not the other way around. Only the login card gets a background (`container`); the clock and the status line are drawn onto the console, whose background is slot 0. Elements outside the card therefore have to name a light tone and elements inside it a dark one, which is what the string in `config.toml` does.
790
791 Same ratatui toolkit family as `alloy console` and `bottom`; the login screen reads as a design sibling to the rest of the Alloy chrome.
792
793 Rejected:
794 - **gdm.** GNOME Display Manager pulls in the entire GNOME session infrastructure Alloy specifically removes. Non-starter.
795 - **sddm.** Qt-based, KDE default. Qt surface is near-zero elsewhere in Alloy; adopting it just for the login screen is unjustified.
796 - **lightdm.** X11-first, Wayland support via plugins; older shape.
797 - **ly.** TUI display manager (ncurses, C). Established but not Rust, no design-system alignment.
798 - **regreet.** Rust GTK greetd greeter; nicer visuals than tuigreet but drags GTK into the login layer. tuigreet's austerity is a feature.
799 - **agreety.** greetd's default plain-text prompt. Works but visually inconsistent with the rest of the stack.
800
801 **Later: a custom Alloy greetd greeter using `alloy_tui`.** A v2+ candidate alongside the console. Would put the login screen inside the same design system as everything else Alloy authors. Deferred until `alloy_tui` v1 lands.
802