whiskers.git / web / worker / src / view.rs
view.rsannotatedview.rssource693 lines · 35.7 KB · raw
1//! Pages as pure functions: data in, HTML out. Nothing here fetches, reads the clock or knows
2//! a visitor. Everything a visitor can influence (a mood name) is checked before it reaches
3//! here, and maud escapes what is written in parentheses.
4//!
5//! The look is `jev-ui`'s, the same as lmjtfy.fun's: the shell, the nav, the windows, the band.
6//! What is here is whiskers': the cat, the demo's parts, and the words.
7
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};
18
19const CSS: &str = include_str!("page.css");
20
21/// The theme colour of the link preview's bar: the page's pink, as lmjtfy's.
22const THEME: &str = "#f386a1";
23
24/// A cat's head, for the title bars and the foot: 12 by 12, `#` for ink (`jev_ui::components`).
25pub const CAT: Icon = [
26    "............", "..#......#..", ".###....###.", ".##########.", ".##.####.##.", ".##########.", ".####..####.", ".###.##.###.",
27    "..########..", "...######...", "............", "............",
28];
29
30/// What the page needs besides the cat.
31pub struct Page<'a> {
32    pub origin: &'a Origin,
33    /// Changes when the stylesheet does, so the long-cached file is fetched again.
34    pub css_version: &'a str,
35    /// Who is on stage when the page loads (`?mascot=` picks it).
36    pub mascot: Mascot,
37}
38
39fn label(mascot: Mascot, mood: Mood) -> String {
40    format!("A pixel {}, {}", mascot.kind(), mood.name())
41}
42
43/// The mascot, drawn for the page: motion by stylesheet, pupils bound to the pointer.
44pub fn cat_svg(mascot: Mascot, mood: Mood) -> PreEscaped<String> {
45    PreEscaped(svg(&scene(mascot, mood), &label(mascot, mood), &Mode::Live { eyes_attrs: &eyes_attrs() }))
46}
47
48/// The idle cat as a still picture, for the browser tab.
49pub fn favicon() -> String {
50    svg(&scene(Mascot::DEFAULT, Mood::Idle), "Whiskers", &Mode::Frame { t: 0.0, gaze: (0.0, 0.0) }).replacen("id=\"cat\" class=\"cat\" ", "", 1)
51}
52
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}
75
76/// What a mood button, the tour or the pointer changes on the page: the three parts, then the
77/// 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}
96
97
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}
116
117/// The `<meta>` tags a pasted link unfurls from. Built from checked parts: an origin that is
118/// not https cannot get here, and the files' names and sizes are the configuration's.
119pub fn preview_tags(origin: &Origin) -> String {
120    preview(origin).html().expect("the configured preview is valid (checked by a test)")
121}
122
123fn download_row(d: &Download) -> Markup {
124    html! {
125        a .dl-row href=(release_url(d.file)) {
126            (pics(d.icons))
127            span .dl-text { span .dl-name { (d.label) " \u{b7} " (d.size) } span .fine { (d.what) } }
128        }
129    }
130}
131
132/// "Download for Android": a `<details>`, so it opens by tap or key with no script, and by hover
133/// where the browser lets CSS do it (`page.css`). The rows are plain links to the code host. The
134/// 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}
151
152/// One mood's pill. Which one is pressed, and which one is playing, is the server's word
153/// (`demo::pill`): the markup is replaced with every answer, so there are no client bindings to
154/// disagree with it. A playing pill carries the hourglass and a bar that fills over the beat's
155/// 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}
172
173pub fn moods_html(shown: Shown) -> Markup {
174    html! {
175        div #moods .moods role="group" aria-label="Show the cat in a mood" {
176            @for mood in Mood::ALL { (mood_pill(mood, demo::pill(shown, mood))) }
177        }
178    }
179}
180
181/// 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}
194
195/// One button of the picker. Which is pressed is read from the `$mascot` signal, so choosing
196/// 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}
206
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 { "mise install" } }, 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("&quot;mascot&quot;:&quot;ginger&quot;") || 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(&#39;(prefers-reduced-motion: reduce)&#39;).matches || @post(&#39;/tour&#39;)") || 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}