# Alloy component library The intended idioms for the `alloy_tui` crate: the ratatui design-system library the `alloy` console and every authored Alloy TUI depend on. It is Alloy's one authored visual identity (the egui `alloy_ui` sibling was dropped in the 2026-07-17 pivot). ## Scope `alloy_tui` exposes: - **Design tokens** as a runtime `Theme` value resolving makeover intents into ratatui `Color` / `Style`. Single source of truth for everything in [DESIGN-LANGUAGE.md](DESIGN-LANGUAGE.md). - **Themed primitives** configured against the tokens: blocks, list rows, tags, form fields, status bars, log panes. Each carries its focus/selected/disabled variants. - **Layout helpers**: Alloy-flavored wrappers over ratatui's `Layout` constraint solver (section spacing measured in cells). - **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. It does *not* expose: - Application-level state or logic. - Reactive primitives, signals, observables (would violate principle 3 in [MANIFESTO.md](MANIFESTO.md)). - **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. ## The ratatui architecture, stated plainly ratatui is immediate-mode *rendering* with event-driven *input*. Each tick the app draws its whole UI from current state into a `Frame` (no retained widget tree to diverge), then separately handles input events that mutate that state. `alloy_tui` follows that split: the primitives below are render helpers, and the focus/keymap model is what turns key events into state changes. There is no inline `button.clicked()` as in a retained or egui-style toolkit; a "button" renders in its current state, and the app's event handler decides what the activation key does when that control holds focus. ## The four idioms The crate uses four idioms consistently so authoring across TUIs feels uniform. ### Functions for one-shot render helpers The lightest pattern. A helper takes the `Frame` and an area and draws from state: ```rust pub fn status_line(f: &mut Frame, area: Rect, theme: &Theme, state: &StatusState) { let style = match state.severity { Severity::Error => Severity::Error.style(theme), _ => Style::default().fg(theme.content_muted), }; f.render_widget(Paragraph::new(state.message.as_str()).style(style), area); } ``` Use functions for one-shot chrome that takes few arguments and needs no configuration. ### Builders for configurable widgets ratatui widgets are already builders; `alloy_tui` wraps them so the theming is not restatable per call site. A wrapper either returns a configured ratatui widget or renders itself: ```rust pub struct AlloyBlock<'a> { theme: &'a Theme, focused: bool, } impl<'a> AlloyBlock<'a> { pub fn new(theme: &'a Theme) -> Self { /* ... */ } pub fn focused(mut self, focused: bool) -> Self { /* ... */ } // Returns a themed ratatui Block; the caller titles and renders it. // Per DESIGN-LANGUAGE.md, focus swaps *only* the border color. Surface, // title, and everything else stay constant across focused/unfocused; // border-only is the whole rule. pub fn build(self) -> Block<'a> { let border_color = if self.focused { self.theme.border_strong } else { self.theme.border_subtle }; Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(border_color)) .style(Style::default().bg(self.theme.surface_page).fg(self.theme.content_primary)) } } // Call site: let block = AlloyBlock::new(theme).focused(focus.is_focused(PANE_NET)).build(); let inner = block.inner(area); frame.render_widget(block.title(" network "), area); ``` 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. ### Layout helpers over the constraint solver ratatui's `Layout` is the flow primitive; `alloy_tui` adds themed splits that bake in Alloy's cell spacing: ```rust // A titled section: a header row, one cell of breathing space, then the body area. pub fn section(area: Rect, title: &str) -> (Rect /* header */, Rect /* body */) { let rows = Layout::vertical([ Constraint::Length(1), // header Constraint::Length(1), // gap, in cells Constraint::Min(0), // body ]).split(area); (rows[0], rows[2]) } ``` Composition is by nesting `Layout` splits, exactly as ratatui intends; the helpers only fix the spacing constants and header styling. ### `Widget` / `StatefulWidget` for reusable types ratatui's canonical extension traits. Use for types that render themselves and, when they carry selection/scroll state, `StatefulWidget`: ```rust pub struct UrgencyTag<'a> { theme: &'a Theme, kind: Urgency } impl Widget for UrgencyTag<'_> { fn render(self, area: Rect, buf: &mut Buffer) { let accent = match self.kind { Urgency::Low => self.theme.status_info, Urgency::Medium => self.theme.status_warning, Urgency::High => self.theme.status_danger, }; // Accent on the glyph + label only, never as a filled background: // color is information, not chrome (see DESIGN-LANGUAGE.md). Line::from(vec![ Span::styled(self.kind.glyph(), Style::default().fg(accent)), Span::raw(" "), Span::styled(self.kind.label(), Style::default().fg(self.theme.content_primary)), ]).render(area, buf); } } // Call site: f.render_widget(UrgencyTag { theme, kind: Urgency::High }, area); ``` 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`). `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. ## Focus and keymap model The part ratatui does not provide. `alloy_tui` ships: - 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. - 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`, so an empty list selects nothing rather than reporting row 0. - 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. - 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. This is the direct descendant of `sysop-tui`, retinted from that crate's const palette to the runtime theme. ## Design tokens 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 makeover 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. Themes are makeover `.toml` files, the same schema every make-family app reads, living in makeover's `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: ``` border-subtle = mix(line.border, surface.page, 60%) decorative divider border-strong = mix(line.border, content.primary, 65%) focus / selection ``` 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. 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: - **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. - **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. 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. ## Per-frame discipline 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. ## What this enforces - Every Alloy TUI looks coherent because they all consume the same tokens and the same primitives. - The non-reactive principle is enforced by exposing no reactive primitives. - 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`. - The theme file is the seam where design tooling could plug in. ## Late-term: a design tool, and the token seam The theme file is the deliberate seam where external design tooling could live, and it is toolkit-agnostic: anything that emits a valid makeover `.toml` is a viable producer (a hand-edited file, Tokens Studio, a future Alloy-built tool). The move to makeover 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. 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.