1//! The parts every Jev page is built from: the nav, the headline, the window, the pink band, the 2//! footer and the copy line. They return markup and know nothing of a site's content. 3 4use maud::{Markup, html}; 5 6/// A 12 by 12 pixel icon: `#` for ink, anything else for paper. 7pub type Icon = [&'static str; 12]; 8 9/// 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} 30 31/// A pixel icon as inline SVG: one square per `#`. `class` is added after `icon` (`big` for the 32/// 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} 44 45/// A pixel emoji from SerenityOS's set, the four `ui.css` has a font for. A glyph the font lacks 46/// 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 { 49 /// A grey disc with a white triangle: start. 50 Play, 51 /// A grey disc with two white bars: stop for now. 52 Pause, 53 /// Hourglass with the sand still in the top. 54 SandAtTop, 55 /// Hourglass with the sand run to the bottom. 56 SandAtBottom, 57} 58 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} 71 72/// A pixel emoji in the text. It is decoration: the words beside it say what it means, so it is 73/// hidden from a screen reader. 74pub fn pixel_emoji(emoji: PixelEmoji) -> Markup { 75 html! { span .pe aria-hidden="true" { (emoji.glyph()) } } 76} 77 78/// An hourglass that turns over while something runs (`ui.css`: still for a reader who asks for 79/// 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} 88 89/// 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} 95 96pub struct Nav<'a> { 97 /// What the nav is, for a screen reader. 98 pub label: &'a str, 99 /// The site's name, at the left. 100 pub brand: Link<'a>, 101 /// Anything the site puts between the name and the links (lmjtfy's online count). 102 pub middle: Markup, 103 /// The black links, together at the right. 104 pub links: &'a [Link<'a>], 105} 106 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} 116 117/// 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} 126 127/// A window: a black title bar (an icon, a name, and `meta` at the right) over a framed body. 128/// `title` is markup so a section's window can make it its heading (`h2`, which the bar styles 129/// 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} 133 134/// A window with an `id`, for a page that replaces it whole (Datastar morphs by id) or styles it by 135/// 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} 139 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} 152 153/// 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} 157 158/// 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} 162 163/// A terminal: commands to run, shown as the top of a session, with a button that copies them 164/// (`ui.js`, `data-copy`). After typesafe.ai's: paper under a black title bar, a gutter with one 165/// square per command, and a bold `$`. `id` names the `<code>`, and must be the page's only one. 166/// 167/// What is copied is the commands and nothing else, one to a line: the `$` and the squares are 168/// drawn by the stylesheet, so they are in neither a copy nor a selection. A long command wraps 169/// 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} 188 189/// 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} 199 200/// 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} 214 215/// 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} 219 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 } 288 289 /// What a visitor pastes is the commands, one to a line, with no prompt in front: the text of 290 /// 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 <b> & 'c'")); 300 } 301 302 #[test] 303 fn text_is_escaped() { 304 let html = headline(Some("<i>"), "a & b").into_string(); 305 assert!(html.contains("<i>") && html.contains("a & b")); 306 } 307}