Skip to main content

max / alloy

Consume alloy_tui from crates.io instead of as a workspace member The design system moved to its own repo (git.sr.ht/~maxmj/alloy_tui), split out with subtree so its history went with it. Bento releases one crate per repo, so a workspace member could not go through its publish preflight -- alloy_tui was the last crate still hand-published. The split also matches the licensing that was already true: alloy is GPLv3-or-later and alloy_tui is MIT, and a separate repo states that plainly instead of carving one directory out of a copyleft tree. Pinned to 0.1.0 for now; 0.1.1 corrects the crate's repository metadata and is waiting on its new repo being made public.
Author: Max Johnson <me@maxj.phd> · 2026-07-19 21:29 UTC
Signed with PGP, not checked
Commit: b3df6f28811f5914984e33cf80e17f85e1183f82
Parent: 67f1b18
17 files changed, +8 insertions, -1879 deletions
M CONTRIBUTING.md +1 -1
@@ -6,7 +6,7 @@
6 6
7 7 Alloy is licensed under the GNU General Public License v3.0 or later. See [LICENSE](LICENSE). Code contributed to this repository must be compatible with GPLv3+. Any future code outside this license (vendored dependencies, etc.) must carry compatible terms.
8 8
9 - One crate is deliberately exempt: `crates/alloy_tui` is MIT ([its own LICENSE](crates/alloy_tui/LICENSE)), because it is the reusable design system rather than the application. Publishing it permissively lets other terminal UIs adopt Alloy's look without inheriting copyleft, and lets non-GPL siblings link it. Contributions to that crate are MIT; everything else in the repo is GPLv3+.
9 + The design system is deliberately not in this repo: [`alloy_tui`](https://git.sr.ht/~maxmj/alloy_tui) is a separate MIT crate, consumed here from crates.io. It is the reusable library rather than the application, so publishing it permissively lets other terminal UIs adopt Alloy's look without inheriting copyleft, and lets non-GPL siblings link it. Everything in this repo is GPLv3+.
10 10
11 11 ## Project state
12 12
M Cargo.lock +2
@@ -33,6 +33,8 @@
33 33 [[package]]
34 34 name = "alloy_tui"
35 35 version = "0.1.0"
36 + source = "registry+https://github.com/rust-lang/crates.io-index"
37 + checksum = "bb1ca159e66b17d1597702b240d0fa605e2591a52b79953832703690b6e190eb"
36 38 dependencies = [
37 39 "makeover",
38 40 "ratatui",
M README.md +4 -4
@@ -12,10 +12,10 @@
12 12
13 13 ## License
14 14
15 - GPLv3-or-later, with one exception: the `alloy_tui` crate is MIT, so other
16 - projects can build terminal UIs on the design system without taking on
17 - copyleft. See [`LICENSE`](LICENSE), [`crates/alloy_tui/LICENSE`](crates/alloy_tui/LICENSE),
18 - and [`CONTRIBUTING.md`](CONTRIBUTING.md).
15 + GPLv3-or-later. The design system it renders through,
16 + [`alloy_tui`](https://git.sr.ht/~maxmj/alloy_tui), lives in its own repo under
17 + MIT, so other projects can build terminal UIs on it without taking on copyleft.
18 + See [`LICENSE`](LICENSE) and [`CONTRIBUTING.md`](CONTRIBUTING.md).
19 19
20 20 ## Docs
21 21
@@ -13,7 +13,7 @@
13 13 path = "src/main.rs"
14 14
15 15 [dependencies]
16 - alloy_tui = { path = "../alloy_tui" }
16 + alloy_tui = "0.1.0"
17 17 anyhow = "1"
18 18 clap = { version = "4", features = ["derive"] }
19 19 ratatui.workspace = true
@@ -1,13 +1,0 @@
1 - [package]
2 - name = "alloy_tui"
3 - version = "0.1.0"
4 - description = "Alloy design system: makeover intents rendered as ratatui Color/Style, plus themed widgets for the alloy console and siblings."
5 - edition.workspace = true
6 - rust-version.workspace = true
7 - license = "MIT"
8 - repository.workspace = true
9 - authors.workspace = true
10 -
11 - [dependencies]
12 - ratatui.workspace = true
13 - makeover.workspace = true
@@ -1,21 +1,0 @@
1 - MIT License
2 -
3 - Copyright (c) 2026 Make Creative, LLC
4 -
5 - Permission is hereby granted, free of charge, to any person obtaining a copy
6 - of this software and associated documentation files (the "Software"), to deal
7 - in the Software without restriction, including without limitation the rights
8 - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 - copies of the Software, and to permit persons to whom the Software is
10 - furnished to do so, subject to the following conditions:
11 -
12 - The above copyright notice and this permission notice shall be included in all
13 - copies or substantial portions of the Software.
14 -
15 - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 - SOFTWARE.
@@ -1,51 +1,0 @@
1 - # alloy_tui
2 -
3 - Alloy's design system for [ratatui](https://ratatui.rs): themed widgets, a
4 - reserved keymap, and a focus ring, driven by
5 - [makeover](https://crates.io/crates/makeover) themes.
6 -
7 - Extracted from the Alloy console so other terminal UIs can share one look
8 - rather than each re-deriving colors from a palette.
9 -
10 - ## What it gives you
11 -
12 - - **`Theme`** — a makeover `.toml` theme resolved into ratatui `Color`/`Style`.
13 - Two Alloy-specific tokens (`border-subtle`, `border-strong`) are derived
14 - locally so theme files stay minimal and cross-app compatible.
15 - - **Widgets** — `AlloyBlock`, `AlloyList`, `AlloyTabs`, `AlloyStatusBar`,
16 - `AlloyModal`, `AlloyLog`, each taking a `&Theme`.
17 - - **`keys`** — the reserved keymap every Alloy TUI agrees on, so the same keys
18 - mean the same thing across tools.
19 - - **`focus`** — a focus ring, the piece ratatui deliberately leaves to the app.
20 -
21 - ## Color is information
22 -
23 - Chrome is tinted greyscale. Accent color lives on text, carried by `Severity`,
24 - so a colored thing on screen means something rather than decorating. Widgets
25 - enforce this; you get it by using them.
26 -
27 - ## Usage
28 -
29 - ```rust
30 - use alloy_tui::{AlloyStatusBar, Theme, hint};
31 -
32 - // Resolve a makeover theme into ratatui colors.
33 - let dirs = vec![(makeover::bundled_themes_dir().unwrap(), false)];
34 - let colors = makeover::load_theme(&dirs, "akari-night").unwrap();
35 - let theme = Theme::from_theme(&colors).unwrap();
36 -
37 - // Widgets take &Theme.
38 - let footer = AlloyStatusBar::new(&theme, [hint("Tab", "focus"), hint("q", "quit")]);
39 - ```
40 -
41 - There is deliberately no built-in fallback palette. A missing or malformed
42 - theme is an error you surface, not something papered over by rendering in
43 - colors that exist nowhere in the theme files.
44 -
45 - ## License
46 -
47 - MIT.
48 -
49 - The rest of the [Alloy](https://git.sr.ht/~maxmj/alloy) repository is
50 - GPLv3-or-later. This crate is permissive on purpose: it is the reusable design
51 - system, not the application.
@@ -1,182 +1,0 @@
1 - //! The link between two panes: an elbow drawn through the gutter, joining a
2 - //! row on the left to the row it is paired with on the right.
3 - //!
4 - //! One connector at a time, deliberately. Drawing every pairing at once is the
5 - //! obvious reading of the matching-quiz idea and it does not survive contact
6 - //! with a terminal: lines cross, a cell can only hold one glyph, and past
7 - //! three or four pairs the picture is unreadable. Lighting only the focused
8 - //! pairing gives the same "these two are joined" reading, never crosses
9 - //! anything, and needs no crossing-glyph logic.
10 -
11 - use ratatui::buffer::Buffer;
12 - use ratatui::layout::Rect;
13 - use ratatui::style::Style;
14 - use ratatui::widgets::Widget;
15 -
16 - use crate::theme::Theme;
17 -
18 - /// A connector between `from_y` on the left and `to_y` on the right, both in
19 - /// absolute buffer rows.
20 - pub struct AlloyConnector<'a> {
21 - theme: &'a Theme,
22 - from_y: u16,
23 - to_y: u16,
24 - }
25 -
26 - impl<'a> AlloyConnector<'a> {
27 - pub fn new(theme: &'a Theme, from_y: u16, to_y: u16) -> Self {
28 - Self { theme, from_y, to_y }
29 - }
30 - }
31 -
32 - impl Widget for AlloyConnector<'_> {
33 - fn render(self, area: Rect, buf: &mut Buffer) {
34 - // Needs a column to leave, a column to run down, and a column to
35 - // arrive; narrower than that and there is nothing to draw.
36 - if area.width < 3 || area.height == 0 {
37 - return;
38 - }
39 -
40 - let top = area.y;
41 - let bottom = area.y + area.height - 1;
42 - // A row scrolled out of its pane has nothing to point at. Clamping
43 - // instead would draw a connector to a row the user cannot see, which
44 - // reads as a link to whatever happens to be at the edge.
45 - if !(top..=bottom).contains(&self.from_y) || !(top..=bottom).contains(&self.to_y) {
46 - return;
47 - }
48 -
49 - let style = Style::default()
50 - .fg(self.theme.border_strong)
51 - .bg(self.theme.surface_page);
52 - let mid = area.x + area.width / 2;
53 - let last = area.x + area.width - 1;
54 -
55 - let mut put = |x: u16, y: u16, glyph: &str| {
56 - buf[(x, y)].set_symbol(glyph).set_style(style);
57 - };
58 -
59 - if self.from_y == self.to_y {
60 - for x in area.x..=last {
61 - put(x, self.from_y, "─");
62 - }
63 - return;
64 - }
65 -
66 - // Leave the left pane.
67 - for x in area.x..mid {
68 - put(x, self.from_y, "─");
69 - }
70 - // Enter the right pane.
71 - for x in (mid + 1)..=last {
72 - put(x, self.to_y, "─");
73 - }
74 -
75 - let (upper, lower) = if self.from_y < self.to_y {
76 - (self.from_y, self.to_y)
77 - } else {
78 - (self.to_y, self.from_y)
79 - };
80 - for y in (upper + 1)..lower {
81 - put(mid, y, "│");
82 - }
83 -
84 - // Corners: the glyph at each end depends on which way the run turns.
85 - let (from_corner, to_corner) = if self.from_y < self.to_y {
86 - ("┐", "└")
87 - } else {
88 - ("┘", "┌")
89 - };
90 - put(mid, self.from_y, from_corner);
91 - put(mid, self.to_y, to_corner);
92 - }
93 - }
94 -
95 - #[cfg(test)]
96 - mod tests {
97 - use super::*;
98 - use crate::theme::Mode;
99 - use ratatui::style::Color;
100 -
101 - fn theme() -> Theme {
102 - Theme {
103 - mode: Mode::Dark,
104 - surface_page: Color::Rgb(0, 0, 0),
105 - surface_raised: Color::Rgb(1, 1, 1),
106 - surface_sunken: Color::Rgb(2, 2, 2),
107 - surface_overlay: Color::Rgb(3, 3, 3),
108 - content_primary: Color::Rgb(4, 4, 4),
109 - content_secondary: Color::Rgb(5, 5, 5),
110 - content_muted: Color::Rgb(6, 6, 6),
111 - action_primary: Color::Rgb(7, 7, 7),
112 - status_danger: Color::Rgb(8, 8, 8),
113 - status_success: Color::Rgb(9, 9, 9),
114 - status_warning: Color::Rgb(10, 10, 10),
115 - status_info: Color::Rgb(11, 11, 11),
116 - line_border: Color::Rgb(12, 12, 12),
117 - border_subtle: Color::Rgb(13, 13, 13),
118 - border_strong: Color::Rgb(14, 14, 14),
119 - category: [Color::Rgb(15, 15, 15); 6],
120 - }
121 - }
122 -
123 - /// Render a connector into a `width`x`height` gutter and read it back as
124 - /// rows of text.
125 - fn render(from_y: u16, to_y: u16, width: u16, height: u16) -> Vec<String> {
126 - let theme = theme();
127 - let area = Rect::new(0, 0, width, height);
128 - let mut buf = Buffer::empty(area);
129 - AlloyConnector::new(&theme, from_y, to_y).render(area, &mut buf);
130 -
131 - (0..height)
132 - .map(|y| {
133 - (0..width)
134 - .map(|x| buf[(x, y)].symbol())
135 - .collect::<String>()
136 - })
137 - .collect()
138 - }
139 -
140 - #[test]
141 - fn aligned_rows_draw_a_straight_run() {
142 - assert_eq!(render(1, 1, 3, 3), [" ", "───", " "]);
143 - }
144 -
145 - // Descending: leave the left pane, turn down, arrive on the lower row.
146 - #[test]
147 - fn descending_link_turns_down() {
148 - assert_eq!(render(0, 2, 3, 3), ["─┐ ", " │ ", " └─"]);
149 - }
150 -
151 - // Ascending is the mirror image. Reusing the descending corners here would
152 - // draw an elbow pointing the wrong way, which is the kind of thing that
153 - // looks fine until you see it next to its opposite.
154 - #[test]
155 - fn ascending_link_turns_up() {
156 - assert_eq!(render(2, 0, 3, 3), [" ┌─", " │ ", "─┘ "]);
157 - }
158 -
159 - #[test]
160 - fn adjacent_rows_need_no_vertical_run() {
161 - assert_eq!(render(0, 1, 3, 2), ["─┐ ", " └─"]);
162 - }
163 -
164 - // A row scrolled out of view must not be drawn to. Clamping would point
165 - // the connector at whatever sits at the pane edge.
166 - #[test]
167 - fn out_of_range_rows_draw_nothing() {
168 - assert_eq!(render(0, 9, 3, 3), [" ", " ", " "]);
169 - assert_eq!(render(9, 0, 3, 3), [" ", " ", " "]);
170 - }
171 -
172 - #[test]
173 - fn a_gutter_too_narrow_to_hold_an_elbow_draws_nothing() {
174 - assert_eq!(render(0, 1, 2, 2), [" ", " "]);
175 - }
176 -
177 - // Wider gutters keep the vertical run centered and extend the horizontals.
178 - #[test]
179 - fn wider_gutters_extend_the_horizontal_runs() {
180 - assert_eq!(render(0, 2, 5, 3), ["──┐ ", " │ ", " └──"]);
181 - }
182 - }
@@ -1,149 +1,0 @@
1 - //! A list cursor: a bounded, clamping selection over a list whose length
2 - //! changes underneath it.
3 - //!
4 - //! Distinct from [`FocusRing`](crate::FocusRing), and deliberately so. A focus
5 - //! ring moves between a fixed set of panes and wraps, because wrapping past
6 - //! the last pane back to the first is what a user means by Tab. A list cursor
7 - //! moves over rows that appear and disappear on refresh, and it clamps: a user
8 - //! holding `j` at the bottom of a list expects to stay there, not to leap to
9 - //! the top.
10 - //!
11 - //! The clamp-on-resize case is the one worth centralizing. Every console view
12 - //! re-fetches its list and can find it shorter than it was, and a cursor left
13 - //! pointing past the end renders a selection nobody can see.
14 -
15 - /// A selection index over a list of `len` rows.
16 - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17 - pub struct Cursor {
18 - index: usize,
19 - len: usize,
20 - }
21 -
22 - impl Cursor {
23 - /// A cursor over an empty list. Views construct this before their first
24 - /// fetch and call [`resize`](Self::resize) once the data lands.
25 - pub const fn new() -> Self {
26 - Self { index: 0, len: 0 }
27 - }
28 -
29 - /// The selected row, or `None` when the list is empty.
30 - ///
31 - /// Returning `Option` rather than a bare index is what keeps an empty list
32 - /// from reporting row 0 as selected, which renders as a selection marker
33 - /// on a row that does not exist.
34 - pub const fn selected(&self) -> Option<usize> {
35 - if self.len == 0 { None } else { Some(self.index) }
36 - }
37 -
38 - pub const fn len(&self) -> usize {
39 - self.len
40 - }
41 -
42 - pub const fn is_empty(&self) -> bool {
43 - self.len == 0
44 - }
45 -
46 - /// Tell the cursor how long the list is now, clamping the selection into
47 - /// range. Call this on every refresh, before rendering.
48 - pub const fn resize(&mut self, len: usize) {
49 - self.len = len;
50 - if len == 0 {
51 - self.index = 0;
52 - } else if self.index >= len {
53 - self.index = len - 1;
54 - }
55 - }
56 -
57 - /// Move by `delta` rows, clamped at both ends.
58 - pub const fn move_by(&mut self, delta: isize) {
59 - if self.len == 0 {
60 - return;
61 - }
62 - let last = self.len - 1;
63 - if delta < 0 {
64 - // `unsigned_abs` rather than `-delta`: negating isize::MIN
65 - // overflows, and a held key that somehow accumulated one should
66 - // saturate at the top like any other large step.
67 - self.index = self.index.saturating_sub(delta.unsigned_abs());
68 - } else {
69 - // `Ord::min` is not const yet, so this is spelled out.
70 - let target = self.index.saturating_add(delta as usize);
71 - self.index = if target > last { last } else { target };
72 - }
73 - }
74 -
75 - pub const fn next(&mut self) {
76 - self.move_by(1);
77 - }
78 -
79 - pub const fn prev(&mut self) {
80 - self.move_by(-1);
81 - }
82 - }
83 -
84 - #[cfg(test)]
85 - mod tests {
86 - use super::*;
87 -
88 - #[test]
89 - fn empty_cursor_selects_nothing() {
90 - let mut cursor = Cursor::new();
91 - assert_eq!(cursor.selected(), None);
92 - cursor.next();
93 - cursor.prev();
94 - assert_eq!(cursor.selected(), None, "movement on an empty list is inert");
95 - }
96 -
97 - #[test]
98 - fn clamps_at_both_ends_instead_of_wrapping() {
99 - let mut cursor = Cursor::new();
100 - cursor.resize(3);
101 - cursor.prev();
102 - assert_eq!(cursor.selected(), Some(0), "no wrap to the end from the top");
103 - cursor.move_by(99);
104 - assert_eq!(cursor.selected(), Some(2), "no wrap to the top from the end");
105 - }
106 -
107 - // The case this type exists for: a refresh returns fewer rows than the
108 - // cursor is sitting on.
109 - #[test]
110 - fn resize_pulls_the_selection_into_range() {
111 - let mut cursor = Cursor::new();
112 - cursor.resize(5);
113 - cursor.move_by(4);
114 - cursor.resize(2);
115 - assert_eq!(cursor.selected(), Some(1), "clamped to the new last row");
116 - }
117 -
118 - // A list emptying entirely must report no selection rather than row 0.
119 - #[test]
120 - fn resize_to_empty_clears_the_selection() {
121 - let mut cursor = Cursor::new();
122 - cursor.resize(4);
123 - cursor.move_by(3);
124 - cursor.resize(0);
125 - assert_eq!(cursor.selected(), None);
126 - assert!(cursor.is_empty());
127 - }
128 -
129 - // A growing list must not move the cursor; the row under it is still the
130 - // row the user selected.
131 - #[test]
132 - fn growing_the_list_leaves_the_selection_alone() {
133 - let mut cursor = Cursor::new();
134 - cursor.resize(3);
135 - cursor.move_by(1);
136 - cursor.resize(10);
137 - assert_eq!(cursor.selected(), Some(1));
138 - }
139 -
140 - // isize::MIN has no positive counterpart; negating it panics in debug.
141 - #[test]
142 - fn extreme_negative_delta_saturates_rather_than_overflowing() {
143 - let mut cursor = Cursor::new();
144 - cursor.resize(3);
145 - cursor.move_by(2);
146 - cursor.move_by(isize::MIN);
147 - assert_eq!(cursor.selected(), Some(0));
148 - }
149 - }
@@ -1,103 +1,0 @@
1 - //! The focus ring — the one piece ratatui does not provide.
2 - //!
3 - //! Rendering is immediate-mode, but input is event-driven, so something has to
4 - //! remember which pane the next keystroke belongs to. Per
5 - //! docs/COMPONENT-LIBRARY.md that model lives here, and it is deliberately
6 - //! minimal: an index into a fixed number of focusable slots, wrapping in both
7 - //! directions. Apps map their own pane enum onto the index.
8 -
9 - /// A wrapping cursor over `len` focusable slots.
10 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 - pub struct FocusRing {
12 - len: usize,
13 - index: usize,
14 - }
15 -
16 - impl FocusRing {
17 - /// A ring over `len` slots, focused on the first.
18 - ///
19 - /// A zero-length ring is legal and inert: `current()` reports 0 and the
20 - /// movers do nothing, so a view that has not yet loaded its panes does not
21 - /// have to special-case navigation.
22 - pub const fn new(len: usize) -> Self {
23 - Self { len, index: 0 }
24 - }
25 -
26 - pub const fn current(&self) -> usize {
27 - self.index
28 - }
29 -
30 - pub const fn is_focused(&self, slot: usize) -> bool {
31 - self.len > 0 && self.index == slot
32 - }
33 -
34 - pub const fn next(&mut self) {
35 - if self.len > 0 {
36 - self.index = (self.index + 1) % self.len;
37 - }
38 - }
39 -
40 - pub const fn prev(&mut self) {
41 - if self.len > 0 {
42 - self.index = (self.index + self.len - 1) % self.len;
43 - }
44 - }
45 -
46 - /// Focus a specific slot. Out-of-range indices are ignored rather than
47 - /// clamped — silently landing on a neighbouring pane is worse than not
48 - /// moving.
49 - pub const fn focus(&mut self, slot: usize) {
50 - if slot < self.len {
51 - self.index = slot;
52 - }
53 - }
54 -
55 - /// Resize the ring, keeping focus in range. Used when a view's pane count
56 - /// changes (a detail pane appearing, a section collapsing).
57 - pub const fn resize(&mut self, len: usize) {
58 - self.len = len;
59 - if self.index >= len {
60 - self.index = if len == 0 { 0 } else { len - 1 };
61 - }
62 - }
63 - }
64 -
65 - #[cfg(test)]
66 - mod tests {
67 - use super::*;
68 -
69 - #[test]
70 - fn wraps_in_both_directions() {
71 - let mut ring = FocusRing::new(3);
72 - ring.prev();
73 - assert_eq!(ring.current(), 2, "prev from the first slot wraps to the last");
74 - ring.next();
75 - assert_eq!(ring.current(), 0, "next from the last slot wraps to the first");
76 - }
77 -
78 - // An empty ring is what a view has before its panes load. The movers must be
79 - // no-ops rather than panicking on a modulo by zero.
80 - #[test]
81 - fn empty_ring_is_inert() {
82 - let mut ring = FocusRing::new(0);
83 - ring.next();
84 - ring.prev();
85 - assert_eq!(ring.current(), 0);
86 - assert!(!ring.is_focused(0), "nothing is focused when there are no slots");
87 - }
88 -
89 - #[test]
90 - fn resize_pulls_focus_back_into_range() {
91 - let mut ring = FocusRing::new(4);
92 - ring.focus(3);
93 - ring.resize(2);
94 - assert_eq!(ring.current(), 1, "focus clamps to the new last slot");
95 - }
96 -
97 - #[test]
98 - fn focus_ignores_out_of_range_slots() {
99 - let mut ring = FocusRing::new(2);
100 - ring.focus(5);
101 - assert_eq!(ring.current(), 0, "an out-of-range focus leaves the ring where it was");
102 - }
103 - }
@@ -1,132 +1,0 @@
1 - //! Reserved keymap: the constants every Alloy TUI navigates by, and the
2 - //! classifier that turns a raw key event into one of them.
3 - //!
4 - //! Per docs/COMPONENT-LIBRARY.md the reserved keys live in exactly one place so
5 - //! apps match against `Action` rather than hardcoding keycodes: `Tab` /
6 - //! `Shift-Tab` move focus, `l` / `h` move between tabs, `Enter` activates,
7 - //! `Esc` cancels, `Ctrl-S` saves, `q` quits, `?` opens help, `/` filters, `:`
8 - //! opens the command line.
9 - //!
10 - //! Tabs get `l` / `h` rather than `Tab` because `Tab` already means focus and
11 - //! that meaning is documented across every view. Two navigation axes need two
12 - //! keys, and the vim pair reads as horizontal movement, which is what a tab bar
13 - //! is.
14 - //!
15 - //! Descended from sysop-tui's `keys.rs`, widened from that crate's six actions
16 - //! to the full reserved set the console's form surfaces need.
17 -
18 - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
19 -
20 - /// A reserved key's meaning. `Passthrough` means the key is not reserved and
21 - /// belongs to whatever view currently holds focus.
22 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
23 - pub enum Action {
24 - NextFocus,
25 - PrevFocus,
26 - NextTab,
27 - PrevTab,
28 - Activate,
29 - Cancel,
30 - Save,
31 - Help,
32 - Quit,
33 - Filter,
34 - Command,
35 - Passthrough,
36 - }
37 -
38 - /// Classify a key event against the reserved keymap.
39 - ///
40 - /// This is a pure classifier with no notion of mode: it reports what a key
41 - /// *means* in the reserved map, not whether the app should honor it. Two
42 - /// caller obligations follow from that:
43 - ///
44 - /// - **Filter to `KeyEventKind::Press` first.** Windows terminals deliver both
45 - /// press and release for every key, so an unfiltered event loop performs
46 - /// each action twice.
47 - /// - **Ignore the character actions while text entry has focus.** `q`, `/`,
48 - /// `:`, `h`, and `l` are literal characters a user types into a field; a view
49 - /// holding an active text input should route keys to the input and consult
50 - /// this classifier only for `Cancel`, `Save`, and the focus movers.
51 - ///
52 - /// `h` and `l` make this obligation sharper than it was. The earlier
53 - /// character actions were punctuation and one letter that rarely opens a
54 - /// word; `h` and `l` are ordinary letters that appear in almost any typed
55 - /// value, so a view that forwards raw keys to an input without this check
56 - /// now changes tabs mid-word rather than merely on a stray `q`.
57 - pub fn classify(key: KeyEvent) -> Action {
58 - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
59 - let shift = key.modifiers.contains(KeyModifiers::SHIFT);
60 -
61 - match key.code {
62 - KeyCode::Char('s') | KeyCode::Char('S') if ctrl => Action::Save,
63 - KeyCode::BackTab => Action::PrevFocus,
64 - KeyCode::Tab if shift => Action::PrevFocus,
65 - KeyCode::Tab => Action::NextFocus,
66 - KeyCode::Char('l') => Action::NextTab,
67 - KeyCode::Char('h') => Action::PrevTab,
68 - KeyCode::Enter => Action::Activate,
69 - KeyCode::Esc => Action::Cancel,
70 - KeyCode::Char('?') => Action::Help,
71 - KeyCode::Char('q') => Action::Quit,
72 - KeyCode::Char('/') => Action::Filter,
73 - KeyCode::Char(':') => Action::Command,
74 - _ => Action::Passthrough,
75 - }
76 - }
77 -
78 - #[cfg(test)]
79 - mod tests {
80 - use super::*;
81 -
82 - fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
83 - KeyEvent::new(code, mods)
84 - }
85 -
86 - #[test]
87 - fn ctrl_s_saves_but_bare_s_does_not() {
88 - assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::CONTROL)), Action::Save);
89 - assert_eq!(classify(key(KeyCode::Char('s'), KeyModifiers::NONE)), Action::Passthrough);
90 - }
91 -
92 - // Terminals disagree on how they report Shift-Tab: some send BackTab with no
93 - // modifier, others send Tab with SHIFT. Both must reach PrevFocus, or focus
94 - // navigation silently becomes one-directional on half the terminal emulators
95 - // in the stack.
96 - #[test]
97 - fn both_shift_tab_encodings_move_focus_backward() {
98 - assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus);
99 - assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::SHIFT)), Action::PrevFocus);
100 - assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::SHIFT)), Action::PrevFocus);
101 - assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus);
102 - }
103 -
104 - #[test]
105 - fn unreserved_keys_pass_through() {
106 - assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough);
107 - assert_eq!(classify(key(KeyCode::Down, KeyModifiers::NONE)), Action::Passthrough);
108 - }
109 -
110 - #[test]
111 - fn h_and_l_move_between_tabs() {
112 - assert_eq!(classify(key(KeyCode::Char('l'), KeyModifiers::NONE)), Action::NextTab);
113 - assert_eq!(classify(key(KeyCode::Char('h'), KeyModifiers::NONE)), Action::PrevTab);
114 - }
115 -
116 - // Tabs and focus are two navigation axes and must stay on separate keys.
117 - // docs/COMPONENT-LIBRARY.md documents Tab as focus movement across every
118 - // view, so a tab bar claiming Tab would silently redefine it everywhere.
119 - #[test]
120 - fn tab_key_still_means_focus_not_tabs() {
121 - assert_eq!(classify(key(KeyCode::Tab, KeyModifiers::NONE)), Action::NextFocus);
122 - assert_eq!(classify(key(KeyCode::BackTab, KeyModifiers::NONE)), Action::PrevFocus);
123 - }
124 -
125 - // j/k stay unreserved so a list cursor keeps them. Only the horizontal half
126 - // of the vim pair is spoken for.
127 - #[test]
128 - fn vertical_vim_keys_are_not_claimed_by_tabs() {
129 - assert_eq!(classify(key(KeyCode::Char('j'), KeyModifiers::NONE)), Action::Passthrough);
130 - assert_eq!(classify(key(KeyCode::Char('k'), KeyModifiers::NONE)), Action::Passthrough);
131 - }
132 - }
@@ -1,196 +1,0 @@
1 - //! The console frame: body, command-log pane, footer.
2 - //!
3 - //! sysop-tui split a screen into body plus a one-row footer. Alloy adds the
4 - //! command-log pane between them, which docs/CONSOLE.md settled as always-on —
5 - //! the "console teaches its own primitives" claim only lands if the log of
6 - //! underlying CLI invocations is visible without being asked for.
7 -
8 - use ratatui::layout::{Constraint, Layout, Rect};
9 -
10 - /// Default height of the command-log pane, borders included: a two-row window
11 - /// onto the log plus its box. Enough to show the command just run and the one
12 - /// before it, which is what makes the pane read as a running transcript rather
13 - /// than a status line.
14 - pub const LOG_HEIGHT: u16 = 4;
15 -
16 - /// Below this total height the log pane is dropped entirely. The body needs
17 - /// room to be worth drawing; on a short terminal the log is the first thing
18 - /// that should go, and it degrades to nothing rather than to a sliver of
19 - /// borders with no content between them.
20 - const MIN_HEIGHT_FOR_LOG: u16 = 12;
21 -
22 - /// The three regions of a console screen. `log` is empty (zero height) when
23 - /// the terminal is too short to carry it — callers can render into it
24 - /// unconditionally, since ratatui clips a zero-area render.
25 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
26 - pub struct ConsoleAreas {
27 - pub body: Rect,
28 - pub log: Rect,
29 - pub footer: Rect,
30 - }
31 -
32 - /// Split a full-screen area into body, log pane, and footer.
33 - pub fn console(area: Rect) -> ConsoleAreas {
34 - console_with_log_height(area, LOG_HEIGHT)
35 - }
36 -
37 - /// As [`console`], with an explicit log-pane height.
38 - pub fn console_with_log_height(area: Rect, log_height: u16) -> ConsoleAreas {
39 - if area.height < MIN_HEIGHT_FOR_LOG || log_height == 0 {
40 - let [body, footer] =
41 - Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area);
42 - return ConsoleAreas {
43 - body,
44 - log: Rect { height: 0, ..body },
45 - footer,
46 - };
47 - }
48 -
49 - let [body, log, footer] = Layout::vertical([
50 - Constraint::Min(0),
51 - Constraint::Length(log_height),
52 - Constraint::Length(1),
53 - ])
54 - .areas(area);
55 -
56 - ConsoleAreas { body, log, footer }
57 - }
58 -
59 - /// Width of the gutter between two linked panes. Three columns is the minimum
60 - /// an elbow needs: one to leave the left pane, one to carry the vertical run,
61 - /// one to enter the right pane.
62 - pub const GUTTER_WIDTH: u16 = 3;
63 -
64 - /// Center a `width` x `height` box inside `area`, for a modal drawn over a
65 - /// view.
66 - ///
67 - /// Clamps rather than overflowing: a box larger than the area it sits in
68 - /// becomes the area. A modal that renders partly offscreen is worse than a
69 - /// cramped one, because the keys that dismiss it are listed on its last row.
70 - pub fn centered(area: Rect, width: u16, height: u16) -> Rect {
71 - let width = width.min(area.width);
72 - let height = height.min(area.height);
73 - Rect {
74 - x: area.x + (area.width - width) / 2,
75 - y: area.y + (area.height - height) / 2,
76 - width,
77 - height,
78 - }
79 - }
80 -
81 - /// Two panes with a connector gutter between them.
82 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
83 - pub struct PaneAreas {
84 - pub left: Rect,
85 - pub gutter: Rect,
86 - pub right: Rect,
87 - }
88 -
89 - /// Split a body area into two panes separated by a connector gutter.
90 - ///
91 - /// The split is even, with any odd column going to the left pane. Below the
92 - /// width needed for two usable panes the gutter collapses and the right pane
93 - /// takes zero width; callers render into it unconditionally, since ratatui
94 - /// clips a zero-area render, and a view that wants different narrow-terminal
95 - /// behavior can check `right.width`.
96 - pub fn panes(area: Rect) -> PaneAreas {
97 - // Two panes of at least this width each, plus the gutter, or the split is
98 - // not worth making: below it a pane is too narrow to hold a label.
99 - const MIN_PANE_WIDTH: u16 = 16;
100 -
101 - if area.width < MIN_PANE_WIDTH * 2 + GUTTER_WIDTH {
102 - return PaneAreas {
103 - left: area,
104 - gutter: Rect { width: 0, ..area },
105 - right: Rect { width: 0, ..area },
106 - };
107 - }
108 -
109 - let usable = area.width - GUTTER_WIDTH;
110 - let left_width = usable - usable / 2;
111 -
112 - PaneAreas {
113 - left: Rect { width: left_width, ..area },
114 - gutter: Rect {
115 - x: area.x + left_width,
116 - width: GUTTER_WIDTH,
117 - ..area
118 - },
119 - right: Rect {
120 - x: area.x + left_width + GUTTER_WIDTH,
121 - width: usable / 2,
122 - ..area
123 - },
124 - }
125 - }
126 -
127 - #[cfg(test)]
128 - mod tests {
129 - use super::*;
130 -
131 - #[test]
132 - fn panes_tile_the_area_exactly() {
133 - let areas = panes(Rect::new(0, 0, 80, 20));
134 - assert_eq!(areas.left.x, 0);
135 - assert_eq!(areas.gutter.x, areas.left.x + areas.left.width);
136 - assert_eq!(areas.right.x, areas.gutter.x + areas.gutter.width);
137 - assert_eq!(
138 - areas.left.width + areas.gutter.width + areas.right.width,
139 - 80,
140 - "no column is unaccounted for"
141 - );
142 - assert_eq!(areas.gutter.width, GUTTER_WIDTH);
143 - }
144 -
145 - // An odd usable width cannot split evenly; the extra column has to go
146 - // somewhere deterministic rather than being dropped.
147 - #[test]
148 - fn odd_widths_give_the_extra_column_to_the_left() {
149 - let areas = panes(Rect::new(0, 0, 81, 20));
150 - assert_eq!(areas.left.width, 39);
151 - assert_eq!(areas.right.width, 39);
152 - assert_eq!(areas.left.width + areas.gutter.width + areas.right.width, 81);
153 - }
154 -
155 - // A narrow terminal collapses to one pane rather than two unusable slivers.
156 - #[test]
157 - fn narrow_area_collapses_to_a_single_pane() {
158 - let areas = panes(Rect::new(0, 0, 30, 20));
159 - assert_eq!(areas.left.width, 30);
160 - assert_eq!(areas.right.width, 0);
161 - assert_eq!(areas.gutter.width, 0);
162 - }
163 -
164 - #[test]
165 - fn full_height_gets_all_three_regions() {
166 - let areas = console(Rect::new(0, 0, 80, 24));
167 - assert_eq!(areas.body.height, 24 - LOG_HEIGHT - 1);
168 - assert_eq!(areas.log.height, LOG_HEIGHT);
169 - assert_eq!(areas.footer.height, 1);
170 - assert_eq!(
171 - areas.body.height + areas.log.height + areas.footer.height,
172 - 24,
173 - "the three regions must tile the screen exactly, with no dead row"
174 - );
175 - }
176 -
177 - // A short terminal drops the log rather than starving the body. Without this
178 - // the layout hands the body 1-2 rows and spends the rest on log borders.
179 - #[test]
180 - fn short_terminal_drops_the_log_pane() {
181 - let areas = console(Rect::new(0, 0, 80, 10));
182 - assert_eq!(areas.log.height, 0, "log is dropped below the minimum height");
183 - assert_eq!(areas.body.height, 9);
184 - assert_eq!(areas.footer.height, 1);
185 - }
186 -
187 - // The degenerate case: a terminal so short there is only the footer. This
188 - // must not panic or produce a negative-height body.
189 - #[test]
190 - fn single_row_terminal_yields_footer_only() {
191 - let areas = console(Rect::new(0, 0, 80, 1));
192 - assert_eq!(areas.body.height, 0);
193 - assert_eq!(areas.log.height, 0);
194 - assert_eq!(areas.footer.height, 1);
195 - }
196 - }
@@ -1,36 +1,0 @@
1 - //! Alloy design system for ratatui.
2 - //!
3 - //! Consumes makeover `.toml` themes (the same schema every make-family app
4 - //! reads) and renders them as ratatui `Color` / `Style`. Two Alloy-specific
5 - //! tokens (`border-subtle`, `border-strong`) are derived locally so theme
6 - //! files stay minimal — see `docs/TOKENS.md` for the storage format and
7 - //! derivation math, `docs/CONSOLE.md` for the widget roster this crate
8 - //! targets, and `docs/DESIGN-LANGUAGE.md` for the color-is-information rule
9 - //! enforced here: chrome is tinted-greyscale, accents live on text via
10 - //! `Severity`.
11 - //!
12 - //! Beyond the widgets, the crate carries the two things ratatui leaves to the
13 - //! app and every Alloy TUI must agree on: the reserved keymap ([`keys`]) and
14 - //! the focus ring ([`focus`]). Both descend from mountaineer-sysop's
15 - //! `sysop-tui`, retinted from a const palette to the runtime [`Theme`].
16 - //!
17 - //! <!-- wiki: alloy-console -->
18 -
19 - pub mod connector;
20 - pub mod cursor;
21 - pub mod focus;
22 - pub mod keys;
23 - pub mod layout;
24 - pub mod selection;
25 - pub mod text;
26 - pub mod theme;
27 - pub mod widgets;
28 -
29 - pub use connector::AlloyConnector;
30 - pub use cursor::Cursor;
31 - pub use focus::FocusRing;
32 - pub use keys::{Action, classify};
33 - pub use layout::{ConsoleAreas, PaneAreas, console, panes};
34 - pub use selection::{MARKER, selected_style};
35 - pub use theme::{Mode, Theme, ThemeError};
36 - pub use widgets::*;
@@ -1,36 +1,0 @@
1 - //! Selection chrome: the marker glyph and the selected-row style.
2 - //!
3 - //! Ported from sysop-tui's `selection.rs`, retinted from the theme. The marker
4 - //! stays a plain triangle rather than a Nerd Font glyph so selection survives
5 - //! a console without the patched font — the TTY before the session starts, a
6 - //! remote shell, `alloy` over SSH. Per docs/ICONOGRAPHY.md, Nerd Font glyphs
7 - //! decorate; they never carry state on their own.
8 -
9 - use ratatui::style::{Modifier, Style};
10 -
11 - use crate::theme::Theme;
12 -
13 - /// The selected-row marker. Rendered in the gutter, one cell plus a space.
14 - pub const MARKER: &str = "▶";
15 -
16 - /// Blank gutter for unselected rows — the same width as [`MARKER`], so rows do
17 - /// not shift horizontally as selection moves.
18 - pub const MARKER_BLANK: &str = " ";
19 -
20 - /// Style for the selected row.
21 - ///
22 - /// Uses `surface.raised` as the selection field rather than an accent fill:
23 - /// DESIGN-LANGUAGE.md keeps color off chrome, so selection reads as a *raised*
24 - /// surface plus weight, and any `Severity` color already on the row survives
25 - /// unchanged instead of being drowned by an accent background.
26 - pub fn selected_style(theme: &Theme) -> Style {
27 - Style::default()
28 - .bg(theme.surface_raised)
29 - .fg(theme.content_primary)
30 - .add_modifier(Modifier::BOLD)
31 - }
32 -
33 - /// Style for an unselected row.
34 - pub fn unselected_style(theme: &Theme) -> Style {
35 - Style::default().fg(theme.content_secondary)
36 - }
@@ -1,42 +1,0 @@
1 - //! Themed span helpers.
2 - //!
3 - //! sysop-tui's `style.rs` offered the same set (`fg`, `dim`, `bold`, `ok`,
4 - //! `warn`, `fault`) against a const palette. Alloy's palette is loaded at
5 - //! runtime, so each helper takes the `&Theme` instead. The status-colored
6 - //! variants are gone: they collapse into [`Severity`](crate::Severity), which
7 - //! already owns the mapping from intent to `status.*` color.
8 -
9 - use ratatui::style::{Modifier, Style};
10 - use ratatui::text::Span;
11 -
12 - use crate::theme::Theme;
13 -
14 - /// Body text in the primary content color.
15 - pub fn primary(theme: &Theme, s: impl Into<String>) -> Span<'static> {
16 - Span::styled(s.into(), Style::default().fg(theme.content_primary))
17 - }
18 -
19 - /// De-emphasized text — labels, units, inactive rows.
20 - pub fn muted(theme: &Theme, s: impl Into<String>) -> Span<'static> {
21 - Span::styled(s.into(), Style::default().fg(theme.content_muted))
22 - }
23 -
24 - /// Supporting text: dimmer than primary, louder than muted.
25 - pub fn secondary(theme: &Theme, s: impl Into<String>) -> Span<'static> {
26 - Span::styled(s.into(), Style::default().fg(theme.content_secondary))
27 - }
28 -
29 - /// Emphasized body text.
30 - pub fn bold(theme: &Theme, s: impl Into<String>) -> Span<'static> {
31 - Span::styled(
32 - s.into(),
33 - Style::default().fg(theme.content_primary).add_modifier(Modifier::BOLD),
34 - )
35 - }
36 -
37 - /// A key hint or other interactive affordance. The one accent-on-text use that
38 - /// is not a `Severity` — DESIGN-LANGUAGE.md allows the action color here
39 - /// because a key hint *is* the actionable element, not decoration.
40 - pub fn action(theme: &Theme, s: impl Into<String>) -> Span<'static> {
41 - Span::styled(s.into(), Style::default().fg(theme.action_primary))
42 - }
@@ -1,196 +1,0 @@
1 - //! Theme palette: makeover intents resolved into ratatui `Color`s, plus
2 - //! the two Alloy-derived border tokens.
3 - //!
4 - //! Per docs/TOKENS.md, Alloy consumes makeover `.toml` files (the same
5 - //! schema every make-family app already reads) and derives two extra tokens
6 - //! locally so theme files stay minimal and cross-app compatible:
7 - //!
8 - //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider
9 - //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection
10 - //!
11 - //! Mix is in linear sRGB, matching TOKENS.md's worked audit math.
12 - //!
13 - //! ratatui is immediate-mode with per-widget styling — there is no global
14 - //! visuals object. Widgets in this crate take a `&Theme` at construction time
15 - //! and pull colors from it. Apps build one `Theme` per theme load (via
16 - //! `makeover::load_theme` + `Theme::from_theme`) and thread it through.
17 -
18 - use ratatui::style::Color;
19 - use makeover::{Rgb, ThemeColors};
20 -
21 - #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 - pub enum Mode {
23 - Light,
24 - Dark,
25 - HighContrast,
26 - }
27 -
28 - #[derive(Debug, Clone, Copy)]
29 - pub struct Theme {
30 - pub mode: Mode,
31 -
32 - pub surface_page: Color,
33 - pub surface_raised: Color,
34 - pub surface_sunken: Color,
35 - pub surface_overlay: Color,
36 -
37 - pub content_primary: Color,
38 - pub content_secondary: Color,
39 - pub content_muted: Color,
40 -
41 - pub action_primary: Color,
42 -
43 - pub status_danger: Color,
44 - pub status_success: Color,
45 - pub status_warning: Color,
46 - pub status_info: Color,
47 -
48 - pub line_border: Color,
49 - pub border_subtle: Color,
50 - pub border_strong: Color,
51 -
52 - pub category: [Color; 6],
53 - }
54 -
55 - #[derive(Debug, Clone)]
56 - pub enum ThemeError {
57 - MissingKey(&'static str),
58 - InvalidHex { key: &'static str, value: String },
59 - }
60 -
61 - impl std::fmt::Display for ThemeError {
62 - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 - match self {
64 - ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
65 - ThemeError::InvalidHex { key, value } => {
66 - write!(f, "theme key `{key}` has invalid hex value `{value}`")
67 - }
68 - }
69 - }
70 - }
71 -
72 - impl std::error::Error for ThemeError {}
73 -
74 - impl Theme {
75 - /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`.
76 - /// Requires every intent Alloy renders — a malformed or partial theme is
77 - /// rejected explicitly rather than silently rendering with defaults.
78 - pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
79 - let get = |key: &'static str| -> Result<Rgb, ThemeError> {
80 - let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
81 - Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
82 - key,
83 - value: hex.clone(),
84 - })
85 - };
86 -
87 - let surface_page = get("surface.page")?;
88 - let content_primary = get("content.primary")?;
89 - let line_border = get("line.border")?;
90 -
91 - let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60);
92 - let border_strong = mix_linear_srgb(line_border, content_primary, 0.65);
93 -
94 - let mode = match theme.meta.variant.as_str() {
95 - "dark" => Mode::Dark,
96 - "high-contrast" => Mode::HighContrast,
97 - _ => Mode::Light,
98 - };
99 -
100 - Ok(Self {
101 - mode,
102 -
103 - surface_page: rgb(surface_page),
104 - surface_raised: rgb(get("surface.raised")?),
105 - surface_sunken: rgb(get("surface.sunken")?),
106 - surface_overlay: rgb(get("surface.overlay")?),
107 -
108 - content_primary: rgb(content_primary),
109 - content_secondary: rgb(get("content.secondary")?),
110 - content_muted: rgb(get("content.muted")?),
111 -
112 - action_primary: rgb(get("action.primary")?),
113 -
114 - status_danger: rgb(get("status.danger")?),
115 - status_success: rgb(get("status.success")?),
116 - status_warning: rgb(get("status.warning")?),
117 - status_info: rgb(get("status.info")?),
118 -
119 - line_border: rgb(line_border),
120 - border_subtle: rgb(border_subtle),
121 - border_strong: rgb(border_strong),
122 -
123 - category: [
124 - rgb(get("category.one")?),
125 - rgb(get("category.two")?),
126 - rgb(get("category.three")?),
127 - rgb(get("category.four")?),
128 - rgb(get("category.five")?),
129 - rgb(get("category.six")?),
130 - ],
131 - })
132 - }
133 - }
134 -
135 - fn rgb(c: Rgb) -> Color {
136 - Color::Rgb(c.r, c.g, c.b)
137 - }
138 -
139 - // Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
140 - // gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
141 - // less uniform than OKLab but keeps the derived hex reproducible against the
142 - // contrast tables in TOKENS.md.
143 - fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
144 - let al = srgb_to_linear(a);
145 - let bl = srgb_to_linear(b);
146 - let m = (
147 - al.0 + (bl.0 - al.0) * t,
148 - al.1 + (bl.1 - al.1) * t,
149 - al.2 + (bl.2 - al.2) * t,
150 - );
151 - linear_to_srgb(m)
152 - }
153 -
154 - fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) {
155 - (channel_to_linear(c.r), channel_to_linear(c.g), channel_to_linear(c.b))
156 - }
157 -
158 - fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb {
159 - Rgb {
160 - r: channel_to_srgb(c.0),
161 - g: channel_to_srgb(c.1),
162 - b: channel_to_srgb(c.2),
163 - }
164 - }
165 -
166 - fn channel_to_linear(c: u8) -> f32 {
167 - let c = c as f32 / 255.0;
168 - if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
169 - }
170 -
171 - fn channel_to_srgb(c: f32) -> u8 {
172 - let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 };
173 - (v * 255.0).round().clamp(0.0, 255.0) as u8
174 - }
175 -
176 - #[cfg(test)]
177 - mod tests {
178 - use super::*;
179 -
180 - // TOKENS.md line 61 anchors the derivation math against Akari Dawn:
181 - // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary
182 - // must produce #7f786d (the value the contrast-audit table is calibrated on).
183 - // If this test fails, the audit table in TOKENS.md is stale, not the code.
184 - #[test]
185 - fn akari_dawn_border_strong_matches_tokens_md() {
186 - let border = Rgb::from_hex("#cabeae").unwrap();
187 - let primary = Rgb::from_hex("#1a1816").unwrap();
188 - let got = mix_linear_srgb(border, primary, 0.65);
189 - assert_eq!(
190 - (got.r, got.g, got.b),
191 - (0x7f, 0x78, 0x6d),
192 - "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d",
193 - got.r, got.g, got.b
194 - );
195 - }
196 - }