Skip to main content

max / makenotwork

Declare the use-cases page and the tip offer Wave 1 closes. All eight of MNW's smallest served screens are declared, and with git_blame's header that is 15 shapes of the 173. 2678 lib tests pass and clippy is clean. Nothing in the loss list moved: every anchor the marketing site links to, every tier line coming from the live prices, the tip form's four limits, the project a tip from a project page is attributed to, and the sign-in a signed-out reader is offered. `UNIVERSAL_LIST` and `ONWARD` gain named members, for the reason policy's `OTHER` did. `Node` moves into the test modules where it is still a type: a declared shape names its return type in full, so the module around it stops needing the import, which is the shrinkage a fully declared file is supposed to show.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-03 17:57 UTC
Signed with PGP, not checked
Commit: 2f80051b8e988c280121b4fe95c0fe92b216ba29
Parent: 45aaf62
5 files changed, +149 insertions, -125 deletions
@@ -6674,7 +6674,7 @@
6674 6674
6675 6675 [[package]]
6676 6676 name = "quasi-router"
6677 - version = "0.101.3"
6677 + version = "0.101.4"
6678 6678 dependencies = [
6679 6679 "makeover-layout",
6680 6680 ]
@@ -32,7 +32,6 @@
32 32 //! seven-tenths opacity on a button that says the same thing in words.
33 33
34 34 use quasi_declare::declare;
35 - use quasi_router::Node;
36 35 use quasi_webview::Webview;
37 36
38 37 /// The region a press on this control replaces.
@@ -110,6 +109,7 @@
110 109 #[cfg(test)]
111 110 mod tests {
112 111 use super::*;
112 + use quasi_router::Node;
113 113
114 114 fn html(node: &Node) -> String {
115 115 use quasi_axum::Serves as _;
@@ -52,7 +52,7 @@
52 52 use makeover_layout as layout;
53 53 use quasi_declare::declare;
54 54 use quasi_router::screen::Figure;
55 - use quasi_router::{Node, Request, Response, RouteError};
55 + use quasi_router::{Request, Response, RouteError};
56 56 use quasi_webview::Webview;
57 57
58 58 use super::Viewer;
@@ -182,6 +182,7 @@
182 182 mod tests {
183 183 use super::*;
184 184 use quasi_axum::Serves;
185 + use quasi_router::Node;
185 186
186 187 fn balance() -> BalanceView {
187 188 BalanceView {
@@ -29,9 +29,8 @@
29 29 //! that sent it, which is the failure `c7b0d3c1` hit on the header's Log Out.
30 30 //! The handler answers `HX-Redirect` to an htmx caller for that reason.
31 31
32 - use makeover_layout as layout;
33 - use quasi_router::screen::Field;
34 - use quasi_router::{Action, Node, RegionKind, Slot};
32 + use quasi_declare::declare;
33 + use quasi_router::Action;
35 34
36 35 /// The region the offer sits in, and the disclosure that opens it.
37 36 pub const REGION: &str = "tip-offer";
@@ -51,53 +50,58 @@
51 50 pub signed_in: bool,
52 51 }
53 52
54 - /// The offer, as the page carries it.
53 + declare! {
54 + /// The offer, as the page carries it.
55 + ///
56 + /// `None` from the caller when the creator does not take tips; this returns the
57 + /// node for when they do.
58 + #[must_use]
59 + pub shape control(offer: &Offer<'_>) -> Node;
60 +
61 + given offer.signed_in {
62 + false -> region REGION as Group {
63 + act "Support" to get "/login" navigating;
64 + }
65 + true -> region REGION as Group {
66 + showing_at_most_one None;
67 + region FORM_REGION as Group {
68 + label "Support";
69 + form doing checkout(offer) {
70 + submit "Send tip";
71 + field Number "amount_dollars" "Tip amount" {
72 + required;
73 + value "1";
74 + unit "$";
75 + within "1" "9999";
76 + }
77 + field Textarea "message" "Message (optional)" {
78 + placeholder "Add a message (optional)";
79 + limited_to 280;
80 + }
81 + }
82 + }
83 + }
84 + }
85 + }
86 +
87 + /// Where a tip is taken, and what it is attributed to.
55 88 ///
56 - /// `None` from the caller when the creator does not take tips; this returns the
57 - /// node for when they do.
58 - #[must_use]
59 - pub fn control(offer: &Offer<'_>) -> Node {
60 - if !offer.signed_in {
61 - return Node::Region(
62 - Slot::new(REGION, RegionKind::Group)
63 - .with(Node::act("Support", Action::get("/login").navigating())),
64 - );
89 + /// A supplier rather than a spelled-out action, because the project is carried
90 + /// only on a project page and the form has no way to say "and this value too,
91 + /// sometimes". Amendment 6's `doing` is the remedy the form names for exactly
92 + /// this.
93 + fn checkout(offer: &Offer<'_>) -> Action {
94 + let action = Action::post(format!("/stripe/checkout/tip/{}", offer.creator_id));
95 + match offer.project_id {
96 + Some(project) => action.with("project_id", project),
97 + None => action,
65 98 }
66 -
67 - let mut amount = Field::new(layout::FieldKind::Number, "amount_dollars", "Tip amount")
68 - .required()
69 - .value("1")
70 - .unit("$");
71 - amount.min = Some("1".to_owned());
72 - amount.max = Some("9999".to_owned());
73 -
74 - let mut message = Field::new(layout::FieldKind::Textarea, "message", "Message (optional)");
75 - message.placeholder = Some("Add a message (optional)".to_owned());
76 - message.max_length = Some(280);
77 -
78 - let mut action = Action::post(format!("/stripe/checkout/tip/{}", offer.creator_id));
79 - if let Some(project) = offer.project_id {
80 - action = action.with("project_id", project);
81 - }
82 -
83 - Node::Region(
84 - Slot::new(REGION, RegionKind::Group)
85 - .showing_at_most_one(None)
86 - .with(Node::Region(
87 - Slot::new(FORM_REGION, RegionKind::Group)
88 - .label("Support")
89 - .with(Node::Form {
90 - action,
91 - submit: "Send tip".to_owned(),
92 - fields: vec![amount, message],
93 - }),
94 - )),
95 - )
96 99 }
97 100
98 101 #[cfg(test)]
99 102 mod tests {
100 103 use super::*;
104 + use quasi_router::Node;
101 105 use quasi_webview::Webview;
102 106
103 107 fn html(node: &Node) -> String {
@@ -41,10 +41,8 @@
41 41 //! is a second thing to keep in step with the calculator.
42 42
43 43 use makeover_layout as layout;
44 - use quasi_router::screen::Row;
45 - use quasi_router::{
46 - Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
47 - };
44 + use quasi_declare::declare;
45 + use quasi_router::{Document, Request, Response, RouteError};
48 46 use quasi_webview::Webview;
49 47
50 48 use crate::tier_prices::TierPrices;
@@ -242,32 +240,53 @@
242 240 },
243 241 ];
244 242
243 + /// One thing every tier includes.
244 + struct Included {
245 + name: &'static str,
246 + sentence: &'static str,
247 + }
248 +
245 249 /// What every tier includes, name and sentence, in the template's order.
246 - const UNIVERSAL_LIST: &[(&str, &str)] = &[
247 - (
248 - "0% platform fee",
249 - "Only ~3% payment processing. No percentage cuts, no revenue sharing.",
250 - ),
251 - (
252 - "Full data export",
253 - "Download everything anytime. Projects, items, sales, contacts: all yours.",
254 - ),
255 - (
256 - "No lock-in",
257 - "Month-to-month. Cancel anytime. Take your audience with you.",
258 - ),
259 - (
260 - "Source available",
261 - "Read the codebase. Verify every claim about privacy and data handling.",
262 - ),
250 + const UNIVERSAL_LIST: &[Included] = &[
251 + Included {
252 + name: "0% platform fee",
253 + sentence: "Only ~3% payment processing. No percentage cuts, no revenue sharing.",
254 + },
255 + Included {
256 + name: "Full data export",
257 + sentence: "Download everything anytime. Projects, items, sales, contacts: all yours.",
258 + },
259 + Included {
260 + name: "No lock-in",
261 + sentence: "Month-to-month. Cancel anytime. Take your audience with you.",
262 + },
263 + Included {
264 + name: "Source available",
265 + sentence: "Read the codebase. Verify every claim about privacy and data handling.",
266 + },
263 267 ];
264 268
269 + /// One way onward off this page.
270 + struct Onward {
271 + label: &'static str,
272 + route: &'static str,
273 + }
274 +
265 275 /// Where the page sends a reader who is convinced, and where it sends one who
266 276 /// is not yet.
267 - const ONWARD: &[(&str, &str)] = &[
268 - ("Pricing Calculator", "/pricing"),
269 - ("Browse as guest", "/discover"),
270 - ("Creator Guide", "/docs/getting-started"),
277 + const ONWARD: &[Onward] = &[
278 + Onward {
279 + label: "Pricing Calculator",
280 + route: "/pricing",
281 + },
282 + Onward {
283 + label: "Browse as guest",
284 + route: "/discover",
285 + },
286 + Onward {
287 + label: "Creator Guide",
288 + route: "/docs/getting-started",
289 + },
271 290 ];
272 291
273 292 /// The page. Reads no database, only the prices already in memory.
@@ -278,64 +297,64 @@
278 297 Ok(page_screen(&billing.tier_prices).into())
279 298 }
280 299
281 - /// The whole document: the title, the measure, the body.
282 - fn page_screen(prices: &TierPrices) -> Described {
283 - let mut profiles = Slot::new(PROFILES, RegionKind::Group);
284 - for profile in PROFILE_LIST {
285 - profiles = profiles.with(Node::Region(card(profile, prices)));
300 + declare! {
301 + /// The whole document: the title, the measure, the body.
302 + shape page_screen(prices: &TierPrices) -> Screen;
303 +
304 + screen single "Use Cases - Makenotwork" {
305 + measured MEASURE;
306 + documented Document::default().classed(crate::shell::body_class(MEASURE, &["use-cases-page"]));
307 + summarised "A flat monthly fee and no platform cut, for musicians, podcasters, writers, \
308 + developers and six more kinds of creator.";
309 +
310 + region PAGE_REGION as Pane {
311 + page "Use Cases";
312 + text "A flat monthly fee. 0% platform cut. Who it's built for:";
313 +
314 + section "Available now";
315 + region PROFILES as Group {
316 + for profile in PROFILE_LIST {
317 + include card(profile, prices);
318 + }
319 + }
320 +
321 + section "Everyone gets";
322 + region UNIVERSAL as Group {
323 + list {
324 + for gets in UNIVERSAL_LIST {
325 + row gets.name {
326 + secondary gets.sentence;
327 + }
328 + }
329 + }
330 + }
331 +
332 + act "Join the Alpha" to get "/join" navigating;
333 + for onward in ONWARD {
334 + act onward.label to get onward.route navigating;
335 + }
336 + }
286 337 }
287 -
288 - let universal = Node::list(
289 - UNIVERSAL_LIST
290 - .iter()
291 - .map(|(name, description)| Row::new(*name).secondary(*description)),
292 - );
293 -
294 - let mut page = Slot::new(PAGE_REGION, RegionKind::Pane)
295 - .with(Node::page("Use Cases"))
296 - .with(Node::text(
297 - "A flat monthly fee. 0% platform cut. Who it's built for:",
298 - ))
299 - .with(Node::section("Available now"))
300 - .with(Node::Region(profiles))
301 - .with(Node::section("Everyone gets"))
302 - .with(Node::Region(
303 - Slot::new(UNIVERSAL, RegionKind::Group).with(universal),
304 - ))
305 - .with(Node::act(
306 - "Join the Alpha",
307 - Action::get("/join").navigating(),
308 - ));
309 -
310 - for (label, route) in ONWARD {
311 - page = page.with(Node::act(*label, Action::get(*route).navigating()));
312 - }
313 -
314 - Described::single("Use Cases - Makenotwork")
315 - .measured(MEASURE)
316 - .documented(
317 - Document::default().classed(crate::shell::body_class(MEASURE, &["use-cases-page"])),
318 - )
319 - .summarised(
320 - "A flat monthly fee and no platform cut, for musicians, podcasters, writers, \
321 - developers and six more kinds of creator.",
322 - )
323 - .with(page)
324 338 }
325 339
326 - /// One profile card: who it is for, what they get, what it costs.
327 - ///
328 - /// The region is identified by the template's anchor so `/use-cases#podcasters`
329 - /// still lands on the right card.
330 - fn card(profile: &Profile, prices: &TierPrices) -> Slot {
331 - let features = Node::list(profile.features.iter().map(|feature| Row::new(*feature)));
340 + declare! {
341 + /// One profile card: who it is for, what they get, what it costs.
342 + ///
343 + /// The region is identified by the template's anchor so `/use-cases#podcasters`
344 + /// still lands on the right card.
345 + shape card(profile: &Profile, prices: &TierPrices) -> Slot;
332 346
333 - Slot::new(profile.anchor, RegionKind::Group)
334 - .label(profile.title)
335 - .with(Node::text(profile.who))
336 - .with(Node::text(profile.description))
337 - .with(features)
338 - .with(Node::text(profile.priced.line(prices)))
347 + region profile.anchor as Group {
348 + label profile.title;
349 + text profile.who;
350 + text profile.description;
351 + list {
352 + for &feature in profile.features {
353 + row feature;
354 + }
355 + }
356 + text profile.priced.line(prices);
357 + }
339 358 }
340 359
341 360 /// The document this screen is drawn in. Same shape as `/team`'s: the skip