jevcrates.git / jev-ui / src / components.rs

The parts every Jev page is built from: the nav, the headline, the window, the pink band, the footer and the copy line. They return markup and know nothing of a site's content.

4use maud::{Markup, html};

A 12 by 12 pixel icon: # for ink, anything else for paper.

7pub type Icon = [&'static str; 12];

The icons lmjtfy's title bars use; a site adds its own.

10pub mod icons {
11    use super::Icon;
12
13    pub const ASK: Icon = [
14        "............", "..#####.....", ".#.....#....", ".#.....#....", ".#.....#....", ".#.....#....", "..#####.....", ".......##...",
15        "........##..", ".........##.", "..........#.", "............",
16    ];
17    pub const JEV: Icon = [
18        ".....##.....", "....#.##....", "...#.#.##...", "..#.#.#.##..", ".#.#.#.#.##.", "#.#.#.#.####", "####.#.#.#.#", ".##.#.#.#.#.",
19        "..##.#.#.#..", "...##.#.#...", "....##.#....", ".....##.....",
20    ];
21    pub const LLM: Icon = [
22        "............", ".#########..", ".#.......#..", ".#.##.##.#..", ".#.......#..", ".#.#####.#..", ".#.......#..", ".#########..",
23        "...##.......", "..##........", ".#..........", "............",
24    ];
25    pub const TOOL: Icon = [
26        "............", ".......###..", "......##....", "......##..#.", "......#####.", ".....####...", "....###.....", "...###......",
27        "..###.......", ".###........", ".##.........", "............",
28    ];
29}

A pixel icon as inline SVG: one square per #. class is added after icon (big for the framed one in a footer).

33pub fn pixel_icon(rows: &Icon, class: &str) -> Markup {
34    html! {
35        svg class={ "icon " (class) } viewBox="0 0 12 12" aria-hidden="true" {
36            @for (y, row) in rows.iter().enumerate() {
37                @for (x, cell) in row.bytes().enumerate() {
38                    @if cell == b'#' { rect x=(x) y=(y) width="1" height="1" fill="currentColor" {} }
39                }
40            }
41        }
42    }
43}

A pixel emoji from SerenityOS's set, the four ui.css has a font for. A glyph the font lacks cannot be asked for: this is the list, and a test holds it to the stylesheet's unicode-range.

47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub enum PixelEmoji {

A grey disc with a white triangle: start.

50    Play,

A grey disc with two white bars: stop for now.

52    Pause,

Hourglass with the sand still in the top.

54    SandAtTop,

Hourglass with the sand run to the bottom.

56    SandAtBottom,
57}
59impl PixelEmoji {
60    pub const ALL: [PixelEmoji; 4] = [PixelEmoji::Play, PixelEmoji::Pause, PixelEmoji::SandAtTop, PixelEmoji::SandAtBottom];
61
62    pub const fn glyph(self) -> char {
63        match self {
64            PixelEmoji::Play => '\u{25B6}',
65            PixelEmoji::Pause => '\u{23F8}',
66            PixelEmoji::SandAtTop => '\u{23F3}',
67            PixelEmoji::SandAtBottom => '\u{231B}',
68        }
69    }
70}

A pixel emoji in the text. It is decoration: the words beside it say what it means, so it is hidden from a screen reader.

74pub fn pixel_emoji(emoji: PixelEmoji) -> Markup {
75    html! { span .pe aria-hidden="true" { (emoji.glyph()) } }
76}

An hourglass that turns over while something runs (ui.css: still for a reader who asks for less motion). Hidden from a screen reader; say in words that something is running.

80pub fn hourglass() -> Markup {
81    html! {
82        span .hourglass aria-hidden="true" {
83            span .pe.top { (PixelEmoji::SandAtTop.glyph()) }
84            span .pe.bottom { (PixelEmoji::SandAtBottom.glyph()) }
85        }
86    }
87}

A link in the nav.

90#[derive(Clone, Copy, Debug)]
91pub struct Link<'a> {
92    pub href: &'a str,
93    pub label: &'a str,
94}
96pub struct Nav<'a> {

What the nav is, for a screen reader.

98    pub label: &'a str,

The site's name, at the left.

100    pub brand: Link<'a>,

