max / alloy
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6 files changed,
+117 insertions,
-108 deletions
| @@ -19,11 +19,11 @@ | |||
| 19 | 19 | - [`docs/MANIFESTO.md`](docs/MANIFESTO.md) — what Alloy is, who it's for, the thesis. | |
| 20 | 20 | - [`docs/STACK.md`](docs/STACK.md) — every pick (base, compositor, bar, lock, launcher, notifications, screenshot, file manager) with reasoning and what was rejected. | |
| 21 | 21 | - [`docs/MARQUEE-APPS.md`](docs/MARQUEE-APPS.md) — the egui design-system app pipeline. | |
| 22 | - | - [`docs/COMPONENT-LIBRARY.md`](docs/COMPONENT-LIBRARY.md) — `alloy_tui` crate idioms and the design-token pipeline. (Pre-2026-07-17 pivot: authored around egui/`alloy_ui`; the tokens pipeline and authoring idioms carry over.) | |
| 22 | + | - [`docs/COMPONENT-LIBRARY.md`](docs/COMPONENT-LIBRARY.md) — `alloy_tui` crate idioms and how themes reach the widgets. | |
| 23 | 23 | - [`docs/RESEARCH-IMMEDIATE-MODE-DESIGN.md`](docs/RESEARCH-IMMEDIATE-MODE-DESIGN.md) — late-term research sketch: what does Figma look like for immediate-mode UI? How do designers and engineers collaborate without designers writing Rust? | |
| 24 | 24 | - [`docs/DESIGN-LANGUAGE.md`](docs/DESIGN-LANGUAGE.md) — tinted-greyscale chrome, color reserved for information. | |
| 25 | - | - [`docs/CONSOLE.md`](docs/CONSOLE.md) — Alloy Console: the ratatui unified control surface (`alloy net`, `alloy audio`, `alloy config <path>` schema-driven editor). Replaces the "graphical settings app" question. | |
| 26 | - | - [`docs/CONTINUITY.md`](docs/CONTINUITY.md) — Tailscale and Syncthing as base-image citizens: an Alloy install should be rejoinable, not just installable. | |
| 25 | + | - [`docs/CONSOLE.md`](docs/CONSOLE.md) — Alloy Console: the ratatui unified control surface (`alloy net`, `alloy audio`, `alloy mesh` shipped; `alloy config <path>` schema-driven editor next). Replaces the "graphical settings app" question. | |
| 26 | + | - [`docs/CONTINUITY.md`](docs/CONTINUITY.md) — a mesh VPN and file sync as base-image citizens: an Alloy install should be rejoinable, not just installable. | |
| 27 | 27 | - [`docs/SHELL.md`](docs/SHELL.md) — Nushell as the login shell; bash unchanged as `/bin/sh` and `/bin/bash`. Architecture, interop rules, accepted costs. | |
| 28 | 28 | - [`docs/HARDWARE-FW12.md`](docs/HARDWARE-FW12.md) — Framework Laptop 12 as the first hardware target: 2-in-1 stance, touch/tablet/rotation/OSK/fingerprint decisions. | |
| 29 | 29 | - [`docs/IMAGE.md`](docs/IMAGE.md) — Image composition: bootc + Containerfile, source on `git.sr.ht`, CI on `builds.sr.ht`, container image on `quay.io`. |
| @@ -6,16 +6,16 @@ | |||
| 6 | 6 | ||
| 7 | 7 | `alloy_tui` exposes: | |
| 8 | 8 | ||
| 9 | - | - **Design tokens** as ratatui `Color` / `Style` / `Modifier` constants. Single source of truth for everything in [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md). | |
| 9 | + | - **Design tokens** as a runtime `Theme` value resolving theme-common intents into ratatui `Color` / `Style`. Single source of truth for everything in [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md). | |
| 10 | 10 | - **Themed primitives** — blocks, list rows, tags, form fields, status bars, log panes — configured against the tokens, each carrying its focus/selected/disabled variants. | |
| 11 | 11 | - **Layout helpers** — Alloy-flavored wrappers over ratatui's `Layout` constraint solver (section spacing measured in cells). | |
| 12 | - | - **A focus + keymap model** — the piece ratatui does not give you. Rendering is immediate-mode, but input is event-driven, so the crate owns a small focus-ring/navigation model the app drives from its event loop. | |
| 13 | - | - **Backend detection** — the mock-or-real pattern ported from mountaineer-sysop's `sysop-tui`, so a TUI runs against mocks on any host and binds to real system state when present. | |
| 12 | + | - **A focus + keymap model** — the piece ratatui does not give you. Rendering is immediate-mode, but input is event-driven, so the crate owns the navigation model the app drives from its event loop: `FocusRing` across panes, `Cursor` down a list. | |
| 14 | 13 | ||
| 15 | 14 | It does *not* expose: | |
| 16 | 15 | ||
| 17 | 16 | - Application-level state or logic. | |
| 18 | 17 | - Reactive primitives, signals, observables (would violate principle 3 in [MANIFESTO.md](MANIFESTO.md)). | |
| 18 | + | - **Backend detection**, despite earlier drafts of this document listing it above. The mock-or-real pattern from mountaineer-sysop's `sysop-tui` carries over as a pattern, but the code lives per view in the console binary, because what counts as the real backend is `nmcli` for one view and `pactl` for the next. A design-system crate has no business knowing either name. | |
| 19 | 19 | ||
| 20 | 20 | ## The ratatui architecture, stated plainly | |
| 21 | 21 | ||
| @@ -30,10 +30,10 @@ | |||
| 30 | 30 | The lightest pattern. A helper takes the `Frame` and an area and draws from state: | |
| 31 | 31 | ||
| 32 | 32 | ```rust | |
| 33 | - | pub fn status_bar(f: &mut Frame, area: Rect, state: &StatusState) { | |
| 33 | + | pub fn status_line(f: &mut Frame, area: Rect, theme: &Theme, state: &StatusState) { | |
| 34 | 34 | let style = match state.severity { | |
| 35 | - | Severity::Error => tokens::style_on_surface(tokens::ACCENT_ERROR), | |
| 36 | - | Severity::Ok => tokens::TEXT_MUTED, | |
| 35 | + | Severity::Error => Severity::Error.style(theme), | |
| 36 | + | _ => Style::default().fg(theme.content_muted), | |
| 37 | 37 | }; | |
| 38 | 38 | f.render_widget(Paragraph::new(state.message.as_str()).style(style), area); | |
| 39 | 39 | } | |
| @@ -47,35 +47,39 @@ | |||
| 47 | 47 | ||
| 48 | 48 | ```rust | |
| 49 | 49 | pub struct AlloyBlock<'a> { | |
| 50 | - | title: &'a str, | |
| 50 | + | theme: &'a Theme, | |
| 51 | 51 | focused: bool, | |
| 52 | - | kind: SurfaceKind, // Data (square) vs Control (bordered) | |
| 53 | 52 | } | |
| 54 | 53 | ||
| 55 | 54 | impl<'a> AlloyBlock<'a> { | |
| 56 | - | pub fn new(title: &'a str) -> Self { /* ... */ } | |
| 57 | - | pub fn focused(mut self, yes: bool) -> Self { /* ... */ } | |
| 58 | - | pub fn kind(mut self, kind: SurfaceKind) -> Self { /* ... */ } | |
| 59 | - | // Returns a themed ratatui Block; the caller renders it. | |
| 55 | + | pub fn new(theme: &'a Theme) -> Self { /* ... */ } | |
| 56 | + | pub fn focused(mut self, focused: bool) -> Self { /* ... */ } | |
| 57 | + | ||
| 58 | + | // Returns a themed ratatui Block; the caller titles and renders it. | |
| 60 | 59 | // Per DESIGN-LANGUAGE.md, focus swaps *only* the border color. Surface, | |
| 61 | - | // title, and everything else stay constant across focused/unfocused — | |
| 60 | + | // title, and everything else stay constant across focused/unfocused; | |
| 62 | 61 | // border-only is the whole rule. | |
| 63 | - | pub fn block(self) -> Block<'a> { | |
| 64 | - | let border_style = if self.focused { tokens::BORDER_STRONG } else { tokens::BORDER }; | |
| 62 | + | pub fn build(self) -> Block<'a> { | |
| 63 | + | let border_color = if self.focused { | |
| 64 | + | self.theme.border_strong | |
| 65 | + | } else { | |
| 66 | + | self.theme.border_subtle | |
| 67 | + | }; | |
| 65 | 68 | Block::default() | |
| 66 | - | .title(self.title) | |
| 67 | 69 | .borders(Borders::ALL) | |
| 68 | - | .border_type(BorderType::Plain) | |
| 69 | - | .border_style(border_style) | |
| 70 | - | .style(tokens::SURFACE_RAISED) | |
| 70 | + | .border_style(Style::default().fg(border_color)) | |
| 71 | + | .style(Style::default().bg(self.theme.surface_page).fg(self.theme.content_primary)) | |
| 71 | 72 | } | |
| 72 | 73 | } | |
| 73 | 74 | ||
| 74 | 75 | // Call site: | |
| 75 | - | let inner = AlloyBlock::new("Network").focused(self.focus == Pane::Net).block(); | |
| 76 | - | f.render_widget(inner, area); | |
| 76 | + | let block = AlloyBlock::new(theme).focused(focus.is_focused(PANE_NET)).build(); | |
| 77 | + | let inner = block.inner(area); | |
| 78 | + | frame.render_widget(block.title(" network "), area); | |
| 77 | 79 | ``` | |
| 78 | 80 | ||
| 81 | + | The theme arrives as a borrowed `&Theme` rather than being read from globals, because there are no global tokens to read: the palette is loaded from a file at startup. Every widget in the crate takes the theme the same way. The title is applied by the caller rather than the builder, so the wrapper stays a thin themed `Block` and composes with everything ratatui already does with one. | |
| 82 | + | ||
| 79 | 83 | ### Layout helpers over the constraint solver | |
| 80 | 84 | ||
| 81 | 85 | ratatui's `Layout` is the flow primitive; `alloy_tui` adds themed splits that bake in Alloy's cell spacing: | |
| @@ -84,9 +88,9 @@ | |||
| 84 | 88 | // A titled section: a header row, one cell of breathing space, then the body area. | |
| 85 | 89 | pub fn section(area: Rect, title: &str) -> (Rect /* header */, Rect /* body */) { | |
| 86 | 90 | let rows = Layout::vertical([ | |
| 87 | - | Constraint::Length(1), // header | |
| 88 | - | Constraint::Length(tokens::SPACE_SM),// gap, in cells | |
| 89 | - | Constraint::Min(0), // body | |
| 91 | + | Constraint::Length(1), // header | |
| 92 | + | Constraint::Length(1), // gap, in cells | |
| 93 | + | Constraint::Min(0), // body | |
| 90 | 94 | ]).split(area); | |
| 91 | 95 | (rows[0], rows[2]) | |
| 92 | 96 | } | |
| @@ -99,101 +103,77 @@ | |||
| 99 | 103 | ratatui's canonical extension traits. Use for types that render themselves and, when they carry selection/scroll state, `StatefulWidget`: | |
| 100 | 104 | ||
| 101 | 105 | ```rust | |
| 102 | - | pub struct UrgencyTag { kind: Urgency } | |
| 106 | + | pub struct UrgencyTag<'a> { theme: &'a Theme, kind: Urgency } | |
| 103 | 107 | ||
| 104 | - | impl Widget for UrgencyTag { | |
| 108 | + | impl Widget for UrgencyTag<'_> { | |
| 105 | 109 | fn render(self, area: Rect, buf: &mut Buffer) { | |
| 106 | 110 | let accent = match self.kind { | |
| 107 | - | Urgency::Low => tokens::ACCENT_INFO, | |
| 108 | - | Urgency::Medium => tokens::ACCENT_WARN, | |
| 109 | - | Urgency::High => tokens::ACCENT_ERROR, | |
| 111 | + | Urgency::Low => self.theme.status_info, | |
| 112 | + | Urgency::Medium => self.theme.status_warning, | |
| 113 | + | Urgency::High => self.theme.status_danger, | |
| 110 | 114 | }; | |
| 111 | 115 | // Accent on the glyph + label only, never as a filled background: | |
| 112 | 116 | // color is information, not chrome (see DESIGN-LANGUAGE.md). | |
| 113 | 117 | Line::from(vec![ | |
| 114 | 118 | Span::styled(self.kind.glyph(), Style::default().fg(accent)), | |
| 115 | 119 | Span::raw(" "), | |
| 116 | - | Span::styled(self.kind.label(), tokens::TEXT_PRIMARY), | |
| 120 | + | Span::styled(self.kind.label(), Style::default().fg(self.theme.content_primary)), | |
| 117 | 121 | ]).render(area, buf); | |
| 118 | 122 | } | |
| 119 | 123 | } | |
| 120 | 124 | ||
| 121 | 125 | // Call site: | |
| 122 | - | f.render_widget(UrgencyTag { kind: Urgency::High }, area); | |
| 126 | + | f.render_widget(UrgencyTag { theme, kind: Urgency::High }, area); | |
| 123 | 127 | ``` | |
| 124 | 128 | ||
| 125 | - | The starter widget set (per [CONSOLE.md](CONSOLE.md)): `AlloyBlock`, `AlloyList`, `AlloyForm`, `AlloyTable`, `AlloyStatusBar`, `AlloyLog`, plus the schema-driven form fields (`ColorField`, `EnumField`, `RangedNumberField`, `ToggleField`, `TextField`). | |
| 129 | + | The starter widget set (per [CONSOLE.md](CONSOLE.md)). Shipped: `AlloyBlock`, `AlloyList`, `AlloyStatusBar`, `AlloyLog`, and `AlloyConnector`. Landing with `alloy config`: `AlloyForm`, `AlloyTable`, and the schema-driven form fields (`ColorField`, `EnumField`, `RangedNumberField`, `ToggleField`, `TextField`). | |
| 130 | + | ||
| 131 | + | `AlloyList` scrolls statelessly: the first visible row is derived from the selection every frame rather than carried in a `ListState`. That is what keeps it immediate-mode, and it costs centered scrolling rather than minimal scrolling. Because anything drawing alongside a list has to agree with it about which rows are on screen, that derivation is public (`list_offset`, `list_row_y`) rather than reimplemented by callers; `AlloyConnector` uses it to find the row it points at. | |
| 126 | 132 | ||
| 127 | 133 | ## Focus and keymap model | |
| 128 | 134 | ||
| 129 | 135 | The part ratatui does not provide. `alloy_tui` ships: | |
| 130 | 136 | ||
| 131 | - | - A `Focus` type: an ordered ring of focusable pane/field ids, with `next()` / `prev()` / `current()`. The app holds one `Focus` in its state; widgets render their focused variant when `focus.current() == self.id`. | |
| 132 | - | - Reserved keymap constants so every Alloy TUI navigates identically: `Tab` / `Shift-Tab` move focus, `Enter` activates, `Esc` cancels, `Ctrl-S` saves, `q` quits. These live in one place; apps match against them rather than hardcoding keycodes. | |
| 133 | - | - A footer-chrome helper that renders the active keymap hints along the bottom row, so the reserved keys are always discoverable. | |
| 137 | + | - A `FocusRing`: an ordered ring of focusable panes, with `next()` / `prev()` / `current()` / `is_focused(slot)`. The app holds one in its state; widgets render their focused variant when the ring reports their slot. It wraps, because wrapping past the last pane back to the first is what a user means by Tab. A zero-length ring is inert rather than a modulo by zero, so a view whose panes have not loaded needs no special case. | |
| 138 | + | - A `Cursor`: a selection over a list, which is a different problem and therefore a different type. It clamps instead of wrapping, since a user holding `j` at the bottom of a list expects to stay there, and it re-clamps on `resize()` when a refresh returns fewer rows than before. It reports `Option<usize>`, so an empty list selects nothing rather than reporting row 0. | |
| 139 | + | - Reserved keymap constants so every Alloy TUI navigates identically: `Tab` / `Shift-Tab` move focus, `Enter` activates, `Esc` cancels, `Ctrl-S` saves, `q` quits, with `?`, `/`, and `:` reserved for help, filter, and command entry. These live in one place; apps match against the classified `Action` rather than hardcoding keycodes. Two caveats the classifier documents rather than hides: callers must filter to key *press* events, since Windows terminals deliver press and release and an unfiltered loop performs everything twice, and a view holding an active text input must not treat `q`, `/`, or `:` as reserved. | |
| 140 | + | - A footer-chrome helper that renders the active keymap hints along the bottom row, so the reserved keys are always discoverable, with a status slot at the right end. | |
| 134 | 141 | ||
| 135 | - | This is the direct descendant of `sysop-tui`; port its focus/footer/keymap code as the seed. | |
| 142 | + | This is the direct descendant of `sysop-tui`, retinted from that crate's const palette to the runtime theme. | |
| 136 | 143 | ||
| 137 | 144 | ## Design tokens | |
| 138 | 145 | ||
| 139 | - | Tokens are the single source of truth. They live in a TOML file at the crate root and are compiled into Rust constants at build time via a `build.rs` step. One-way pipeline: design tooling (or a hand-edited TOML) authors the tokens; Rust consumes the generated constants. | |
| 146 | + | Tokens are the single source of truth, and they are read at runtime. Earlier drafts of this document described a `tokens.toml` at the crate root compiled to Rust constants by a `build.rs` step. That pipeline was replaced by theme-common before `alloy_tui` was written, and no part of it exists. [TOKENS.md](TOKENS.md) is authoritative; this section only describes the crate-side shape. | |
| 140 | 147 | ||
| 141 | - | Canonical values (light + dark ramp, accent palette, typography role split, geometry) are locked in [TOKENS.md](TOKENS.md), which also explains which tokens survive the move to a terminal cell grid (the palette and semantic roles) and which do not (pixel radii, sub-cell borders). The TOML below is illustrative shape: | |
| 148 | + | Themes are theme-common `.toml` files, the same schema every make-family app reads, living in `MNW/shared/themes/` and `~/.config/alloy/themes/`. `alloy_tui` loads one, resolves the intents it needs into ratatui colors, and derives two Alloy-specific tokens locally so theme files stay minimal and cross-app compatible: | |
| 142 | 149 | ||
| 143 | - | ```toml | |
| 144 | - | # alloy_tui/tokens.toml (shape; values per TOKENS.md) | |
| 145 | - | [palette.light] | |
| 146 | - | surface = "oklch(96% 0.012 80)" | |
| 147 | - | surface-raised = "oklch(98% 0.012 80)" | |
| 148 | - | surface-overlay = "oklch(99.5% 0.012 80)" | |
| 149 | - | text-primary = "oklch(18% 0.012 80)" | |
| 150 | - | text-muted = "oklch(55% 0.012 80)" | |
| 151 | - | border = "oklch(80% 0.012 80)" | |
| 152 | - | border-strong = "oklch(65% 0.012 80)" | |
| 153 | - | ||
| 154 | - | [palette.accents] | |
| 155 | - | error = "oklch(55% 0.18 25)" | |
| 156 | - | warn = "oklch(70% 0.15 85)" | |
| 157 | - | healthy = "oklch(62% 0.16 145)" | |
| 158 | - | info = "oklch(60% 0.16 240)" | |
| 159 | - | syntax = "oklch(55% 0.18 310)" | |
| 160 | - | ||
| 161 | - | [spacing] # in terminal cells, not pixels | |
| 162 | - | sm = 1 | |
| 163 | - | md = 2 | |
| 164 | - | lg = 3 | |
| 150 | + | ``` | |
| 151 | + | border-subtle = mix(line.border, surface.page, 60%) decorative divider | |
| 152 | + | border-strong = mix(line.border, content.primary, 65%) focus / selection | |
| 165 | 153 | ``` | |
| 166 | 154 | ||
| 167 | - | The build step converts OKLCH to sRGB and emits `tokens.rs` as ratatui values: | |
| 155 | + | Mixing is in linear sRGB, matching the audit math in TOKENS.md, and is pinned by a test against a value from that document's contrast table. If the test fails, the table is stale rather than the code. | |
| 168 | 156 | ||
| 169 | - | ```rust | |
| 170 | - | // generated | |
| 171 | - | pub const SURFACE: Style = Style::new().bg(Color::Rgb(0xF5, 0xF3, 0xEC)); | |
| 172 | - | pub const SURFACE_RAISED: Style = Style::new().bg(Color::Rgb(0xFA, 0xF8, 0xF2)); | |
| 173 | - | pub const TEXT_PRIMARY: Style = Style::new().fg(Color::Rgb(0x2A, 0x28, 0x22)); | |
| 174 | - | pub const BORDER: Style = Style::new().fg(Color::Rgb(0xC9, 0xC3, 0xB4)); | |
| 175 | - | pub const BORDER_STRONG: Style = Style::new().fg(Color::Rgb(0xA0, 0x99, 0x82)); | |
| 176 | - | pub const ACCENT_ERROR: Color = Color::Rgb(0xC0, 0x3A, 0x2F); | |
| 177 | - | pub const SPACE_SM: u16 = 1; | |
| 178 | - | ``` | |
| 157 | + | The result is one `Theme` value per load, threaded by reference through the widgets. Two consequences worth stating, because both differ from a constants pipeline: | |
| 179 | 158 | ||
| 180 | - | Truecolor is the target (rio and every modern terminal render 24-bit `Color::Rgb`). Provide a 256-color downgrade table for terminals that report no truecolor, computed at build time from the same OKLCH values. | |
| 159 | + | - **A malformed or partial theme is rejected, not defaulted.** Every intent the crate renders is required at load. There is no built-in fallback palette anywhere in the crate or the console, because TOKENS.md's rule is that no hex values live in Rust, and a silent fallback would put a palette there that exists in no theme file. | |
| 160 | + | - **Light and dark are not two compiled ramps.** They are separate theme files (`akari-dawn`, `akari-night`), selected by `--theme` or guessed from `$COLORFGBG` at startup. `Mode` records which kind was loaded; it does not select between built-in palettes. | |
| 181 | 161 | ||
| 182 | - | Dark mode is the polarity-flipped derivative of the light ramp (same hue, same chroma, L-stops repositioned so `surface-raised` still reads lighter than `surface`). The crate exposes both palettes and a `Mode` the app selects at startup or from `$COLORFGBG` / terminal query. See [TOKENS.md](TOKENS.md#the-ramp-dark-mode-derived). | |
| 162 | + | Truecolor is the target (rio and every modern terminal render 24-bit `Color::Rgb`). A 256-color downgrade table for terminals reporting no truecolor is still deferred, as it is in TOKENS.md. | |
| 183 | 163 | ||
| 184 | 164 | ## Per-frame discipline | |
| 185 | 165 | ||
| 186 | - | ratatui already redraws the whole frame each tick, so there is no `set_style` seam to police as egui had. The discipline is narrower and just as strict: **widgets never construct a `Color` or `Style` literally; they only reference `tokens::` constants.** A single `Theme { mode }` value threads through the render call so light/dark selection happens in one place. That keeps theming centralized and prevents per-app drift. | |
| 166 | + | ratatui already redraws the whole frame each tick, so there is no `set_style` seam to police as egui had. The discipline is narrower and just as strict: **widgets never construct a `Color` literally; every color comes off the `&Theme` they were handed.** One `Theme` is built per theme load and threaded through the render call, so light/dark selection happens in one place. That keeps theming centralized and prevents per-app drift. | |
| 187 | 167 | ||
| 188 | 168 | ## What this enforces | |
| 189 | 169 | ||
| 190 | 170 | - Every Alloy TUI looks coherent because they all consume the same tokens and the same primitives. | |
| 191 | 171 | - The non-reactive principle is enforced by exposing no reactive primitives. | |
| 192 | 172 | - Mandatory state variants from [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md#affordance-depth-via-ramp-not-via-color) are baked into the primitives: `AlloyBlock` cannot render without resolving its focused/unfocused border from the ramp, and form fields resolve their disabled style from `text-muted`. | |
| 193 | - | - The token TOML is the seam where design tooling could plug in. | |
| 173 | + | - The theme file is the seam where design tooling could plug in. | |
| 194 | 174 | ||
| 195 | 175 | ## Late-term: a design tool, and the token seam | |
| 196 | 176 | ||
| 197 | - | The token TOML is the deliberate seam where external design tooling could live, and it is toolkit-agnostic: anything that emits a valid `tokens.toml` is a viable producer (a hand-edited file, Tokens Studio, a future Alloy-built tool). A speculative token-sync tool keeps the working name **`cast`** (alloys are cast; the tool casts a design source into Alloy tokens). The broader research question of a design tool native to immediate-mode UI is captured in [RESEARCH-IMMEDIATE-MODE-DESIGN.md](RESEARCH-IMMEDIATE-MODE-DESIGN.md); with the pivot to ratatui, that question now targets terminal compositions rather than GPU-drawn ones, but the architecture is the same. | |
| 177 | + | The theme file is the deliberate seam where external design tooling could live, and it is toolkit-agnostic: anything that emits a valid theme-common `.toml` is a viable producer (a hand-edited file, Tokens Studio, a future Alloy-built tool). The move to theme-common widened that seam rather than closing it, since the format is now shared with every make-family app instead of being Alloy's own. A speculative token-sync tool keeps the working name **`cast`** (alloys are cast; the tool casts a design source into Alloy tokens). The broader research question of a design tool native to immediate-mode UI is captured in [RESEARCH-IMMEDIATE-MODE-DESIGN.md](RESEARCH-IMMEDIATE-MODE-DESIGN.md); with the pivot to ratatui, that question now targets terminal compositions rather than GPU-drawn ones, but the architecture is the same. | |
| 198 | 178 | ||
| 199 | - | The commitment that keeps the door open: **tokens are a file, not Rust source.** As long as `tokens.toml` is the authority and Rust consumes generated constants, the producer is swappable and the design system stays portable. | |
| 179 | + | The commitment that keeps the door open: **tokens are a file, not Rust source.** As long as the theme file is the authority and Rust holds no hex values of its own, the producer is swappable and the design system stays portable. Runtime loading strengthens that commitment: swapping a theme is now a file change and a relaunch rather than a rebuild. |
| @@ -15,17 +15,32 @@ | |||
| 15 | 15 | ## Subcommand shape | |
| 16 | 16 | ||
| 17 | 17 | ``` | |
| 18 | - | alloy net # network: nmcli / iwd front | |
| 19 | - | alloy audio # audio: wpctl / pactl front | |
| 18 | + | alloy net # network: nmcli front [shipped] | |
| 19 | + | alloy audio # audio: pactl front, streams and devices [shipped] | |
| 20 | + | alloy mesh # mesh VPN: tailscale front (alias: tail) [shipped] | |
| 20 | 21 | alloy display # outputs: swaymsg output / wlr-randr front | |
| 21 | 22 | alloy update # system updates: rpm-ostree front | |
| 22 | - | alloy tail # tailscale front (see CONTINUITY.md) | |
| 23 | 23 | alloy sync # syncthing front (see CONTINUITY.md) | |
| 24 | 24 | alloy config <path> # schema-driven TOML/KDL editor | |
| 25 | 25 | alloy theme <name> # swap the runtime theme; reads MNW/shared/themes/*.toml | |
| 26 | 26 | # or ~/.config/alloy/themes/*.toml via theme-common | |
| 27 | 27 | ``` | |
| 28 | 28 | ||
| 29 | + | `alloy mesh` was named `alloy tail` when this document was written. It is | |
| 30 | + | generic now, for two reasons. Someone who has never heard of Tailscale should | |
| 31 | + | still find the screen that lists the machines they can reach, and Headscale is | |
| 32 | + | a self-hosted control server driving the same client, so a vendor-named verb | |
| 33 | + | would misdescribe half its users. `tail` remains as an alias. The backend name | |
| 34 | + | stays visible in the view title (`mesh (tailscale)`), and a self-hosted control | |
| 35 | + | plane is named there too (`mesh (tailscale via hs.example.org)`), read once at | |
| 36 | + | startup from `tailscale debug prefs`. That is a `debug` interface and therefore | |
| 37 | + | unstable, so the lookup degrades to showing nothing rather than failing. | |
| 38 | + | ||
| 39 | + | Backend picks that differ from the sketch above: `net` fronts `nmcli` alone, | |
| 40 | + | since Alloy is Fedora and NetworkManager is what is there. `audio` fronts | |
| 41 | + | `pactl` rather than `wpctl`, because `pactl -f json` is a documented contract | |
| 42 | + | while `wpctl status` renders a box-drawing tree meant for human eyes. | |
| 43 | + | ||
| 29 | 44 | Each subcommand is a small ratatui adapter over an existing CLI. The subcommands share: | |
| 30 | 45 | ||
| 31 | 46 | - The `alloy_tui` design-system crate (colors, typography, layout, form widgets). | |
| @@ -33,7 +48,11 @@ | |||
| 33 | 48 | - A common status area (busy/error/dirty indicators). | |
| 34 | 49 | - A common command-log pane (every action shows the underlying CLI invocation, so users learn the primitive, not the wrapper). | |
| 35 | 50 | ||
| 36 | - | That last point is deliberate. Alloy Console is not trying to hide the CLI — it's trying to make the CLI *approachable*. Every action taken through the console prints the equivalent shell command in the log pane. Users graduate from console to CLI as they get comfortable; the tool teaches its own primitives. | |
| 51 | + | That last point is deliberate. Alloy Console is not trying to hide the CLI, it is trying to make the CLI *approachable*. Every action taken through the console prints the equivalent shell command in the log pane. Users graduate from console to CLI as they get comfortable; the tool teaches its own primitives. | |
| 52 | + | ||
| 53 | + | **What the pane records is what the user asked for**, which is narrower than everything the console runs. Three kinds of invocation are the console's own bookkeeping and stay out of it: capability probes, which run before the user has asked for anything; background polls on the shell tick, which exist so a stream appearing or a peer going offline shows up without a keypress; and the re-read that confirms what an action did. Without that split a single volume keypress writes its action plus a four-command re-read into a two-row pane, and the command the user pressed a key for scrolls off before it can be read. An explicit refresh is a user action and does log. | |
| 54 | + | ||
| 55 | + | Every command is executed as argv rather than through a shell, and displayed the same way it is run, quoting arguments that contain whitespace so the logged line can be pasted into a shell and mean the same thing there. | |
| 37 | 56 | ||
| 38 | 57 | ## `alloy config` — schema-driven editor | |
| 39 | 58 | ||
| @@ -92,14 +111,20 @@ | |||
| 92 | 111 | ||
| 93 | 112 | ## `alloy_tui` — the ratatui design system | |
| 94 | 113 | ||
| 95 | - | `alloy_tui` **is** Alloy's design system. The pivot dropped the egui `alloy_ui` sibling, so there is no GUI counterpart — this crate carries the whole authored visual identity. It consumes `tokens.toml` (see [TOKENS.md](TOKENS.md)); palette, typography, and semantic colors render as terminal chrome. | |
| 114 | + | `alloy_tui` **is** Alloy's design system. The pivot dropped the egui `alloy_ui` sibling, so there is no GUI counterpart, and this crate carries the whole authored visual identity. It consumes theme-common `.toml` theme files at runtime (see [TOKENS.md](TOKENS.md)); palette and semantic colors render as terminal chrome. | |
| 96 | 115 | ||
| 97 | - | Contents (v1): | |
| 98 | - | - Themed `ratatui` widget wrappers: `AlloyBlock`, `AlloyList`, `AlloyForm`, `AlloyTable`, `AlloyStatusBar`, `AlloyLog`. | |
| 99 | - | - Extension traits on `ratatui::Frame`. | |
| 116 | + | Contents (v1). Shipped: | |
| 117 | + | - Themed `ratatui` widget wrappers: `AlloyBlock`, `AlloyList`, `AlloyStatusBar`, `AlloyLog`. | |
| 118 | + | - `AlloyConnector`, which draws the link between two panes. Added for `alloy audio`'s stream-to-device pairing and not in the original roster. | |
| 119 | + | - `keys`: the reserved keymap (Tab, Shift-Tab, Enter, Esc, Ctrl-S, q, plus `?`, `/`, `:`) and the classifier apps match against. | |
| 120 | + | - `FocusRing` for moving between panes, and `Cursor` for a selection over a list whose length changes underneath it. Two types rather than one: a focus ring wraps, because that is what Tab means, while a list cursor clamps and re-clamps when a refresh returns fewer rows. | |
| 121 | + | - Footer chrome and the theme layer, ported from mountaineer-sysop's `sysop-tui` and retinted from a runtime theme rather than a const palette. | |
| 122 | + | ||
| 123 | + | Still to come, with `alloy config`: | |
| 124 | + | - `AlloyForm`, `AlloyTable`. | |
| 100 | 125 | - Form-field widgets driven by the schema types above (`ColorField`, `EnumField`, `RangedNumberField`, etc.). | |
| 101 | - | - Consistent keymap constants (Tab, Shift-Tab, Enter, Esc, Ctrl-S, q). | |
| 102 | - | - Footer chrome, reserved keys, and mock-or-real backend detection ported from mountaineer-sysop's `sysop-tui`. | |
| 126 | + | ||
| 127 | + | Not in `alloy_tui`, contrary to earlier drafts: mock-or-real backend detection. It lives per view in the console binary, because what counts as "the real backend" is `nmcli` for one view and `pactl` for another. The pattern carries over from `sysop-tui`; the code does not. | |
| 103 | 128 | ||
| 104 | 129 | The immediate-mode paradigm's stability property (see [STACK.md](STACK.md)) is now shipped in one place: everything Alloy authors renders through `alloy_tui`. | |
| 105 | 130 | ||
| @@ -112,8 +137,12 @@ | |||
| 112 | 137 | ||
| 113 | 138 | ## Roadmap | |
| 114 | 139 | ||
| 115 | - | - **v0.5 (post-v0 stack).** `alloy config` alone, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). No live-state subcommands yet. Ships as a way for users to configure the stack without hand-editing TOML. | |
| 116 | - | - **v1.** Add `alloy net`, `alloy audio`, `alloy display`, `alloy update`, `alloy tail`, `alloy sync`, `alloy theme`. Fills out the console as a system control surface. `alloy theme` swaps the runtime theme in place (theme-common consumer, no re-login). First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy tail` and `alloy sync` enrollments. | |
| 140 | + | The ordering below is the reverse of what this document originally planned, which put `alloy config` alone at v0.5 and every live-state subcommand at v1. The live-state views went first instead. They are small enough to carve one at a time, and each one forced a piece of shared machinery into existence against something real: the shell chrome and log pane from `net`, the second list and the `Cursor` from `audio`, the two-pane layout and `AlloyConnector` from `audio`'s routing, the background tick from watching streams appear. `alloy config` needs the form widgets and the schema parser at once, and it is a better shape to build on a shell that has already carried three screens. | |
| 141 | + | ||
| 142 | + | - **Shipped.** `alloy net`, `alloy audio`, `alloy mesh`. Plus the shell they share: frame, reserved keys, focus, command-log pane, background tick. | |
| 143 | + | - **Next.** `alloy config`, with schemas for the v0-adopted TOML configs (rio, yazi, mako, and others; the sway config takes the text-edit fallback). The largest remaining piece: schema-DSL v1 parser, `toml_edit` roundtrip layer, and the form widgets together. | |
| 144 | + | - **Blocked on the target machine.** `alloy display` and `alloy update` front `swaymsg`/`wlr-randr` and `rpm-ostree`, none of which exist on a non-Fedora, non-sway development box. Writing them now would mean shipping parsers checked against nothing but their own fixtures, which is exactly how the two parser bugs found so far got written. They want the QEMU image or real hardware. | |
| 145 | + | - **Then.** `alloy sync`, `alloy theme`. `alloy theme` swaps the runtime theme in place (theme-common consumer, no re-login). First-boot flow (see [CONTINUITY.md](CONTINUITY.md)) is a thin shim over `alloy mesh` and `alloy sync` enrollments. | |
| 117 | 146 | - **v1.x.** Additional adopted-tool schemas as the v0 stack grows. (`alloy hinged` was shelved with the FW12 tablet flow in the pivot.) | |
| 118 | 147 | - **v2+.** Third-party subcommand registration (a well-known directory of ratatui adapters the console discovers at runtime), if a real ecosystem case emerges. Not planned. | |
| 119 | 148 |
| @@ -15,8 +15,8 @@ | |||
| 15 | 15 | **Pick: Tailscale client, shipped in the base image, disabled at first boot until the user enrolls.** | |
| 16 | 16 | ||
| 17 | 17 | - The client (`tailscaled` + `tailscale`) is open-source and works with either the tailscale.com control plane or a self-hosted Headscale server. | |
| 18 | - | - The control plane choice is exposed at enrollment time as a "Server" field, defaulting to tailscale.com. Users pointing at Headscale set `--login-server=<url>` — Alloy handles this as a first-class field in `alloy tail`, not a footnote. | |
| 19 | - | - Enabled via `alloy tail` (see below) or plain `sudo tailscale up`. Not enrolled by default. An OS that phones home before the user says yes is out of character for Alloy. | |
| 18 | + | - The control plane choice is exposed at enrollment time as a "Server" field, defaulting to tailscale.com. Users pointing at Headscale set `--login-server=<url>`, which Alloy handles as a first-class field in `alloy mesh` rather than a footnote. The console reads the control plane back and names it in the view title when it is self-hosted, so "which control plane am I on" is answerable without dropping to a shell. | |
| 19 | + | - Enabled via `alloy mesh` (see below) or plain `sudo tailscale up`. Not enrolled by default. An OS that phones home before the user says yes is out of character for Alloy. | |
| 20 | 20 | ||
| 21 | 21 | **Not shipping the Headscale server itself.** That is fleet-admin work, not client-OS work. Users self-hosting Headscale run it on their infrastructure of choice; Alloy points at it. | |
| 22 | 22 | ||
| @@ -49,15 +49,15 @@ | |||
| 49 | 49 | +---------------------------------------------------------+ | |
| 50 | 50 | ``` | |
| 51 | 51 | ||
| 52 | - | Skip is fine and reversible — later, the same enrollments live under `alloy tail` and `alloy sync`. This is not a wizard, and Alloy does not grow one. Two rows, two actions, done. | |
| 52 | + | Skip is fine and reversible — later, the same enrollments live under `alloy mesh` and `alloy sync`. This is not a wizard, and Alloy does not grow one. Two rows, two actions, done. | |
| 53 | 53 | ||
| 54 | 54 | **Deferred to v1.** The first-boot flow itself is v1 scope. v0.5 users start the daemons manually via the console subcommands. Shipping the subcommands first, and the wizard after, is the correct ordering — the wizard is a thin shim over the enrollment actions the subcommands already expose. | |
| 55 | 55 | ||
| 56 | 56 | ## Console subcommands | |
| 57 | 57 | ||
| 58 | - | `alloy tail` and `alloy sync` live under Alloy Console. Full spec in [CONSOLE.md](CONSOLE.md); scope summary here so this document stands alone: | |
| 58 | + | `alloy mesh` and `alloy sync` live under Alloy Console. Full spec in [CONSOLE.md](CONSOLE.md); scope summary here so this document stands alone: | |
| 59 | 59 | ||
| 60 | - | - **`alloy tail`** — ratatui front over Tailscale. Peer list with online status, exit-node selection, MagicDNS lookup, share/unshare, enrollment flow with configurable login server (tailscale.com or Headscale). Replaces `tailscale status` as the daily-use surface. | |
| 60 | + | - **`alloy mesh`** (was `alloy tail`; the old verb remains an alias). A ratatui front over Tailscale, named for what it is rather than who makes it, since Headscale users drive the same client. Replaces `tailscale status` as the daily-use surface. **Shipped:** peer list with online status and last-seen, this machine first, exit-node selection and clearing, and the control plane named in the title when it is self-hosted. **Still to come:** MagicDNS lookup, share/unshare, and the enrollment flow. | |
| 61 | 61 | - **`alloy sync`** — ratatui front over Syncthing's REST API. Folder list with per-folder sync state, device list with connection state, add/remove folder, add/remove device, pause/resume. Does not try to replicate the Syncthing web UI's full feature surface — just the operations users actually perform. The web UI remains available for edge cases. | |
| 62 | 62 | ||
| 63 | 63 | ## Non-goals | |
| @@ -89,5 +89,5 @@ | |||
| 89 | 89 | ||
| 90 | 90 | - [ ] MagicDNS interaction with Fedora's `systemd-resolved` — verify no config conflict on a fresh Silverblue install with Tailscale enrolled. | |
| 91 | 91 | - [x] **Syncthing's `discosrv` and `relaysrv`: defaults for v1.** Public infrastructure is fine for the enroll-and-it-works model. Expose configuration in `alloy sync` only if requests appear from users self-hosting the discovery / relay side. | |
| 92 | - | - [ ] Headscale login-server field in `alloy tail` enrollment: preserve across `tailscale down` / `tailscale up` cycles automatically, or require re-entry? Automatic is the correct default; verify Tailscale client behavior supports it cleanly. | |
| 93 | - | - [x] **First-boot screen appears once.** If the user skips both Tailscale and Syncthing, `alloy tail` and `alloy sync` are the enrollment paths after that; the first-boot flow does not re-appear. | |
| 92 | + | - [ ] Headscale login-server field in `alloy mesh` enrollment: preserve across `tailscale down` / `tailscale up` cycles automatically, or require re-entry? Automatic is the correct default; verify Tailscale client behavior supports it cleanly. | |
| 93 | + | - [x] **First-boot screen appears once.** If the user skips both Tailscale and Syncthing, `alloy mesh` and `alloy sync` are the enrollment paths after that; the first-boot flow does not re-appear. |
| @@ -32,7 +32,7 @@ | |||
| 32 | 32 | ||
| 33 | 33 | Immediate-mode reframes the unit of design work. There is no screen — there is a *function rendered every frame from current state*. So the meaningful artifacts a designer produces are: | |
| 34 | 34 | ||
| 35 | - | 1. **Tokens** — palette, type scale, spacing, radii, motion (or its absence). Flat data, already covered by `tokens.toml` in [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md). | |
| 35 | + | 1. **Tokens** — palette, type scale, spacing, radii, motion (or its absence). Flat data, already covered by the theme-common theme files described in [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md). | |
| 36 | 36 | 2. **Themed primitives** — buttons, text fields, tags, focus rings. Visual specification of state variants. | |
| 37 | 37 | 3. **Composition vocabulary** — "this is a card with these slots," "this is a list row with this arrangement," "this section is dense; this one is breathy." A *grammar*, not a pixel layout. | |
| 38 | 38 | 4. **Data shapes the composition accepts.** A `PackageRow` accepts `(name, version, urgency, icon)`. The designer specifies what fields render in what arrangement; the engineer provides the data. | |
| @@ -49,7 +49,7 @@ | |||
| 49 | 49 | ||
| 50 | 50 | ### Token-first authoring | |
| 51 | 51 | ||
| 52 | - | Most design work is the token graph. The tool's primary editing surface is the palette, the typography scale, the spacing scale, the radii, the elevation steps. Changes propagate instantly to every component preview. This is already most of Figma's value — and it round-trips cleanly to a `tokens.toml` file (the `cast` tool idea generalized). | |
| 52 | + | Most design work is the token graph. The tool's primary editing surface is the palette, the typography scale, the spacing scale, the radii, the elevation steps. Changes propagate instantly to every component preview. This is already most of Figma's value — and it round-trips cleanly to a theme-common theme file (the `cast` tool idea generalized). | |
| 53 | 53 | ||
| 54 | 54 | ### Composition grammar, not pixel layout | |
| 55 | 55 | ||
| @@ -78,7 +78,7 @@ | |||
| 78 | 78 | ||
| 79 | 79 | ### Engineer-facing output | |
| 80 | 80 | ||
| 81 | - | The artifacts produced are: `tokens.toml` (tokens), `<Component>.alloy.ron` files (compositions), and a small registry of which components exist. The engineer wires these into the authored TUIs (the console). There is no pixel-perfect mockup to translate; the composition file *is* the spec. | |
| 81 | + | The artifacts produced are: a theme-common theme file (tokens), `<Component>.alloy.ron` files (compositions), and a small registry of which components exist. The engineer wires these into the authored TUIs (the console). There is no pixel-perfect mockup to translate; the composition file *is* the spec. | |
| 82 | 82 | ||
| 83 | 83 | ## Open research questions | |
| 84 | 84 | ||
| @@ -104,7 +104,7 @@ | |||
| 104 | 104 | ||
| 105 | 105 | Almost nothing. The architectural decisions already made in [COMPONENT-LIBRARY.md](COMPONENT-LIBRARY.md) keep the door open: | |
| 106 | 106 | ||
| 107 | - | - Tokens live in a TOML file, not in Rust source. Any tool that emits valid `tokens.toml` is a viable producer. | |
| 107 | + | - Tokens live in a TOML file, not in Rust source. Any tool that emits a valid theme-common theme file is a viable producer. | |
| 108 | 108 | - The crate exposes a small, stable set of layout combinators and themed primitives. These would be the vocabulary the composition grammar references. | |
| 109 | 109 | ||
| 110 | 110 | The further commitments Alloy *could* make to keep this even more open (small, low-cost, worth considering): |
| @@ -6,7 +6,7 @@ | |||
| 6 | 6 | ||
| 7 | 7 | ## Storage format: theme-common | |
| 8 | 8 | ||
| 9 | - | Alloy consumes themes in the [theme-common](../../../MNW/shared/theme-common/) TOML format, the same schema GoingsOn, Balanced Breakfast, audiofiles, and makenot.work already consume. A theme file has seven sections: | |
| 9 | + | Alloy consumes themes in the [theme-common](../../MNW/shared/theme-common/) TOML format, the same schema GoingsOn, Balanced Breakfast, audiofiles, and makenot.work already consume. A theme file has seven sections: | |
| 10 | 10 | ||
| 11 | 11 | ```toml | |
| 12 | 12 | [meta] | |
| @@ -45,7 +45,7 @@ | |||
| 45 | 45 | six = "#305858" | |
| 46 | 46 | ``` | |
| 47 | 47 | ||
| 48 | - | Alloy's default light theme is **[Akari Dawn](../../../MNW/shared/themes/akari-dawn.toml)**; the default dark is **[Akari Night](../../../MNW/shared/themes/akari-night.toml)**. Both are based on Shu Kutsuzawa's [Akari](https://github.com/cappyzawa/akari-theme) (MIT). Users pick a mode at startup (`--theme` flag or `$COLORFGBG` / OSC background query); users can also drop any theme-common `.toml` into `~/.config/alloy/themes/` and pass its name to swap. | |
| 48 | + | Alloy's default light theme is **[Akari Dawn](../../MNW/shared/themes/akari-dawn.toml)**; the default dark is **[Akari Night](../../MNW/shared/themes/akari-night.toml)**. Both are based on Shu Kutsuzawa's [Akari](https://github.com/cappyzawa/akari-theme) (MIT). Users pick a mode at startup (`--theme` flag or `$COLORFGBG` / OSC background query); users can also drop any theme-common `.toml` into `~/.config/alloy/themes/` and pass its name to swap. | |
| 49 | 49 | ||
| 50 | 50 | **Why theme-common as the storage format:** the future user-facing theme catalog (a website of downloadable `.toml` files) is only useful if every make-family app renders any downloaded theme the same way. Sharing the format across apps buys that with zero adapter code. | |
| 51 | 51 | ||
| @@ -129,15 +129,15 @@ | |||
| 129 | 129 | - **Calibrated-display verification** on real hardware in real terminals (truecolor and 256-color). Pre-1.0 gate. | |
| 130 | 130 | - **256-color downgrade table** for terminals that report no truecolor. | |
| 131 | 131 | - **Figlet / Nerd-Font recipes** for the large numeric readouts a display font would otherwise carry. | |
| 132 | - | - **`alloy theme <path>` subcommand** (see [CONSOLE.md](CONSOLE.md)) — v0.5+, wraps theme swap. | |
| 132 | + | - **`alloy theme <name>` subcommand** (see [CONSOLE.md](CONSOLE.md)), which wraps theme swap. The console reads themes at startup today, via `--theme` or a guess from `$COLORFGBG`; swapping in place without a relaunch is what remains. | |
| 133 | 133 | ||
| 134 | 134 | ## How this file is consumed | |
| 135 | 135 | ||
| 136 | - | Themes at [`MNW/shared/themes/*.toml`](../../../MNW/shared/themes/) are the ground truth. `alloy_tui` at load time: | |
| 136 | + | Themes at [`MNW/shared/themes/*.toml`](../../MNW/shared/themes/) are the ground truth. `alloy_tui` at load time: | |
| 137 | 137 | ||
| 138 | 138 | 1. Reads the selected theme file via the `theme_common` crate. | |
| 139 | 139 | 2. Computes the two derived tokens (`border-subtle`, `border-strong`) from `line.border`. | |
| 140 | - | 3. Exposes the full token map as ratatui `Style` / `Color` constants. | |
| 140 | + | 3. Exposes the full token map as a runtime `Theme` of ratatui `Style` / `Color` values. | |
| 141 | 141 | 4. Optionally emits a 256-color fallback table for terminals without truecolor. | |
| 142 | 142 | ||
| 143 | 143 | No hex values are hard-coded in Rust. Changing the theme file changes every authored Alloy surface after re-launch (or hot-swap once `alloy theme` ships). Third-party themes downloaded into `~/.config/alloy/themes/` work with zero adapter code. |