Pages as pure functions: data in, HTML out. Nothing here fetches, reads the clock or knows a visitor. Everything a visitor can influence (a mood name) is checked before it reaches here, and maud escapes what is written in parentheses.
The look is jev-ui's, the same as lmjtfy.fun's: the shell, the nav, the windows, the band.
What is here is whiskers': the cat, the demo's parts, and the words.
8use cat::svg::{Mode, eyes_attrs, pointer_to_gaze_expr, svg}; 9use cat::{Mascot, Mood, scene}; 10use jev_ui::components::{Icon, Link, Nav, PixelEmoji, band, copy_line, footer, headline, hourglass, nav, pixel_emoji, sheet, skip_link, window}; 11use jev_ui::preview::{Origin, Picture, Preview, Video}; 12use jev_ui::shell::{Assets, Attr, Head, document}; 13use maud::{Markup, PreEscaped, html}; 14use site::config::{APP_BUTTON_ICON, APP_DOWNLOADS, CHECKSUMS_FILE, CLONE_URL, Download, Pic, SERVER_DOWNLOAD, release_url, DESCRIPTION, NAME, PREVIEW_HEIGHT, PREVIEW_IMAGE, PREVIEW_VIDEO, PREVIEW_WIDTH, TITLE}; 15use crate::explain; 16use crate::pic::{Row, facts, pic, pics}; 17use site::demo::{self, Pill, Shown, Stage, Step, TourControl, caption};
19const CSS: &str = include_str!("page.css");
The theme colour of the link preview's bar: the page's pink, as lmjtfy's.
22const THEME: &str = "#f386a1";
A cat's head, for the title bars and the foot: 12 by 12, # for ink (jev_ui::components).
Changes when the stylesheet does, so the long-cached file is fetched again.
34 pub css_version: &'a str,
The mascot, drawn for the page: motion by stylesheet, pupils bound to the pointer.
The idle cat as a still picture, for the browser tab.
53pub fn caption_html(mood: Mood) -> Markup { 54 let c = caption(mood); 55 html! { 56 // Read aloud as it changes, unless the tour is playing, which changes every few seconds. 57 div #caption .caption "data-attr:aria-live"="$tour ? 'off' : 'polite'" aria-live="polite" { 58 h3 { (c.heading) } 59 p { (c.line) } 60 } 61 } 62} 63 64pub fn pipeline_html(mood: Mood) -> Markup { 65 let busy = demo::busy_with(mood); 66 html! { 67 ol #pipeline .pipeline aria-label="What happens to a message" { 68 @for stage in Stage::ALL { 69 @let on = busy.contains(&stage); 70 li .on[on] { @if on { span .vh { "now: " } } (stage.label()) } 71 } 72 } 73 } 74}
What a mood button, the tour or the pointer changes on the page: the three parts, then the signals. Sent as Datastar events.
78pub fn patches(shown: Shown, mascot: Mascot) -> String { 79 use datastar::prelude::{PatchElements, PatchSignals}; 80 let mood = shown.mood(); 81 let elements = html! { (cat_svg(mascot, mood)) (caption_html(mood)) (pipeline_html(mood)) (moods_html(shown)) (tour_button(shown)) }; 82 let signals = serde_json::json!({ 83 "mascot": mascot.name(), 84 "mood": mood.name(), 85 "step": shown.step.index(), 86 "tour": shown.tour, 87 "held": 0, 88 "dwell": shown.step.dwell_secs(), 89 }); 90 format!( 91 "{}{}", 92 PatchElements::new(elements.into_string()).into_datastar_event(), 93 PatchSignals::new(signals.to_string()).into_datastar_event() 94 ) 95}
98fn preview(origin: &Origin) -> Preview<'_> { 99 Preview { 100 origin, 101 site_name: NAME, 102 title: TITLE, 103 description: DESCRIPTION, 104 page: "/", 105 picture: Picture { 106 path: PREVIEW_IMAGE, 107 query: None, 108 width: PREVIEW_WIDTH, 109 height: PREVIEW_HEIGHT, 110 alt: "A pixel cat in a window on a dotted background, with the word Whiskers", 111 }, 112 video: Some(Video { path: PREVIEW_VIDEO, width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }), 113 theme_color: THEME, 114 } 115}
The <meta> tags a pasted link unfurls from. Built from checked parts: an origin that is
not https cannot get here, and the files' names and sizes are the configuration's.
"Download for Android": a <details>, so it opens by tap or key with no script, and by hover
where the browser lets CSS do it (page.css). The rows are plain links to the code host. The
menu is in the flow of the page, never floated, so it cannot overflow a narrow screen.
135fn download_menu() -> Markup { 136 html! { 137 details .dl { 138 summary .dl-button { 139 (pic(APP_BUTTON_ICON)) 140 span { "Download for Android" } 141 span .fold aria-hidden="true" {} 142 } 143 div .dl-menu { 144 @for d in APP_DOWNLOADS.iter() { (download_row(d)) } 145 (download_row(&SERVER_DOWNLOAD)) 146 a .dl-sums href=(release_url(CHECKSUMS_FILE)) { (CHECKSUMS_FILE) } span .fine { " checks the downloads." } 147 } 148 } 149 } 150}
One mood's pill. Which one is pressed, and which one is playing, is the server's word
(demo::pill): the markup is replaced with every answer, so there are no client bindings to
disagree with it. A playing pill carries the hourglass and a bar that fills over the beat's
dwell; a new beat is a new element (its id names the beat), so the bar starts again.
156fn mood_pill(mood: Mood, pill: Pill) -> Markup { 157 let name = mood.name(); 158 let pressed = pill != Pill::Rest; 159 html! { 160 button #{ "mood-" (name) } .mood.on[pressed].playing[matches!(pill, Pill::Playing { .. })] type="button" aria-pressed=(pressed) 161 "data-on:click"={ "$mood = '" (name) "'; @post('/mood')" } { 162 (name) 163 @if let Pill::Playing { step, secs } = pill { 164 span .vh { " (playing)" } 165 span .chip { (hourglass()) } 166 // Four steps a second: a pixel countdown, not a smooth one. 167 span #{ "beat-" (step.index()) } .fill style={ "--secs:" (secs) "s;--ticks:" (secs * 4) } aria-hidden="true" {} 168 } 169 } 170 } 171}
Play or pause: the icon, the words and the accessible name all say what pressing it does.
182pub fn tour_button(shown: Shown) -> Markup { 183 let control = demo::tour_control(shown); 184 let icon = match control { 185 TourControl::Play => PixelEmoji::Play, 186 TourControl::Pause => PixelEmoji::Pause, 187 }; 188 html! { 189 button #tour .tour type="button" aria-label=(control.label()) "data-on:click"="@post('/tour')" { 190 (pixel_emoji(icon)) " " (control.label()) 191 } 192 } 193}
One button of the picker. Which is pressed is read from the $mascot signal, so choosing
needs no markup swapped in; the server answers with the new figure.
197fn mascot_button(mascot: Mascot, selected: Mascot) -> Markup { 198 let name = mascot.name(); 199 html! { 200 button .mascot type="button" aria-pressed=(mascot == selected) 201 "data-on:click"={ "$mascot = '" (name) "'; @post('/mascot')" } 202 "data-attr:aria-pressed"={ "String($mascot === '" (name) "')" } 203 "data-class:on"={ "$mascot === '" (name) "'" } { (mascot.label()) } 204 } 205}
207pub fn page(page: &Page) -> Markup { 208 let start = Mood::Idle; 209 // The first thing drawn: the cat resting, the tour not yet started (`data-init` asks for it). 210 let resting = Shown { step: Step::first(start), tour: false }; 211 let signals = serde_json::json!({ 212 "mascot": page.mascot.name(), "mood": start.name(), "step": Step::first(start).index(), "tour": false, "held": 0, 213 "dwell": Step::first(start).dwell_secs(), "gx": 0, "gy": 0 214 }); 215 let head = Head { 216 title: TITLE, 217 meta: html! { (PreEscaped(preview_tags(page.origin))) }, 218 style: CSS, 219 // The page's policy forbids inline script, so the shared script is a file. 220 assets: Assets::Linked, 221 datastar: true, 222 icon: Some("/favicon.svg"), 223 extra: html! { 224 link rel="canonical" href=(page.origin.url("/").expect("the root is a path")); 225 link rel="stylesheet" href={ "/cat.css?v=" (page.css_version) }; 226 }, 227 }; 228 let attrs = [ 229 Attr::new("data-signals", &signals.to_string()), 230 // The tour plays unless the visitor asked for less motion: the server is asked to start it, 231 // so the pill, the button and the signal all come from one answer. 232 Attr::new("data-init", "matchMedia('(prefers-reduced-motion: reduce)').matches || @post('/tour')"), 233 Attr::new("data-on-interval__duration.1s", "$tour && ++$held >= $dwell && @post('/tick')"), 234 ]; 235 let links = [ 236 Link { href: "#demo", label: "Meet the cat" }, 237 Link { href: "#safe", label: "Safety" }, 238 Link { href: "#jev", label: "Where Jev is used" }, 239 Link { href: "#how", label: "How it works" }, 240 Link { href: "#get", label: "Get it" }, 241 ]; 242 document(&head, &attrs, html! { 243 (skip_link("main", "Skip to the content")) 244 header { div .wrap { (nav(&Nav { label: "On this page", brand: Link { href: "/", label: NAME }, middle: html! {}, links: &links })) } } 245 main #main { 246 (sheet(html! { 247 (headline(Some("A talking cat companion for a young child"), NAME)) 248 div .stack { 249 (window(Some(&CAT), html! { "What it is" }, None, true, html! { 250 p .lede { "A talking cat companion for a young child. The child taps the cat, speaks, and hears a spoken answer. Parents see every conversation and set the limits." } 251 })) 252 div role="note" { 253 (window(Some(&CAT), html! { h2 { "A personal project, not a product" } }, None, true, html! { 254 p { 255 "Whiskers was built for one young child and one household. There is no company behind it, nothing to sign up for, no support, and no promise it will work for you or keep working. " 256 "You are welcome to read it, run it, and learn from it. Its safety checks were tried on sentences their author wrote, not on real children\u{2019}s speech: they are not a substitute for a grown-up." 257 } 258 })) 259 } 260 section #demo aria-labelledby="demo-h" { 261 (window(Some(&CAT), html! { h2 #demo-h { "Meet the cat" } }, None, false, html! { 262 p .fine { "This is the cat, moving by the same rules as on the tablet. It is a scripted demo: no model and no check is running behind it. Move your pointer and its eyes follow." } 263 div .demo { 264 figure #stage .stage 265 "data-on:pointermove__window__throttle.40ms"=(pointer_to_gaze_expr()) { 266 (cat_svg(page.mascot, start)) 267 figcaption .vh { "The mascot, drawn on a 15 by 16 pixel grid." } 268 } 269 div .side { 270 div .mascots role="group" aria-label="Choose who to meet" { 271 @for &mascot in Mascot::ALL { (mascot_button(mascot, page.mascot)) } 272 } 273 (caption_html(start)) 274 (pipeline_html(start)) 275 (moods_html(resting)) 276 (tour_button(resting)) 277 } 278 } 279 noscript { p .fine { "The demo needs JavaScript. Everything else on this page does not." } } 280 })) 281 } 282 } 283 })) 284 (band(html! { 285 section #safe aria-labelledby="safe-h" { 286 (window(Some(&CAT), html! { h2 #safe-h { "How it keeps a child safe" } }, None, true, html! { 287 ul .points { 288 li { 289 strong { "Every message is checked by Jev before the child sees or hears it. " } 290 "Jev is TypeSafe\u{2019}s System One model: it never writes, it makes typed decisions, here allow or refuse, with a probability you can see. What the child says is checked, and what Whiskers would answer is checked again. " 291 a href="https://docs.typesafe.ai" { "About Jev" } "." 292 } 293 li { 294 strong { "When the check cannot be made, the answer is no. " } 295 "A failed or throttled check counts as a refusal, never as permission." 296 } 297 li { 298 strong { "Everything is logged. " } 299 "The log is written before each step. Parents read every conversation, are shown first anything where the child says they are hurt or is asked to keep a secret, and get a summary of the day." 300 } 301 li { 302 strong { "A failure ends in a kind, spoken sentence. " } 303 "If the model, the check or the network fails, Whiskers says a fixed, safe line aloud. It never goes silent and never reads out an error." 304 } 305 li { 306 strong { "One Whiskers across every device. " } 307 "The child\u{2019}s tablet and the parents\u{2019} phones are one cat with one memory and one set of limits, so a rule set in one place holds everywhere." 308 } 309 li { 310 strong { "An allowance and a bedtime. " } 311 "Parents set how long Whiskers can be used in a day and the quiet hours when it sleeps. Spoken time is metered too." 312 } 313 li { 314 strong { "It says what it is. " } 315 "The greeting is fixed text that tells the child Whiskers is an AI, so no model output can leave that out." 316 } 317 } 318 })) 319 } 320 })) 321 (sheet(html! { 322 (explain::section()) 323 })) 324 (sheet(html! { 325 div .stack { 326 section #how aria-labelledby="how-h" { 327 (window(Some(&CAT), html! { h2 #how-h { "How it works" } }, None, true, html! { 328 p { 329 "One Rust core, and a thin shell for each kind of device. The core holds the conversation, the memory, the log and the rules; the Kotlin and Jetpack Compose app only draws and listens. " 330 "A small service holds the keys, so the Jev key and the model\u{2019}s never sit on the child\u{2019}s tablet." 331 } 332 ol .flow aria-label="A message\u{2019}s journey" { 333 li { strong { "Tablet" } " hears the child and keeps it in the log." } 334 li { strong { "Service" } " asks Jev: may this be said?" } 335 li { strong { "Model" } " writes a reply, through a gateway the household runs." } 336 li { strong { "Service" } " asks Jev again: may this be heard?" } 337 li { strong { "Tablet" } " speaks it, or a safe line if anything failed." } 338 } 339 p .fine { "The cat above is not a picture of the app: it follows the app\u{2019}s own mood rules, and a test keeps its moods the same as the app\u{2019}s." } 340 })) 341 } 342 section #get aria-labelledby="get-h" { 343 (window(Some(&CAT), html! { h2 #get-h { "Get it" } }, None, true, html! { 344 p { "Whiskers is a personal project, not a product: there is no hosted version and nothing to sign up for. You run it yourself, with your own keys. There are two parts to get, the Android app and the server it talks to." } 345 (download_menu()) 346 div .get-block { 347 h3 { "Installing the app" } 348 p .fine { "Allow installs from this source in your device\u{2019}s settings (sideloading), then open the file. The first time, hold the goodnight button, pass the grown-up lock and enter the address of the machine running the server." } 349 } 350 div .get-block { 351 h3 { "What the server (whiskersd) needs" } 352 (facts("What the server needs", vec![ 353 Row { icon: Pic::Wifi, title: html! { code { "WHISKERS_ADDR" } }, text: html! { "The address it listens on." } }, 354 Row { icon: Pic::Claude, title: html! { code { "WHISKERS_MODEL_URL" } }, text: html! { "An Anthropic Messages API gateway that you run." } }, 355 Row { icon: Pic::Key, title: html! { "Your own keys" }, text: html! { "For Jev, and for ElevenLabs if you want the natural voice (the voice is optional)." } }, 356 ])) 357 p .fine { "The README in the repository says more." } 358 } 359 div .get-block { 360 h3 { "Build from source" } 361 (copy_line("clone", html! { "Clone:" }, &format!("git clone --recurse-submodules {CLONE_URL}"))) 362 (facts("Building from source", vec![ 363 Row { icon: Pic::Wrench, title: html! { code { "nix develop" } }, text: html! { "Provides the whole toolchain." } }, 364 Row { icon: Pic::Lock, title: html! { code { "WHISKERS_KEYSTORE" } }, text: html! { "Sign the app with your own key by setting these environment variables; the app\u{2019}s README says how." } }, 365 ])) 366 } 367 div .get-block { 368 h3 { "Good to know" } 369 (facts("Good to know", vec![ 370 Row { icon: Pic::Android, title: html! { "Android only today" }, text: html! { "The app is for Android." } }, 371 Row { icon: Pic::AppleLogo, title: html! { "iOS later, maybe" }, text: html! { "An iOS app is possible, because the core is Rust." } }, 372 Row { icon: Pic::Calendar, title: html! { "Named by date" }, text: html! { "Releases are named by date, for example 2026.10.5, not by version numbers." } }, 373 ])) 374 } 375 })) 376 } 377 } 378 })) 379 } 380 (footer(&CAT, html! { "No cookies, no analytics, and nothing on this page is loaded from anywhere else. Pixel icons by shuqikhor (MIT) and SerenityOS (BSD 2-Clause), the Android and arm marks drawn here. Its host sees requests as any host does." }, None)) 381 }) 382} 383 384#[cfg(test)] 385mod tests { 386 use super::*; 387 388 fn origin() -> Origin { 389 Origin::parse("https://whiskers.example").unwrap() 390 } 391 392 fn html() -> String { 393 page(&Page { origin: &origin(), css_version: "abc", mascot: Mascot::DEFAULT }).into_string() 394 } 395 396 #[test] 397 fn the_page_unfurls_as_a_video_card() { 398 let html = html(); 399 assert_eq!(jev_ui::preview::missing(&html, jev_ui::preview::Kind::Video), Vec::<String>::new()); 400 assert!(html.contains("content=\"https://whiskers.example/preview/loop.mp4\"")); 401 assert!(html.contains("<link rel=\"canonical\" href=\"https://whiskers.example/\">"), "the page names its canonical address"); 402 assert!(html.contains("property=\"og:url\" content=\"https://whiskers.example/\"")); 403 } 404 405 #[test] 406 fn the_clone_address_is_the_one_constant() { 407 let html = html(); 408 assert_eq!(html.matches(CLONE_URL).count(), 1, "shown once; the copy button names it by id"); 409 assert!(html.contains("data-copy=\"clone\"")); 410 assert!(!html.contains("not public yet")); 411 } 412 413 #[test] 414 fn every_download_is_linked_once_by_the_contract_address() { 415 let html = html(); 416 for d in APP_DOWNLOADS.iter().chain([&SERVER_DOWNLOAD]) { 417 let href = format!("href=\"https://code.lmjtfy.fun/whiskers/latest/{}\"", d.file); 418 assert_eq!(html.matches(&href).count(), 1, "{}", d.file); 419 assert_eq!(release_url(d.file), format!("https://code.lmjtfy.fun/whiskers/latest/{}", d.file)); 420 } 421 assert_eq!(html.matches(&format!("href=\"{}\"", release_url(CHECKSUMS_FILE))).count(), 1); 422 } 423 424 #[test] 425 fn the_download_menu_is_a_details_with_four_downloads_and_the_checksums() { 426 let html = html(); 427 let start = html.find("<details class=\"dl\"").unwrap(); 428 let menu = &html[start..start + html[start..].find("</details>").unwrap()]; 429 assert!(menu.contains("<summary class=\"dl-button\">")); 430 assert!(menu.contains("Download for Android")); 431 assert_eq!(menu.matches("class=\"dl-row\"").count(), 4); 432 assert_eq!(menu.matches("class=\"dl-sums\"").count(), 1); 433 assert_eq!(menu.matches("href=\"https://code.lmjtfy.fun/whiskers/latest/").count(), 5); 434 assert!(menu.contains("arm64 lite \u{b7} 81 MB") && menu.contains("whiskersd (x86-64 Linux) \u{b7} 3.4 MB")); 435 for file in ["android", "arm", "sound-high", "earth"] { 436 assert!(menu.contains(&format!("pixelicon-{file}\"")), "{file}"); 437 } 438 assert!(menu.contains("data:image/png;base64,"), "the server's SerenityOS icon"); 439 } 440 441 #[test] 442 fn every_table_row_has_a_row_header_and_an_icon() { 443 let html = html(); 444 let tables = html.matches("<table class=\"facts\">").count(); 445 assert!(tables >= 3); 446 let rows = html.matches("<td class=\"icon-cell\">").count(); 447 assert_eq!(html.matches("<th scope=\"row\">").count(), rows); 448 assert!(html.contains("<caption class=\"vh\">")); 449 } 450 451 #[test] 452 fn the_server_block_names_what_it_needs_and_the_page_has_no_plain_http_link() { 453 let html = html(); 454 for need in ["WHISKERS_ADDR", "WHISKERS_MODEL_URL", "ElevenLabs", "WHISKERS_KEYSTORE"] { 455 assert!(html.contains(need), "{need}"); 456 } 457 assert!(!html.contains("href=\"http://")); 458 assert!(!html.contains("not public yet")); 459 assert!(html.contains("2026.10.5")); 460 assert!(html.contains("shuqikhor") && html.contains("SerenityOS"), "the icons' credit"); 461 } 462 463 #[test] 464 fn the_honest_note_comes_before_the_demo() { 465 let html = html(); 466 let note = html.find("A personal project, not a product").unwrap(); 467 assert!(note < html.find("id=\"demo\"").unwrap()); 468 } 469 470 #[test] 471 fn the_page_asks_for_nothing_from_elsewhere() { 472 let html = html(); 473 let css = CSS; 474 for text in [&html, &css.to_owned()] { 475 for bad in ["fonts.googleapis", "fonts.gstatic", "cdn.", "unpkg", "jsdelivr", "googletagmanager", "@import"] { 476 assert!(!text.contains(bad), "{bad}"); 477 } 478 } 479 // Every address the page loads is its own: the only `src` and stylesheet `href` are paths. 480 for attr in ["src=\"", "stylesheet\" href=\""] { 481 for part in html.split(attr).skip(1) { 482 // Its own paths, or the SerenityOS icon embedded in the page (a `data:` PNG, which the policy's `img-src` allows). 483 assert!(part.starts_with('/') || part.starts_with("data:image/png;base64,"), "{attr}{}", &part[..20.min(part.len())]); 484 } 485 } 486 } 487 488 #[test] 489 fn the_page_wears_the_shared_look_and_serves_it_from_itself() { 490 use jev_ui::asset; 491 let html = html(); 492 assert!(html.contains(&format!("href=\"{}\"", asset::CSS.href()))); 493 assert!(html.contains(&format!("src=\"{}\"", asset::JS.href()))); 494 assert!(html.contains("src=\"/datastar.js\"")); 495 for class in ["class=\"sheet\"", "class=\"band\"", "class=\"win white\"", "class=\"bar-title\"", "class=\"headline\"", "class=\"dark\""] { 496 assert!(html.contains(class), "{class}"); 497 } 498 // The page's policy forbids inline script: every script is a file. 499 for part in html.split("<script").skip(1) { 500 assert!(part.split('>').next().unwrap().contains("src=\""), "an inline script"); 501 } 502 // The cat's own motion is the site's file, named by its content. 503 assert!(html.contains("href=\"/cat.css?v=abc\"")); 504 } 505 506 #[test] 507 fn it_is_a_document_with_landmarks_a_title_and_a_language() { 508 let html = html(); 509 for needle in ["<html lang=\"en\">", "<main id=\"main\">", "<h1>Whiskers</h1>", "<nav aria-label", "<header>", "<footer>", "class=\"skip\"", "<title>"] { 510 assert!(html.contains(needle), "{needle}"); 511 } 512 } 513 514 #[test] 515 fn nothing_private_is_on_the_page() { 516 let html = html().to_lowercase(); 517 // The owner's own terms are in a local file that is never committed (tools/check-private.sh); 518 // without it this part of the check has nothing to read and says so rather than pass silently. 519 let terms = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.private-terms"); 520 match std::fs::read_to_string(&terms) { 521 Ok(list) => { 522 for term in list.lines().map(|l| l.trim().to_lowercase()).filter(|l| !l.is_empty() && !l.starts_with('#')) { 523 assert!(!html.contains(&term), "a private term is on the page"); 524 } 525 } 526 Err(_) => eprintln!("nothing_private_is_on_the_page: no .private-terms here, owner terms not checked"), 527 } 528 } 529 530 #[test] 531 fn the_picker_lists_every_mascot_as_a_button_and_starts_on_ginger() { 532 let html = html(); 533 for &mascot in Mascot::ALL { 534 assert!(html.contains(&format!("$mascot = '{}'; @post('/mascot')", mascot.name())), "{}", mascot.name()); 535 assert!(html.contains(&format!(">{}</button>", mascot.label())), "{}", mascot.label()); 536 } 537 let (g, y, b) = (html.find("Ginger</button>").unwrap(), html.find("Grey</button>").unwrap(), html.find("Bunny</button>").unwrap()); 538 assert!(g < y && y < b, "the picker lists Ginger, Grey, Bunny in that order"); 539 assert!(html.contains("A pixel ginger cat, idle")); 540 assert!(html.contains(""mascot":"ginger"") || html.contains("\"mascot\":\"ginger\"")); 541 assert!(html.contains("aria-pressed=\"true\"")); 542 } 543 544 #[test] 545 fn a_link_can_preselect_the_bunny_and_the_page_then_draws_a_rabbit() { 546 let html = page(&Page { origin: &origin(), css_version: "abc", mascot: Mascot::Bunny }).into_string(); 547 assert!(html.contains("A pixel rabbit, idle")); 548 assert!(!html.contains("A pixel ginger cat, idle")); 549 } 550 551 #[test] 552 fn the_button_of_the_chosen_mascot_is_the_pressed_one_before_any_script_runs() { 553 let html = page(&Page { origin: &origin(), css_version: "abc", mascot: Mascot::Bunny }).into_string(); 554 let bunny = html.split("$mascot = 'bunny'").next().unwrap(); 555 assert!(bunny.rsplit("<button").next().unwrap().contains("aria-pressed=\"true\"")); 556 let ginger = html.split("$mascot = 'ginger'").next().unwrap(); 557 assert!(ginger.rsplit("<button").next().unwrap().contains("aria-pressed=\"false\"")); 558 } 559 560 #[test] 561 fn every_mascot_has_a_picture_for_every_mood_in_the_patch() { 562 for &mascot in Mascot::ALL { 563 for mood in Mood::ALL { 564 let shown = demo::pick(&demo::Signals { mood: mood.name().into(), ..Default::default() }).unwrap(); 565 let events = patches(shown, mascot); 566 assert!(events.contains(&format!("A pixel {}, {}", mascot.kind(), mood.name()))); 567 assert!(events.contains(&format!("\"mascot\":\"{}\"", mascot.name()))); 568 } 569 } 570 } 571 572 #[test] 573 fn a_patch_replaces_the_cat_the_caption_and_the_pipeline_and_sets_the_signals() { 574 let shown = demo::pick(&demo::Signals { mood: "thinking".into(), step: 0, tour: true, ..Default::default() }).unwrap(); 575 let events = patches(shown, Mascot::DEFAULT); 576 assert!(events.contains("datastar-patch-elements")); 577 assert!(events.contains("datastar-patch-signals")); 578 for id in ["id=\"cat\"", "id=\"caption\"", "id=\"pipeline\""] { 579 assert!(events.contains(id), "{id}"); 580 } 581 assert!(events.contains("\"mood\":\"thinking\"")); 582 assert!(events.contains("\"tour\":false")); 583 } 584 585 #[test] 586 fn every_mood_has_a_button_and_a_cat() { 587 let html = html(); 588 for mood in Mood::ALL { 589 assert!(html.contains(&format!("$mood = '{}'", mood.name()))); 590 } 591 } 592 593 fn token(css: &str, name: &str) -> cat::node::Color { 594 let value = css.split(&format!("{name}:")).nth(1).unwrap().trim_start(); 595 let hex = value.strip_prefix('#').unwrap().get(..6).unwrap(); 596 cat::node::Color::hex(u32::from_str_radix(hex, 16).unwrap()) 597 } 598 599 #[test] 600 fn the_stage_is_the_black_the_scenes_are_held_to_and_the_page_chrome_stays_light() { 601 let stage = token(CSS, "--stage"); 602 assert_eq!(stage, cat::stage::STAGE); 603 assert!(CSS.contains(".stage { margin: 0; background: var(--stage);")); 604 let shared = jev_ui::asset::CSS.text(); 605 let (ink, paper, grey, pink) = (token(shared, "--ink"), token(shared, "--paper"), token(shared, "--grey"), token(shared, "--pink")); 606 assert!(cat::stage::luminance(paper) > 0.9 && cat::stage::luminance(grey) > 0.7, "the page round the stage is light"); 607 // The caption (on the window's grey), the pills (paper, pressed in ink, pink when lit) and 608 // the tour button (ink): text at 4.5 to 1, the countdown bar on the pressed pill at 3. 609 let text = |fg, bg, what: &str| assert!(cat::stage::contrast(fg, bg) >= 4.5, "{what}: {:.2}", cat::stage::contrast(fg, bg)); 610 text(ink, grey, "caption on the window"); 611 text(ink, paper, "pill at rest"); 612 text(paper, ink, "pressed pill and the tour button"); 613 text(ink, pink, "a lit pipeline stage"); 614 assert!(cat::stage::contrast(pink, ink) >= cat::stage::MIN_CONTRAST, "the countdown on a pressed pill"); 615 // The pill's hourglass sits on paper, whatever the pill is. 616 assert!(CSS.contains(".mood .chip") && CSS.contains("background: var(--paper)")); 617 } 618 619 fn moods(shown: Shown) -> String { 620 moods_html(shown).into_string() 621 } 622 623 #[test] 624 fn the_spinner_is_on_the_playing_pill_and_on_no_other() { 625 for beat in 0..demo::TOUR.len() { 626 let shown = Shown { step: Step::new(beat as u64), tour: true }; 627 let html = moods(shown); 628 assert_eq!(html.matches("class=\"fill\"").count() + html.matches("class=\"hourglass\"").count(), 2, "one bar and one hourglass"); 629 assert_eq!(html.matches("(playing)").count(), 1); 630 let mine = html.split("<button").find(|b| b.contains(&format!("id=\"mood-{}\"", shown.mood().name()))).unwrap(); 631 assert!(mine.contains("hourglass") && mine.contains(&format!("id=\"beat-{beat}\"")) && mine.contains(&format!("--secs:{}s", shown.step.dwell_secs())), "{mine}"); 632 assert!(mine.contains("aria-pressed=\"true\"")); 633 } 634 } 635 636 #[test] 637 fn nothing_spins_when_the_tour_is_paused_or_a_mood_was_picked() { 638 let paused = moods(demo::toggle(&demo::Signals { step: 3, tour: true, ..Default::default() })); 639 let picked = moods(demo::pick(&demo::Signals { mood: "dizzy".into(), tour: true, ..Default::default() }).unwrap()); 640 for html in [&paused, &picked, &moods(Shown { step: Step::new(0), tour: false })] { 641 for gone in ["hourglass", "class=\"fill\"", "(playing)", "beat-"] { 642 assert!(!html.contains(gone), "{gone}"); 643 } 644 assert_eq!(html.matches("aria-pressed=\"true\"").count(), 1, "the mood on stage is the pressed one"); 645 } 646 assert!(picked.split("<button").find(|b| b.contains("id=\"mood-dizzy\"")).unwrap().contains("aria-pressed=\"true\"")); 647 } 648 649 #[test] 650 fn the_tour_button_swaps_icon_words_and_name_together() { 651 let play = tour_button(Shown { step: Step::new(0), tour: false }).into_string(); 652 let pause = tour_button(Shown { step: Step::new(0), tour: true }).into_string(); 653 for (html, glyph, words, other) in [(&play, '\u{25B6}', "Play tour", "Pause tour"), (&pause, '\u{23F8}', "Pause tour", "Play tour")] { 654 assert!(html.contains(&format!("aria-label=\"{words}\"")), "{html}"); 655 assert!(html.contains(&format!("</span> {words}</button>")) && html.contains(glyph)); 656 assert!(!html.contains(other)); 657 assert!(html.contains("<span class=\"pe\" aria-hidden=\"true\">"), "the icon is decoration"); 658 } 659 assert!(play.contains("id=\"tour\"") && pause.contains("id=\"tour\"")); 660 } 661 662 #[test] 663 fn the_page_starts_resting_and_asks_the_server_to_start_the_tour() { 664 let html = html(); 665 assert!(html.contains("</span> Play tour</button>")); 666 assert!(html.contains("matchMedia('(prefers-reduced-motion: reduce)').matches || @post('/tour')") || html.contains("matchMedia('(prefers-reduced-motion: reduce)').matches || @post('/tour')")); 667 assert!(!html.contains("class=\"hourglass\""), "no spinner before the tour plays"); 668 } 669 670 #[test] 671 fn a_patch_redraws_the_pills_and_the_button() { 672 let shown = Shown { step: Step::new(2), tour: true }; 673 let events = patches(shown, Mascot::DEFAULT); 674 for id in ["id=\"moods\"", "id=\"tour\"", "id=\"beat-2\""] { 675 assert!(events.contains(id), "{id}"); 676 } 677 assert!(events.contains("Pause tour")); 678 } 679 680 #[test] 681 fn the_pixel_emoji_font_is_the_pages_own_and_the_policy_still_allows_only_itself() { 682 assert!(jev_ui::asset::find("/fonts/pixel-emoji.woff2").is_some()); 683 assert!(crate::headers::CSP.contains("font-src 'self'")); 684 let css = jev_ui::asset::CSS.text(); 685 assert!(css.contains("\"Pixel Emoji\"") && !css.contains("://")); 686 } 687 688 #[test] 689 fn the_favicon_is_a_plain_svg() { 690 let icon = favicon(); 691 assert!(icon.starts_with("<svg xmlns") && !icon.contains("id=\"cat\"")); 692 } 693}