Anything the site puts between the name and the links (lmjtfy's online count).

102    pub middle: Markup,

The black links, together at the right.

104    pub links: &'a [Link<'a>],
105}
107pub fn nav(nav: &Nav<'_>) -> Markup {
108    html! {
109        nav aria-label=(nav.label) {
110            a href=(nav.brand.href) { (nav.brand.label) }
111            (nav.middle)
112            @for link in nav.links { a .dark href=(link.href) { (link.label) } }
113        }
114    }
115}

The big title with crop marks round it.

118pub fn headline(eyebrow: Option<&str>, title: &str) -> Markup {
119    html! {
120        div .headline {
121            @if let Some(eyebrow) = eyebrow { p .eyebrow { (eyebrow) } }
122            h1 { (title) }
123        }
124    }
125}

A window: a black title bar (an icon, a name, and meta at the right) over a framed body. title is markup so a section's window can make it its heading (h2, which the bar styles as its own text). white is a paper body; otherwise it is the grey of the window.

130pub fn window(icon: Option<&Icon>, title: Markup, meta: Option<Markup>, white: bool, body: Markup) -> Markup {
131    window_at(None, icon, title, meta, white, body)
132}

A window with an id, for a page that replaces it whole (Datastar morphs by id) or styles it by name. Otherwise as window.

136pub fn window_with_id(id: &str, icon: Option<&Icon>, title: Markup, meta: Option<Markup>, white: bool, body: Markup) -> Markup {
137    window_at(Some(id), icon, title, meta, white, body)
138}
140fn window_at(id: Option<&str>, icon: Option<&Icon>, title: Markup, meta: Option<Markup>, white: bool, body: Markup) -> Markup {
141    html! {
142        div id=[id] .win.white[white] {
143            div .bar-title {
144                @if let Some(icon) = icon { (pixel_icon(icon, "")) }
145                (title)
146                @if let Some(meta) = meta { span .meta { (meta) } }
147            }
148            div .body { (body) }
149        }
150    }
151}

A white section of the page: the content in a column.

154pub fn sheet(content: Markup) -> Markup {
155    html! { div .sheet { div .wrap { (content) } } }
156}

The full-width pink band, its content in the same column.

159pub fn band(content: Markup) -> Markup {
160    html! { div .band { div .wrap { (content) } } }
161}

A terminal: commands to run, shown as the top of a session, with a button that copies them (ui.js, data-copy). After typesafe.ai's: paper under a black title bar, a gutter with one square per command, and a bold $. id names the <code>, and must be the page's only one.

What is copied is the commands and nothing else, one to a line: the $ and the squares are drawn by the stylesheet, so they are in neither a copy nor a selection. A long command wraps under itself, never under the $.

170pub fn terminal(id: &str, title: &str, commands: &[&str]) -> Markup {
171    html! {
172        div .term {
173            div .term-bar {
174                span { (title) }
175                button .ghost type="button" data-copy=(id) { "Copy" }
176            }
177            pre .term-body {
178                code id=(id) {
179                    @for (index, command) in commands.iter().enumerate() {
180                        @if index > 0 { "\n" }
181                        span .term-line { (command) }
182                    }
183                }
184            }
185        }
186    }
187}

A command or link with a button that copies it (ui.js, data-copy). id names the <code>.

190pub fn copy_line(id: &str, label: Markup, command: &str) -> Markup {
191    html! {
192        div .share.clone {
193            span { (label) }
194            code id=(id) { (command) }
195            button .ghost type="button" data-copy=(id) { "Copy" }
196        }
197    }
198}

The foot of a page: what the site is, and optionally how to get its code.

201pub fn footer(icon: &Icon, about: Markup, clone: Option<Markup>) -> Markup {
202    html! {
203        footer {
204            div .wrap {
205                div .about {
206                    (pixel_icon(icon, "big"))
207                    span { (about) }
208                }
209                @if let Some(clone) = clone { (clone) }
210            }
211        }
212    }
213}

The link that lets a keyboard skip the nav.

