//! The use-cases page at `/use-cases`, described. //! //! The second public document, and the first with content worth calling //! content: nine creator profiles, four platform promises, a call to action and //! three onward links. It replaces `templates/pages/use_cases.html`, //! `UseCasesTemplate` and `landing::use_cases_page`. //! //! `/team` proved the mount ([`super::Audience::Anyone`], `4239540d`); this one //! asks whether the vocabulary carries a marketing page. It does, and the two //! places it pushed back are worth the reader's time. //! //! # A card with a bullet list is a region, not a row //! //! `Row` refuses to hold a node, deliberately: "a row part may not carry an //! arbitrary node, which is the door through which a description becomes a //! templating language" (`layout::RowPart::Proportion`). Each use-case card has //! a four-to-five item feature list inside it, so a card is a //! [`RegionKind::Group`] labelled with its title, and the features are a //! [`Node::list`] of rows carrying nothing but a primary. //! //! The four "Everyone gets" cards have a name and a sentence and nothing else, //! so those *are* rows, and they are one `Node::list` rather than four regions. //! Same page, two shapes, and the difference is whether the card contains a //! structure or a sentence. //! //! # The grid is not described, and that is the point //! //! `use-case-grid` was `display: grid` with a column count. There is no grid //! region and there should not be: `RegionKind::Columns` is a kanban of peers //! that choose nothing about each other, which is a different claim. Nine //! sibling groups say what is true -- nine cards, equal, belonging to one //! section -- and how they sit is the design system's answer for every consumer //! rather than this page's CSS. Same call as `/team`'s single-card grid. //! //! # The prices are read per request, from the same place the calculator reads //! //! Every tier line interpolates [`crate::tier_prices::TierPrices`], which is //! `Billing`'s and is derived from `docs/business/assumptions.toml`. The screen //! reads it off `viewer.app` rather than taking a state of its own: a public //! document already has a per-request viewer, and a second copy of the prices //! is a second thing to keep in step with the calculator. //! //! # The words are in `content/use-cases.toml`, and the prices are the holes //! //! The first screen on the build-time seam that does not fold to a literal, and //! it is the one that shows what the seam is for. Nine profiles, four promises //! and three onward links were `const` arrays here; they are copy now, read at //! macro time, so all three loops are unrolled before an AST exists. What is //! left varying is nine tier lines, each built from the live `TierPrices`. //! //! So the residual is the page's markup with nine holes in it. Serving it //! writes nine strings into gaps. The comparison is against what this page used //! to do, which was build some two hundred nodes and render them, on every //! request, to produce markup that differed in nine short strings. //! //! A profile's `features` is a list field, which is what the copy production //! grew to admit this page (`for feature in profile.features`). A list is still //! words; the seal that file's header describes is against types, and the //! alternative here was nine numbered fields with the count written into the //! declaration. use makeover_layout as layout; use quasi_declare::declare; use quasi_router::{Document, Request, Response, RouteError}; use quasi_webview::Webview; use crate::tier_prices::TierPrices; /// The address, registered whole. See [`super::public_document_mount`]. pub const PATH: &str = "/use-cases"; /// The page's own region, and what the skip link points at. pub const PAGE_REGION: &str = "use-cases"; /// The nine profiles. const PROFILES: &str = "use-case-profiles"; /// What every tier includes. const UNIVERSAL: &str = "everyone-gets"; const MEASURE: layout::Measure = layout::Measure::Wide; /// The four tier lines a card can end with, by the name the copy gives. /// /// The one thing on this page a request decides. Everything else a card says is /// words and lives in `content/use-cases.toml`; the price does not, because a /// formatted price in a content file is a fourth place a price can go stale. /// So the file names the *shape* of the line and this builds it from the live /// `TierPrices`. /// /// A `&str` rather than the enum this replaced, and that is the cost of the /// copy moving out of Rust: a proc macro cannot evaluate a path, so a variant /// written in the file would survive into the AST as one. What buys the /// exhaustiveness back is [`tests::every_priced_name_in_the_copy_is_one_of_the_four`], /// which holds the file to the four names this answers. /// /// # Panics /// /// On a name this does not know, which is a content file naming a line that /// does not exist. The test above is what makes that a test failure rather /// than a page that renders empty. fn tier_line(priced: &str, prices: &TierPrices) -> String { match priced { "basic" => format!( "Basic ${}/mo \u{b7} {}, {}/file", prices.basic_std, prices.basic_total, prices.basic_per_file ), "small-files" => format!( "Small Files ${}/mo \u{b7} {}, {}/file", prices.small_files_std, prices.small_files_total, prices.small_files_per_file ), "big-files" => format!( "Big Files ${}/mo \u{b7} {}, {}/file", prices.big_files_std, prices.big_files_total, prices.big_files_per_file ), // "Basic $X/mo or Small Files $Y/mo", which is Educators and only // Educators: the work fits in either tier depending on what is // uploaded. "basic-or-small-files" => format!( "Basic ${}/mo or Small Files ${}/mo", prices.basic_std, prices.small_files_std ), other => panic!("content/use-cases.toml names a tier line that does not exist: {other}"), } } /// Every name [`tier_line`] answers to, which is what the copy is held to. #[cfg(test)] const TIER_LINES: &[&str] = &["basic", "small-files", "big-files", "basic-or-small-files"]; /// The prices this page ends every card with. /// /// Read off the viewer rather than from a state of this screen's own, which is /// the module header's point: a public document already has a per-request /// viewer, and a second copy of the prices is a second thing to keep in step /// with the calculator. /// /// Its own function because both halves of the mount want it: the document, to /// state the screen, and the body, to fill the nine holes. #[must_use] pub fn prices(viewer: &super::Viewer) -> TierPrices { use axum::extract::FromRef as _; crate::Billing::from_ref(&viewer.app).tier_prices } /// The page. Reads no database, only the prices already in memory. /// /// Kept beside the residual mount rather than replaced by it, and it is what /// the residual is checked against: `quasi::residuals` asserts that filling the /// compiled one gives back exactly what building and rendering this gives. A /// screen with no second way to produce its markup has nothing to check the /// first one with. pub fn screen(viewer: &super::Viewer, _request: Request) -> Result { Ok(page_screen(&prices(viewer)).into()) } declare! { /// The whole document: the title, the measure, the body. pub(crate) shape page_screen(prices: &TierPrices) -> Screen; screen single "Use Cases - Makenotwork" { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &["use-cases-page"])); summarised "A flat monthly fee and no platform cut, for musicians, podcasters, writers, \ developers and six more kinds of creator."; include page_region(prices); } } declare! { /// The page's one region, split out so it can be staged. /// /// The split is where the request stops mattering, and on this page that is /// not the whole region: nine cards each end in a price built from /// `TierPrices`, which the server holds in memory and can change under a /// running process. So the residual here is not one literal the way /// `/policy` and `/team` are. It is the page's markup with nine holes in /// it, and serving it is writing nine strings into gaps rather than /// building and rendering a tree of some two hundred nodes. /// /// **The card is written here rather than in a `card()` shape**, and that /// is the copy production deciding it. A card's words are an entry in /// `content/use-cases.toml`, and a shape takes arguments one at a time: a /// `card(anchor, title, who, description, ...)` would have to spell each /// field at the call site and could not pass `features` at all, because a /// list field is iterated and not written. Unrolled here the loop is gone /// either way, so the shape bought a name and cost the copy file. #[staged] pub(crate) shape page_region(prices: &TierPrices) -> Slot; region PAGE_REGION as Pane { page "Use Cases"; text "A flat monthly fee. 0% platform cut. Who it's built for:"; section "Available now"; region PROFILES as Group { for profile in copy "content/use-cases.toml" as profiles { // The region is identified by the template's anchor so // `/use-cases#podcasters` still lands on the right card. region profile.anchor as Group { // `section` and not `label`. A region's label is rendered // only by a container that discloses or steps through its // members, and `PROFILES` is a plain group, so a `label` // here reaches no markup at all. The template drew // `
Musicians
`, and every // one of the nine has been missing from the page since // `84f0886b` described it; the card's own test asserted the // anchor, the who and the features, and never the title. section profile.title; text profile.who; text profile.description; list { for feature in profile.features { row feature; } } text super::use_cases::tier_line(profile.priced, prices); } } } section "Everyone gets"; region UNIVERSAL as Group { list { for gets in copy "content/use-cases.toml" as universal { row gets.name { secondary gets.sentence; } } } } act "Join the Alpha" to get "/join" navigating; for onward in copy "content/use-cases.toml" as onward { act onward.label to get onward.route navigating; } } } /// The document this screen is drawn in. Same shape as `/team`'s: the skip /// link and the site header, which reads the viewer's user through an `Option` /// because on this mount there may not be one. #[must_use] pub fn renderer(viewer: &super::Viewer) -> Webview { Webview::new().with_shell(viewer.document_shell().with_body_first(format!( "{}{}", crate::shell::skip_link(PAGE_REGION), crate::shell::site_header(viewer.user.as_ref()), ))) } #[cfg(test)] mod tests { use super::*; fn prices() -> TierPrices { TierPrices::default() } fn html() -> String { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(&prices())) } /// `2790e5c4`. The template wrote the measure on the body and /// `use-cases-page` on a container div; a described document has no /// container, so both land on the body. Same merge `/team` found. #[test] fn the_document_carries_the_classes_the_template_carried() { let screen = page_screen(&prices()); assert_eq!( screen.document.body_class.as_deref(), Some("padded-page use-cases-page") ); let rendered = html(); assert!( rendered.contains("class=\"padded-page use-cases-page\""), "{rendered}" ); } /// The copy the page is built from, read the way the macro reads it. /// /// The test asks the content file what the page should say rather than /// holding a second copy of it, which is `policy`'s rule: a card somebody /// deletes from the file is a deletion and not a failure, and a card that /// stops being rendered is a failure. fn copy() -> toml::Table { include_str!("../../content/use-cases.toml") .parse() .expect("the use-cases copy is TOML") } /// Every card the template drew is still drawn, and still reachable by the /// anchor the marketing links use. #[test] fn every_profile_keeps_its_card_and_its_anchor() { let html = html(); let copy = copy(); let profiles = copy["profiles"].as_array().expect("a list of profiles"); assert_eq!(profiles.len(), 9, "the template drew nine"); for profile in profiles { let anchor = profile["anchor"].as_str().expect("an anchor"); assert!(html.contains(anchor), "{anchor} missing"); for field in ["who", "title", "description"] { let text = profile[field].as_str().expect("words"); let escaped = crate::helpers::escape_html(text); assert!(html.contains(&escaped), "{anchor}: {field} missing"); } for feature in profile["features"].as_array().expect("a list") { let feature = feature.as_str().expect("words"); let escaped = crate::helpers::escape_html(feature); assert!(html.contains(&escaped), "{feature} missing from {anchor}"); } } } /// The copy names a tier line this module can build. /// /// What buys back the exhaustiveness the enum had. `tier_line` matches on a /// string because a proc macro cannot evaluate a path, so the compiler /// stopped being able to say that every case is covered; this says it /// instead, against the file that does the naming. #[test] fn every_priced_name_in_the_copy_is_one_of_the_four() { let copy = copy(); for profile in copy["profiles"].as_array().expect("a list of profiles") { let priced = profile["priced"].as_str().expect("a tier line name"); assert!( TIER_LINES.contains(&priced), "{priced} is not one of {TIER_LINES:?}", ); } } /// Everything the page promises every tier, and every way onward off it. #[test] fn the_universal_list_and_the_onward_links_come_from_the_copy() { let html = html(); let copy = copy(); for gets in copy["universal"].as_array().expect("a list") { for field in ["name", "sentence"] { let text = gets[field].as_str().expect("words"); let escaped = crate::helpers::escape_html(text); assert!(html.contains(&escaped), "{text} missing"); } } for onward in copy["onward"].as_array().expect("a list") { let route = onward["route"].as_str().expect("a route"); assert!(html.contains(route), "{route} missing"); } } /// No price is written into this module. Each tier line is built from /// `TierPrices`, so a price change in `assumptions.toml` moves this page /// with it. #[test] fn every_tier_line_comes_from_the_live_prices() { let mut prices = prices(); prices.basic_std = 4321; prices.small_files_std = 5678; prices.big_files_std = 8765; let html = { use quasi_axum::Serves as _; Webview::new().screen(&page_screen(&prices)) }; assert!(html.contains("4321"), "the Basic price is not read: {html}"); assert!(html.contains("5678"), "the Small Files price is not read"); assert!(html.contains("8765"), "the Big Files price is not read"); } /// The educators card is the only one naming two tiers, and it is the case /// a formatted string in the table would have quietly flattened. #[test] fn the_educators_card_names_both_tiers_it_fits_in() { let mut prices = prices(); prices.basic_std = 4321; prices.small_files_std = 5678; let line = tier_line("basic-or-small-files", &prices); assert!(line.contains("4321") && line.contains("5678"), "{line}"); } /// `736f45a5`: this screen's markup carries none of the four spellings. #[test] fn the_page_spells_no_spinner() { let html = html(); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!(!html.contains(spelling), "{spelling} survives in {html}"); } } }