Skip to main content

max / makenotwork

16.9 KB · 404 lines History Blame Raw
1 //! The use-cases page at `/use-cases`, described.
2 //!
3 //! The second public document, and the first with content worth calling
4 //! content: nine creator profiles, four platform promises, a call to action and
5 //! three onward links. It replaces `templates/pages/use_cases.html`,
6 //! `UseCasesTemplate` and `landing::use_cases_page`.
7 //!
8 //! `/team` proved the mount ([`super::Audience::Anyone`], `4239540d`); this one
9 //! asks whether the vocabulary carries a marketing page. It does, and the two
10 //! places it pushed back are worth the reader's time.
11 //!
12 //! # A card with a bullet list is a region, not a row
13 //!
14 //! `Row` refuses to hold a node, deliberately: "a row part may not carry an
15 //! arbitrary node, which is the door through which a description becomes a
16 //! templating language" (`layout::RowPart::Proportion`). Each use-case card has
17 //! a four-to-five item feature list inside it, so a card is a
18 //! [`RegionKind::Group`] labelled with its title, and the features are a
19 //! [`Node::list`] of rows carrying nothing but a primary.
20 //!
21 //! The four "Everyone gets" cards have a name and a sentence and nothing else,
22 //! so those *are* rows, and they are one `Node::list` rather than four regions.
23 //! Same page, two shapes, and the difference is whether the card contains a
24 //! structure or a sentence.
25 //!
26 //! # The grid is not described, and that is the point
27 //!
28 //! `use-case-grid` was `display: grid` with a column count. There is no grid
29 //! region and there should not be: `RegionKind::Columns` is a kanban of peers
30 //! that choose nothing about each other, which is a different claim. Nine
31 //! sibling groups say what is true -- nine cards, equal, belonging to one
32 //! section -- and how they sit is the design system's answer for every consumer
33 //! rather than this page's CSS. Same call as `/team`'s single-card grid.
34 //!
35 //! # The prices are read per request, from the same place the calculator reads
36 //!
37 //! Every tier line interpolates [`crate::tier_prices::TierPrices`], which is
38 //! `Billing`'s and is derived from `docs/business/assumptions.toml`. The screen
39 //! reads it off `viewer.app` rather than taking a state of its own: a public
40 //! document already has a per-request viewer, and a second copy of the prices
41 //! is a second thing to keep in step with the calculator.
42 //!
43 //! # The words are in `content/use-cases.toml`, and the prices are the holes
44 //!
45 //! The first screen on the build-time seam that does not fold to a literal, and
46 //! it is the one that shows what the seam is for. Nine profiles, four promises
47 //! and three onward links were `const` arrays here; they are copy now, read at
48 //! macro time, so all three loops are unrolled before an AST exists. What is
49 //! left varying is nine tier lines, each built from the live `TierPrices`.
50 //!
51 //! So the residual is the page's markup with nine holes in it. Serving it
52 //! writes nine strings into gaps. The comparison is against what this page used
53 //! to do, which was build some two hundred nodes and render them, on every
54 //! request, to produce markup that differed in nine short strings.
55 //!
56 //! A profile's `features` is a list field, which is what the copy production
57 //! grew to admit this page (`for feature in profile.features`). A list is still
58 //! words; the seal that file's header describes is against types, and the
59 //! alternative here was nine numbered fields with the count written into the
60 //! declaration.
61
62 use makeover_layout as layout;
63 use quasi_declare::declare;
64 use quasi_router::{Document, Request, Response, RouteError};
65 use quasi_webview::Webview;
66
67 use crate::tier_prices::TierPrices;
68
69 /// The address, registered whole. See [`super::public_document_mount`].
70 pub const PATH: &str = "/use-cases";
71
72 /// The page's own region, and what the skip link points at.
73 pub const PAGE_REGION: &str = "use-cases";
74
75 /// The nine profiles.
76 const PROFILES: &str = "use-case-profiles";
77
78 /// What every tier includes.
79 const UNIVERSAL: &str = "everyone-gets";
80
81 const MEASURE: layout::Measure = layout::Measure::Wide;
82
83 /// The four tier lines a card can end with, by the name the copy gives.
84 ///
85 /// The one thing on this page a request decides. Everything else a card says is
86 /// words and lives in `content/use-cases.toml`; the price does not, because a
87 /// formatted price in a content file is a fourth place a price can go stale.
88 /// So the file names the *shape* of the line and this builds it from the live
89 /// `TierPrices`.
90 ///
91 /// A `&str` rather than the enum this replaced, and that is the cost of the
92 /// copy moving out of Rust: a proc macro cannot evaluate a path, so a variant
93 /// written in the file would survive into the AST as one. What buys the
94 /// exhaustiveness back is [`tests::every_priced_name_in_the_copy_is_one_of_the_four`],
95 /// which holds the file to the four names this answers.
96 ///
97 /// # Panics
98 ///
99 /// On a name this does not know, which is a content file naming a line that
100 /// does not exist. The test above is what makes that a test failure rather
101 /// than a page that renders empty.
102 fn tier_line(priced: &str, prices: &TierPrices) -> String {
103 match priced {
104 "basic" => format!(
105 "Basic ${}/mo \u{b7} {}, {}/file",
106 prices.basic_std, prices.basic_total, prices.basic_per_file
107 ),
108 "small-files" => format!(
109 "Small Files ${}/mo \u{b7} {}, {}/file",
110 prices.small_files_std, prices.small_files_total, prices.small_files_per_file
111 ),
112 "big-files" => format!(
113 "Big Files ${}/mo \u{b7} {}, {}/file",
114 prices.big_files_std, prices.big_files_total, prices.big_files_per_file
115 ),
116 // "Basic $X/mo or Small Files $Y/mo", which is Educators and only
117 // Educators: the work fits in either tier depending on what is
118 // uploaded.
119 "basic-or-small-files" => format!(
120 "Basic ${}/mo or Small Files ${}/mo",
121 prices.basic_std, prices.small_files_std
122 ),
123 other => panic!("content/use-cases.toml names a tier line that does not exist: {other}"),
124 }
125 }
126
127 /// Every name [`tier_line`] answers to, which is what the copy is held to.
128 #[cfg(test)]
129 const TIER_LINES: &[&str] = &["basic", "small-files", "big-files", "basic-or-small-files"];
130
131 /// The prices this page ends every card with.
132 ///
133 /// Read off the viewer rather than from a state of this screen's own, which is
134 /// the module header's point: a public document already has a per-request
135 /// viewer, and a second copy of the prices is a second thing to keep in step
136 /// with the calculator.
137 ///
138 /// Its own function because both halves of the mount want it: the document, to
139 /// state the screen, and the body, to fill the nine holes.
140 #[must_use]
141 pub fn prices(viewer: &super::Viewer) -> TierPrices {
142 use axum::extract::FromRef as _;
143
144 crate::Billing::from_ref(&viewer.app).tier_prices
145 }
146
147 /// The page. Reads no database, only the prices already in memory.
148 ///
149 /// Kept beside the residual mount rather than replaced by it, and it is what
150 /// the residual is checked against: `quasi::residuals` asserts that filling the
151 /// compiled one gives back exactly what building and rendering this gives. A
152 /// screen with no second way to produce its markup has nothing to check the
153 /// first one with.
154 pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
155 Ok(page_screen(&prices(viewer)).into())
156 }
157
158 declare! {
159 /// The whole document: the title, the measure, the body.
160 pub(crate) shape page_screen(prices: &TierPrices) -> Screen;
161
162 screen single "Use Cases - Makenotwork" {
163 measured MEASURE;
164 documented Document::default().classed(crate::shell::body_class(MEASURE, &["use-cases-page"]));
165 summarised "A flat monthly fee and no platform cut, for musicians, podcasters, writers, \
166 developers and six more kinds of creator.";
167
168 include page_region(prices);
169 }
170 }
171
172 declare! {
173 /// The page's one region, split out so it can be staged.
174 ///
175 /// The split is where the request stops mattering, and on this page that is
176 /// not the whole region: nine cards each end in a price built from
177 /// `TierPrices`, which the server holds in memory and can change under a
178 /// running process. So the residual here is not one literal the way
179 /// `/policy` and `/team` are. It is the page's markup with nine holes in
180 /// it, and serving it is writing nine strings into gaps rather than
181 /// building and rendering a tree of some two hundred nodes.
182 ///
183 /// **The card is written here rather than in a `card()` shape**, and that
184 /// is the copy production deciding it. A card's words are an entry in
185 /// `content/use-cases.toml`, and a shape takes arguments one at a time: a
186 /// `card(anchor, title, who, description, ...)` would have to spell each
187 /// field at the call site and could not pass `features` at all, because a
188 /// list field is iterated and not written. Unrolled here the loop is gone
189 /// either way, so the shape bought a name and cost the copy file.
190 #[staged]
191 pub(crate) shape page_region(prices: &TierPrices) -> Slot;
192
193 region PAGE_REGION as Pane {
194 page "Use Cases";
195 text "A flat monthly fee. 0% platform cut. Who it's built for:";
196
197 section "Available now";
198 region PROFILES as Group {
199 for profile in copy "content/use-cases.toml" as profiles {
200 // The region is identified by the template's anchor so
201 // `/use-cases#podcasters` still lands on the right card.
202 region profile.anchor as Group {
203 // `section` and not `label`. A region's label is rendered
204 // only by a container that discloses or steps through its
205 // members, and `PROFILES` is a plain group, so a `label`
206 // here reaches no markup at all. The template drew
207 // `<div class="use-case-title">Musicians</div>`, and every
208 // one of the nine has been missing from the page since
209 // `84f0886b` described it; the card's own test asserted the
210 // anchor, the who and the features, and never the title.
211 section profile.title;
212 text profile.who;
213 text profile.description;
214 list {
215 for feature in profile.features {
216 row feature;
217 }
218 }
219 text super::use_cases::tier_line(profile.priced, prices);
220 }
221 }
222 }
223
224 section "Everyone gets";
225 region UNIVERSAL as Group {
226 list {
227 for gets in copy "content/use-cases.toml" as universal {
228 row gets.name {
229 secondary gets.sentence;
230 }
231 }
232 }
233 }
234
235 act "Join the Alpha" to get "/join" navigating;
236 for onward in copy "content/use-cases.toml" as onward {
237 act onward.label to get onward.route navigating;
238 }
239 }
240 }
241
242 /// The document this screen is drawn in. Same shape as `/team`'s: the skip
243 /// link and the site header, which reads the viewer's user through an `Option`
244 /// because on this mount there may not be one.
245 #[must_use]
246 pub fn renderer(viewer: &super::Viewer) -> Webview {
247 Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
248 "{}{}",
249 crate::shell::skip_link(PAGE_REGION),
250 crate::shell::site_header(viewer.user.as_ref()),
251 )))
252 }
253
254 #[cfg(test)]
255 mod tests {
256 use super::*;
257
258 fn prices() -> TierPrices {
259 TierPrices::default()
260 }
261
262 fn html() -> String {
263 use quasi_axum::Serves as _;
264
265 Webview::new().screen(&page_screen(&prices()))
266 }
267
268 /// `2790e5c4`. The template wrote the measure on the body and
269 /// `use-cases-page` on a container div; a described document has no
270 /// container, so both land on the body. Same merge `/team` found.
271 #[test]
272 fn the_document_carries_the_classes_the_template_carried() {
273 let screen = page_screen(&prices());
274
275 assert_eq!(
276 screen.document.body_class.as_deref(),
277 Some("padded-page use-cases-page")
278 );
279 let rendered = html();
280 assert!(
281 rendered.contains("class=\"padded-page use-cases-page\""),
282 "{rendered}"
283 );
284 }
285
286 /// The copy the page is built from, read the way the macro reads it.
287 ///
288 /// The test asks the content file what the page should say rather than
289 /// holding a second copy of it, which is `policy`'s rule: a card somebody
290 /// deletes from the file is a deletion and not a failure, and a card that
291 /// stops being rendered is a failure.
292 fn copy() -> toml::Table {
293 include_str!("../../content/use-cases.toml")
294 .parse()
295 .expect("the use-cases copy is TOML")
296 }
297
298 /// Every card the template drew is still drawn, and still reachable by the
299 /// anchor the marketing links use.
300 #[test]
301 fn every_profile_keeps_its_card_and_its_anchor() {
302 let html = html();
303
304 let copy = copy();
305 let profiles = copy["profiles"].as_array().expect("a list of profiles");
306 assert_eq!(profiles.len(), 9, "the template drew nine");
307 for profile in profiles {
308 let anchor = profile["anchor"].as_str().expect("an anchor");
309 assert!(html.contains(anchor), "{anchor} missing");
310 for field in ["who", "title", "description"] {
311 let text = profile[field].as_str().expect("words");
312 let escaped = crate::helpers::escape_html(text);
313 assert!(html.contains(&escaped), "{anchor}: {field} missing");
314 }
315 for feature in profile["features"].as_array().expect("a list") {
316 let feature = feature.as_str().expect("words");
317 let escaped = crate::helpers::escape_html(feature);
318 assert!(html.contains(&escaped), "{feature} missing from {anchor}");
319 }
320 }
321 }
322
323 /// The copy names a tier line this module can build.
324 ///
325 /// What buys back the exhaustiveness the enum had. `tier_line` matches on a
326 /// string because a proc macro cannot evaluate a path, so the compiler
327 /// stopped being able to say that every case is covered; this says it
328 /// instead, against the file that does the naming.
329 #[test]
330 fn every_priced_name_in_the_copy_is_one_of_the_four() {
331 let copy = copy();
332
333 for profile in copy["profiles"].as_array().expect("a list of profiles") {
334 let priced = profile["priced"].as_str().expect("a tier line name");
335 assert!(
336 TIER_LINES.contains(&priced),
337 "{priced} is not one of {TIER_LINES:?}",
338 );
339 }
340 }
341
342 /// Everything the page promises every tier, and every way onward off it.
343 #[test]
344 fn the_universal_list_and_the_onward_links_come_from_the_copy() {
345 let html = html();
346
347 let copy = copy();
348 for gets in copy["universal"].as_array().expect("a list") {
349 for field in ["name", "sentence"] {
350 let text = gets[field].as_str().expect("words");
351 let escaped = crate::helpers::escape_html(text);
352 assert!(html.contains(&escaped), "{text} missing");
353 }
354 }
355 for onward in copy["onward"].as_array().expect("a list") {
356 let route = onward["route"].as_str().expect("a route");
357 assert!(html.contains(route), "{route} missing");
358 }
359 }
360
361 /// No price is written into this module. Each tier line is built from
362 /// `TierPrices`, so a price change in `assumptions.toml` moves this page
363 /// with it.
364 #[test]
365 fn every_tier_line_comes_from_the_live_prices() {
366 let mut prices = prices();
367 prices.basic_std = 4321;
368 prices.small_files_std = 5678;
369 prices.big_files_std = 8765;
370
371 let html = {
372 use quasi_axum::Serves as _;
373 Webview::new().screen(&page_screen(&prices))
374 };
375
376 assert!(html.contains("4321"), "the Basic price is not read: {html}");
377 assert!(html.contains("5678"), "the Small Files price is not read");
378 assert!(html.contains("8765"), "the Big Files price is not read");
379 }
380
381 /// The educators card is the only one naming two tiers, and it is the case
382 /// a formatted string in the table would have quietly flattened.
383 #[test]
384 fn the_educators_card_names_both_tiers_it_fits_in() {
385 let mut prices = prices();
386 prices.basic_std = 4321;
387 prices.small_files_std = 5678;
388
389 let line = tier_line("basic-or-small-files", &prices);
390
391 assert!(line.contains("4321") && line.contains("5678"), "{line}");
392 }
393
394 /// `736f45a5`: this screen's markup carries none of the four spellings.
395 #[test]
396 fn the_page_spells_no_spinner() {
397 let html = html();
398
399 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
400 assert!(!html.contains(spelling), "{spelling} survives in {html}");
401 }
402 }
403 }
404