216pub fn skip_link(target: &str, label: &str) -> Markup {
217    html! { a .skip href=(format!("#{target}")) { (label) } }
218}
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn an_icon_is_one_square_per_ink_pixel() {
226        let svg = pixel_icon(&icons::JEV, "big").into_string();
227        let ink: usize = icons::JEV.iter().map(|row| row.matches('#').count()).sum();
228        assert_eq!(svg.matches("<rect").count(), ink);
229        assert!(svg.contains("class=\"icon big\"") && svg.contains("aria-hidden=\"true\""));
230    }
231
232    #[test]
233    fn every_icon_is_twelve_by_twelve() {
234        for icon in [icons::ASK, icons::JEV, icons::LLM, icons::TOOL] {
235            assert!(icon.iter().all(|row| row.len() == 12));
236        }
237    }
238
239    #[test]
240    fn every_pixel_emoji_is_in_the_fonts_range_and_nothing_else_is() {
241        let css = crate::asset::CSS.text();
242        let range = css.split("unicode-range:").nth(1).unwrap().split(';').next().unwrap();
243        let listed: Vec<String> = range.split(',').map(|r| r.trim().to_owned()).collect();
244        assert_eq!(listed.len(), PixelEmoji::ALL.len(), "{range}");
245        for emoji in PixelEmoji::ALL {
246            assert!(listed.contains(&format!("U+{:X}", emoji.glyph() as u32)), "{emoji:?}");
247        }
248    }
249
250    #[test]
251    fn a_pixel_emoji_is_decoration_and_the_hourglass_has_both_halves_of_its_turn() {
252        assert_eq!(pixel_emoji(PixelEmoji::Play).into_string(), "<span class=\"pe\" aria-hidden=\"true\">\u{25B6}</span>");
253        let glass = hourglass().into_string();
254        assert!(glass.contains("aria-hidden=\"true\"") && glass.contains("pe top") && glass.contains("pe bottom"));
255        assert!(glass.contains('\u{23F3}') && glass.contains('\u{231B}'));
256    }
257
258    #[test]
259    fn the_nav_puts_the_name_first_and_the_dark_links_after() {
260        let links = [Link { href: "#a", label: "A" }, Link { href: "#b", label: "B" }];
261        let page = nav(&Nav { label: "On this page", brand: Link { href: "/", label: "Site" }, middle: html! { span #mid {} }, links: &links }).into_string();
262        let (brand, mid, a) = (page.find(">Site<").unwrap(), page.find("id=\"mid\"").unwrap(), page.find("class=\"dark\" href=\"#a\"").unwrap());
263        assert!(brand < mid && mid < a);
264        assert!(page.contains("aria-label=\"On this page\""));
265    }
266
267    #[test]
268    fn a_window_has_a_title_bar_over_a_body_and_white_is_a_class() {
269        let html = window(Some(&icons::ASK), html! { h2 { "Ask" } }, Some(html! { "meta" }), true, html! { p { "inside" } }).into_string();
270        assert!(html.starts_with("<div class=\"win white\">") && html.contains("<span class=\"meta\">meta</span>"));
271        assert!(html.contains("<h2>Ask</h2>"));
272        assert!(html.find("bar-title").unwrap() < html.find("class=\"body\"").unwrap());
273        assert!(window(None, html! { "x" }, None, false, html! {}).into_string().starts_with("<div class=\"win\">"));
274    }
275
276    #[test]
277    fn a_window_may_carry_an_id_and_has_none_otherwise() {
278        let named = window_with_id("budget", None, html! { "x" }, None, false, html! {}).into_string();
279        assert!(named.starts_with("<div class=\"win\" id=\"budget\">"), "{named}");
280        assert!(!window(None, html! { "x" }, None, false, html! {}).into_string().contains("id="));
281    }
282
283    #[test]
284    fn the_copy_button_names_the_text_it_copies() {
285        let html = copy_line("clone", html! { "Get it:" }, "git clone x").into_string();
286        assert!(html.contains("<code id=\"clone\">git clone x</code>") && html.contains("data-copy=\"clone\""));
287    }

What a visitor pastes is the commands, one to a line, with no prompt in front: the text of the element the copy button names is exactly that.

291    #[test]
292    fn a_terminal_copies_its_commands_and_nothing_else() {
293        let html = terminal("install", "Install", &["mise install", "mise run check"]).into_string();
294        assert!(html.contains("data-copy=\"install\""));
295        let code = html.split("<code id=\"install\">").nth(1).unwrap().split("</code>").next().unwrap();
296        let text = code.replace("<span class=\"term-line\">", "").replace("</span>", "");
297        assert_eq!(text, "mise install\nmise run check");
298        assert!(!html.contains('$'), "the prompt is the stylesheet's, so it is never copied");
299        assert!(terminal("x", "T", &["a <b> & 'c'"]).into_string().contains("a &lt;b&gt; &amp; 'c'"));
300    }
302    #[test]
303    fn text_is_escaped() {
304        let html = headline(Some("<i>"), "a & b").into_string();
305        assert!(html.contains("&lt;i&gt;") && html.contains("a &amp; b"));
306    }
307}