max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
6 files changed,
+301 insertions,
-76 deletions
| @@ -396,6 +396,15 @@ | |||
| 396 | 396 | /// emitted by `quasi-webview` at request time, which no scan of this repo can | |
| 397 | 397 | /// see. A class the description writes is dead here by construction. | |
| 398 | 398 | /// | |
| 399 | + | /// # 49 since 2026-09-07, lowered by converting screens | |
| 400 | + | /// | |
| 401 | + | /// The ratchet doing what the paragraph below says it should. `/team`, | |
| 402 | + | /// `/use-cases` and `/fan-plus` moved onto the build-time residual seam | |
| 403 | + | /// (quasicoherent `14d42beb`), their markup is now derived from the described | |
| 404 | + | /// vocabulary rather than written by a template, and the dead count fell 52 to | |
| 405 | + | /// 49 without anything being deleted by hand. Lower it again whenever a | |
| 406 | + | /// conversion moves it; raising it is the thing that wants an argument. | |
| 407 | + | /// | |
| 399 | 408 | /// # 52 since 2026-09-01, and the three were already loose | |
| 400 | 409 | /// | |
| 401 | 410 | /// Re-sealed after measuring, not after a change: the count was 52 at the | |
| @@ -413,7 +422,7 @@ | |||
| 413 | 422 | /// is mid-flight, and this number should FALL as phase 3 converts screens | |
| 414 | 423 | /// rather than being raised again. If it needs raising a second time, read the | |
| 415 | 424 | /// list before doing it. | |
| 416 | - | const DEAD_VOCABULARY_HIGH_WATER: usize = 52; | |
| 425 | + | const DEAD_VOCABULARY_HIGH_WATER: usize = 49; | |
| 417 | 426 | ||
| 418 | 427 | /// The scripts this build script generates into `static/`. | |
| 419 | 428 | /// |
| @@ -75,58 +75,18 @@ | |||
| 75 | 75 | /// What the membership costs, in whole dollars per month. | |
| 76 | 76 | const PRICE: &str = "$8"; | |
| 77 | 77 | ||
| 78 | - | /// One thing a membership buys. | |
| 79 | - | /// | |
| 80 | - | /// Named members rather than a tuple, for `policy`'s reason: a description | |
| 81 | - | /// names what it draws, and `.1` is not a name. | |
| 82 | - | struct Benefit { | |
| 83 | - | name: &'static str, | |
| 84 | - | detail: &'static str, | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | /// What a member gets. | |
| 88 | - | /// | |
| 89 | - | /// **Keep this list and the Fan+ card on the landing page identical, and keep | |
| 90 | - | /// both to what the code grants.** Audited 2026-08-05, carried over from the | |
| 91 | - | /// template's comment because it is a rule rather than a note: a benefit listed | |
| 92 | - | /// here and not granted by the code is a promise nobody implemented. | |
| 93 | - | const BENEFITS: &[Benefit] = &[ | |
| 94 | - | Benefit { | |
| 95 | - | name: "$5 monthly credit", | |
| 96 | - | detail: "A promo code delivered by email each billing cycle, usable toward any purchase on the platform", | |
| 97 | - | }, | |
| 98 | - | Benefit { | |
| 99 | - | name: "+ badge", | |
| 100 | - | detail: "Displayed next to your name on your forum posts", | |
| 101 | - | }, | |
| 102 | - | Benefit { | |
| 103 | - | name: "Forum signatures", | |
| 104 | - | detail: "A signature block rendered under everything you post", | |
| 105 | - | }, | |
| 106 | - | Benefit { | |
| 107 | - | name: "Image embeds", | |
| 108 | - | detail: "Post images in forum threads", | |
| 109 | - | }, | |
| 110 | - | ]; | |
| 111 | - | ||
| 112 | 78 | /// The region a member's own state is drawn in. | |
| 113 | 79 | const MEMBERSHIP: &str = "fan-plus-membership"; | |
| 114 | 80 | ||
| 115 | 81 | /// The region the pitch is drawn in. | |
| 116 | 82 | const PITCH: &str = "fan-plus-pitch"; | |
| 117 | 83 | ||
| 118 | - | /// What a visitor is offered instead of a control they cannot use. | |
| 119 | - | /// | |
| 120 | - | /// One sentence with two inline links, not two buttons. The sentence says which | |
| 121 | - | /// link is for whom and a pair of buttons does not, and that distinction is the | |
| 122 | - | /// whole point of `loose-wire g1-23`: Fan+ needs an account, so without the | |
| 123 | - | /// second link this page is a dead end for exactly the visitor it is written | |
| 124 | - | /// for. It is prose, so it is `own_prose`, on `/policy`'s rule. | |
| 125 | - | const VISITOR: &str = | |
| 126 | - | "[Create an account](/join) to join, or [log in](/login) if you already have one."; | |
| 127 | - | ||
| 128 | 84 | /// What this request knows about the reader's membership. | |
| 129 | - | enum Standing { | |
| 85 | + | /// | |
| 86 | + | /// `pub(crate)` so `quasi::residuals` can name it: the residual checks live | |
| 87 | + | /// beside the roster rather than in each screen, and a screen that branches has | |
| 88 | + | /// to be filled under every branch to be checked at all. | |
| 89 | + | pub(crate) enum Standing { | |
| 130 | 90 | /// Nobody is signed in. The page is a pitch plus a way to get an account. | |
| 131 | 91 | Visitor, | |
| 132 | 92 | /// Signed in, not a member. | |
| @@ -171,6 +131,12 @@ | |||
| 171 | 131 | } | |
| 172 | 132 | ||
| 173 | 133 | /// The page. | |
| 134 | + | /// | |
| 135 | + | /// Kept beside the residual mount rather than replaced by it, and it is what the | |
| 136 | + | /// residual is checked against: `quasi::residuals` asserts that filling the | |
| 137 | + | /// compiled one gives back exactly what building and rendering this gives, on | |
| 138 | + | /// every branch. A screen with no second way to produce its markup has nothing | |
| 139 | + | /// to check the first one with. | |
| 174 | 140 | pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> { | |
| 175 | 141 | // Moved out of the request rather than borrowed: the signature is quasi's, | |
| 176 | 142 | // so the request arrives owned. | |
| @@ -184,6 +150,22 @@ | |||
| 184 | 150 | Ok(page_screen(&standing, just_subscribed).into()) | |
| 185 | 151 | } | |
| 186 | 152 | ||
| 153 | + | /// The two things this page reads, for the mount that serves it from a residual. | |
| 154 | + | /// | |
| 155 | + | /// Its own function because both halves of the mount want them: the document, to | |
| 156 | + | /// state the screen, and the body, to fill the branches. A failure to read the | |
| 157 | + | /// membership answers `Visitor`, which is what a reader with no account sees; the | |
| 158 | + | /// alternative is a 500 on a marketing page because Stripe's mirror of a | |
| 159 | + | /// subscription could not be loaded, and the page that says "here is what Fan+ | |
| 160 | + | /// is" reads correctly for somebody whose membership is unknown. | |
| 161 | + | #[must_use] | |
| 162 | + | pub(crate) fn reading(viewer: &super::Viewer, carried: &super::Carried) -> (Standing, bool) { | |
| 163 | + | ( | |
| 164 | + | standing(viewer).unwrap_or(Standing::Visitor), | |
| 165 | + | carried.says("subscribed", "true"), | |
| 166 | + | ) | |
| 167 | + | } | |
| 168 | + | ||
| 187 | 169 | /// Read the reader's membership, if there is a reader. | |
| 188 | 170 | fn standing(viewer: &super::Viewer) -> Result<Standing, RouteError> { | |
| 189 | 171 | let Some(user) = viewer.user.as_ref() else { | |
| @@ -211,7 +193,7 @@ | |||
| 211 | 193 | /// a visitor gets the pitch and both ways to get an account, a signed-in | |
| 212 | 194 | /// reader gets the pitch and the control that subscribes, and a member gets | |
| 213 | 195 | /// their membership's state and nothing to buy. | |
| 214 | - | shape page_screen(standing: &Standing, just_subscribed: bool) -> Screen; | |
| 196 | + | pub(crate) shape page_screen(standing: &Standing, just_subscribed: bool) -> Screen; | |
| 215 | 197 | ||
| 216 | 198 | screen single "Fan+ - Makenotwork" { | |
| 217 | 199 | measured MEASURE; | |
| @@ -219,21 +201,46 @@ | |||
| 219 | 201 | summarised "Support the platform and get $5 of credit back every month, plus forum badges, \ | |
| 220 | 202 | signatures and image embeds."; | |
| 221 | 203 | ||
| 222 | - | region PAGE_REGION as Pane { | |
| 223 | - | page "Fan+"; | |
| 224 | - | banner layout::Tone::Success "You're now a Fan+ member. Welcome." when just_subscribed; | |
| 204 | + | include page_region(standing, just_subscribed); | |
| 205 | + | } | |
| 206 | + | } | |
| 225 | 207 | ||
| 226 | - | include membership(standing) when standing.is_member(); | |
| 227 | - | include pitch() unless standing.is_member(); | |
| 208 | + | declare! { | |
| 209 | + | /// The page's one region, split out so it can be staged. | |
| 210 | + | /// | |
| 211 | + | /// **The first screen on the seam whose residual branches.** `/policy` and | |
| 212 | + | /// `/team` fold to one literal and `/use-cases` is literals and holes; this | |
| 213 | + | /// page asks four questions about the reader, so its residual carries the | |
| 214 | + | /// markup of every answer and the filler walks the arms the request picks. | |
| 215 | + | /// That is the point rather than a cost: the three readers see three | |
| 216 | + | /// documents, and all three are compiled. | |
| 217 | + | #[staged] | |
| 218 | + | pub(crate) shape page_region(standing: &Standing, just_subscribed: bool) -> Slot; | |
| 228 | 219 | ||
| 229 | - | act "Join Fan+" to post SUBSCRIBE awaiting when standing.is_unsubscribed(); | |
| 230 | - | include super::own_prose(VISITOR) when standing.is_visitor(); | |
| 220 | + | region PAGE_REGION as Pane { | |
| 221 | + | page "Fan+"; | |
| 222 | + | banner layout::Tone::Success "You're now a Fan+ member. Welcome." when just_subscribed; | |
| 223 | + | ||
| 224 | + | include membership(standing) when standing.is_member(); | |
| 225 | + | include pitch() unless standing.is_member(); | |
| 226 | + | ||
| 227 | + | act "Join Fan+" to post SUBSCRIBE awaiting when standing.is_unsubscribed(); | |
| 228 | + | for visitor in copy "content/fan-plus.toml" as visitor { | |
| 229 | + | include super::own_prose(visitor.body) when standing.is_visitor(); | |
| 231 | 230 | } | |
| 232 | 231 | } | |
| 233 | 232 | } | |
| 234 | 233 | ||
| 235 | 234 | declare! { | |
| 236 | 235 | /// What a member is shown: the state of the thing they are paying for. | |
| 236 | + | /// | |
| 237 | + | /// `#[staged]` because [`page_region`] includes it and hands it the | |
| 238 | + | /// standing, which is a value the request brings. A staged `include` whose | |
| 239 | + | /// arguments are not all literal opens a scope and calls the callee's | |
| 240 | + | /// filler, so the callee has to have one; without the flag the call does | |
| 241 | + | /// not resolve and rustc names `membership_fill`. The other direction is | |
| 242 | + | /// [`pitch`], which takes nothing and is `#[constant]` instead. | |
| 243 | + | #[staged] | |
| 237 | 244 | shape membership(standing: &Standing) -> Slot; | |
| 238 | 245 | ||
| 239 | 246 | region MEMBERSHIP as Group { | |
| @@ -248,13 +255,19 @@ | |||
| 248 | 255 | /// What somebody who is not a member is shown, whether or not they have an | |
| 249 | 256 | /// account. The two branches differ only in what they are offered | |
| 250 | 257 | /// afterwards. | |
| 258 | + | /// | |
| 259 | + | /// `#[constant]`: it reads nothing the request brings. `PRICE` is a `const` | |
| 260 | + | /// and a staged path is kept rather than made a hole, so the figure is | |
| 261 | + | /// evaluated once while the residual is derived. The benefits are copy read | |
| 262 | + | /// at macro time, so the loop is unrolled before any AST exists. | |
| 263 | + | #[constant] | |
| 251 | 264 | shape pitch() -> Slot; | |
| 252 | 265 | ||
| 253 | 266 | region PITCH as Group { | |
| 254 | 267 | text "Support the platform and get something back every month."; | |
| 255 | 268 | section "What you get"; | |
| 256 | 269 | list { | |
| 257 | - | for benefit in BENEFITS { | |
| 270 | + | for benefit in copy "content/fan-plus.toml" as benefits { | |
| 258 | 271 | row benefit.name { | |
| 259 | 272 | secondary benefit.detail; | |
| 260 | 273 | } | |
| @@ -394,13 +407,24 @@ | |||
| 394 | 407 | ||
| 395 | 408 | /// The benefits are what the code grants, and the landing page's Fan+ card | |
| 396 | 409 | /// says the same four. Audited 2026-08-05; this keeps the count honest. | |
| 410 | + | /// | |
| 411 | + | /// Reads `content/fan-plus.toml` the way the macro reads it, which is | |
| 412 | + | /// `policy`'s rule: the file is asked what the page should say rather than | |
| 413 | + | /// a second copy of it being held here. The count stays asserted, because | |
| 414 | + | /// the audit is about the number as much as the names. | |
| 397 | 415 | #[test] | |
| 398 | 416 | fn the_four_benefits_are_all_stated() { | |
| 399 | 417 | let html = html(&Standing::Unsubscribed, false); | |
| 400 | 418 | ||
| 401 | - | assert_eq!(BENEFITS.len(), 4); | |
| 402 | - | for benefit in BENEFITS { | |
| 403 | - | assert!(html.contains(benefit.name), "{} missing", benefit.name); | |
| 419 | + | let copy: toml::Table = include_str!("../../content/fan-plus.toml") | |
| 420 | + | .parse() | |
| 421 | + | .expect("the fan-plus copy is TOML"); | |
| 422 | + | let benefits = copy["benefits"].as_array().expect("a list of benefits"); | |
| 423 | + | assert_eq!(benefits.len(), 4); | |
| 424 | + | for benefit in benefits { | |
| 425 | + | let name = benefit["name"].as_str().expect("a name"); | |
| 426 | + | let escaped = crate::helpers::escape_html(name); | |
| 427 | + | assert!(html.contains(&escaped), "{name} missing"); | |
| 404 | 428 | } | |
| 405 | 429 | } | |
| 406 | 430 |
| @@ -669,9 +669,9 @@ | |||
| 669 | 669 | served_document_mount( | |
| 670 | 670 | app, | |
| 671 | 671 | team::PATH, | |
| 672 | - | |_| team::page_screen(), | |
| 672 | + | |_, _| team::page_screen(), | |
| 673 | 673 | team::renderer, | |
| 674 | - | |_| residuals::settled(&residuals::TEAM), | |
| 674 | + | |_, _| residuals::settled(&residuals::TEAM), | |
| 675 | 675 | ), | |
| 676 | 676 | ), | |
| 677 | 677 | ( | |
| @@ -679,9 +679,9 @@ | |||
| 679 | 679 | served_document_mount( | |
| 680 | 680 | app, | |
| 681 | 681 | use_cases::PATH, | |
| 682 | - | |viewer| use_cases::page_screen(&use_cases::prices(viewer)), | |
| 682 | + | |viewer, _| use_cases::page_screen(&use_cases::prices(viewer)), | |
| 683 | 683 | use_cases::renderer, | |
| 684 | - | |viewer| { | |
| 684 | + | |viewer, _| { | |
| 685 | 685 | use_cases::page_region_serve(&residuals::USE_CASES, &use_cases::prices(viewer)) | |
| 686 | 686 | .into() | |
| 687 | 687 | }, | |
| @@ -692,14 +692,27 @@ | |||
| 692 | 692 | served_document_mount( | |
| 693 | 693 | app, | |
| 694 | 694 | policy::PATH, | |
| 695 | - | |_| policy::page_screen(), | |
| 695 | + | |_, _| policy::page_screen(), | |
| 696 | 696 | policy::renderer, | |
| 697 | - | |_| residuals::settled(&residuals::POLICY), | |
| 697 | + | |_, _| residuals::settled(&residuals::POLICY), | |
| 698 | 698 | ), | |
| 699 | 699 | ), | |
| 700 | 700 | ( | |
| 701 | 701 | fan_plus::PATH, | |
| 702 | - | public_document_mount(app, fan_plus::PATH, fan_plus::screen, fan_plus::renderer), | |
| 702 | + | served_document_mount( | |
| 703 | + | app, | |
| 704 | + | fan_plus::PATH, | |
| 705 | + | |viewer, carried| { | |
| 706 | + | let (standing, just_subscribed) = fan_plus::reading(viewer, carried); | |
| 707 | + | fan_plus::page_screen(&standing, just_subscribed) | |
| 708 | + | }, | |
| 709 | + | fan_plus::renderer, | |
| 710 | + | |viewer, carried| { | |
| 711 | + | let (standing, just_subscribed) = fan_plus::reading(viewer, carried); | |
| 712 | + | fan_plus::page_region_serve(&residuals::FAN_PLUS, &standing, just_subscribed) | |
| 713 | + | .into() | |
| 714 | + | }, | |
| 715 | + | ), | |
| 703 | 716 | ), | |
| 704 | 717 | ( | |
| 705 | 718 | creators::PATH, | |
| @@ -810,9 +823,12 @@ | |||
| 810 | 823 | /// builds a `Node`, which is the property the seam exists for; a `Cow` is what | |
| 811 | 824 | /// lets the first path stay a borrow while the second returns a `String`. | |
| 812 | 825 | /// | |
| 813 | - | /// `screen` takes the viewer because a document is not derivable: the shell | |
| 814 | - | /// carries whoever is looking, and a screen with parameters needs them to state | |
| 815 | - | /// its own title and measure. | |
| 826 | + | /// `screen` and `body` take the viewer because a document is not derivable: the | |
| 827 | + | /// shell carries whoever is looking, and a screen with parameters needs them to | |
| 828 | + | /// state its own title and measure. They take the request's parts as well, | |
| 829 | + | /// because a screen may read the address it was reached at: `/fan-plus` shows a | |
| 830 | + | /// welcome banner only on the way back from checkout, which is `?subscribed=true` | |
| 831 | + | /// and is carried nowhere else. | |
| 816 | 832 | /// | |
| 817 | 833 | /// Pairing a body with the wrong screen serves one page's markup inside | |
| 818 | 834 | /// another's document, which nothing here can catch; what catches it is | |
| @@ -821,9 +837,9 @@ | |||
| 821 | 837 | fn served_document_mount( | |
| 822 | 838 | app: &AppState, | |
| 823 | 839 | path: &'static str, | |
| 824 | - | screen: fn(&Viewer) -> quasi_router::Screen, | |
| 840 | + | screen: fn(&Viewer, &Carried) -> quasi_router::Screen, | |
| 825 | 841 | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 826 | - | body: fn(&Viewer) -> std::borrow::Cow<'static, str>, | |
| 842 | + | body: fn(&Viewer, &Carried) -> std::borrow::Cow<'static, str>, | |
| 827 | 843 | ) -> axum::Router { | |
| 828 | 844 | let app = app.clone(); | |
| 829 | 845 | axum::Router::new().route( | |
| @@ -842,8 +858,10 @@ | |||
| 842 | 858 | return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); | |
| 843 | 859 | }; | |
| 844 | 860 | ||
| 845 | - | let markup = body(&viewer); | |
| 846 | - | axum::response::Html(served(&viewer, screen, renderer, &markup)).into_response() | |
| 861 | + | let carried = Carried::of(&parts); | |
| 862 | + | let markup = body(&viewer, &carried); | |
| 863 | + | axum::response::Html(served(&viewer, &carried, screen, renderer, &markup)) | |
| 864 | + | .into_response() | |
| 847 | 865 | } | |
| 848 | 866 | }), | |
| 849 | 867 | ) | |
| @@ -857,11 +875,44 @@ | |||
| 857 | 875 | /// `residuals::tests` asserts and nothing at this level can. | |
| 858 | 876 | fn served( | |
| 859 | 877 | viewer: &Viewer, | |
| 860 | - | screen: fn(&Viewer) -> quasi_router::Screen, | |
| 878 | + | carried: &Carried, | |
| 879 | + | screen: fn(&Viewer, &Carried) -> quasi_router::Screen, | |
| 861 | 880 | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 862 | 881 | markup: &str, | |
| 863 | 882 | ) -> String { | |
| 864 | - | renderer(viewer).served(&screen(viewer), markup) | |
| 883 | + | renderer(viewer).served(&screen(viewer, carried), markup) | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | /// What the address carried, for a screen on the seam that reads it. | |
| 887 | + | /// | |
| 888 | + | /// The adapter builds a `quasi_router::Request` and hands every other screen | |
| 889 | + | /// one; a screen here answers no `Screen`, so it never reaches the adapter and | |
| 890 | + | /// there is no `Request` to read. This is that much of one: the query, parsed | |
| 891 | + | /// the same way, and nothing else. Kept to what a served document actually | |
| 892 | + | /// needs rather than growing into a second `Request` nobody asked for. | |
| 893 | + | pub struct Carried(std::collections::HashMap<String, String>); | |
| 894 | + | ||
| 895 | + | impl Carried { | |
| 896 | + | /// The query the reader arrived with. | |
| 897 | + | fn of(parts: &axum::http::request::Parts) -> Self { | |
| 898 | + | Self( | |
| 899 | + | parts | |
| 900 | + | .uri | |
| 901 | + | .query() | |
| 902 | + | .map(|query| { | |
| 903 | + | url::form_urlencoded::parse(query.as_bytes()) | |
| 904 | + | .map(|(key, value)| (key.into_owned(), value.into_owned())) | |
| 905 | + | .collect() | |
| 906 | + | }) | |
| 907 | + | .unwrap_or_default(), | |
| 908 | + | ) | |
| 909 | + | } | |
| 910 | + | ||
| 911 | + | /// Whether the address carried `key` set to exactly `value`. | |
| 912 | + | #[must_use] | |
| 913 | + | pub fn says(&self, key: &str, value: &str) -> bool { | |
| 914 | + | self.0.get(key).is_some_and(|held| held.trim() == value) | |
| 915 | + | } | |
| 865 | 916 | } | |
| 866 | 917 | ||
| 867 | 918 | /// Every described screen a reader with no session can reach, mounted. |
| @@ -69,6 +69,9 @@ | |||
| 69 | 69 | ("USE_CASES", |plan| { | |
| 70 | 70 | Node::Region(super::use_cases::page_region_staged(plan)) | |
| 71 | 71 | }), | |
| 72 | + | ("FAN_PLUS", |plan| { | |
| 73 | + | Node::Region(super::fan_plus::page_region_staged(plan)) | |
| 74 | + | }), | |
| 72 | 75 | ] | |
| 73 | 76 | } | |
| 74 | 77 | ||
| @@ -317,6 +320,66 @@ | |||
| 317 | 320 | } | |
| 318 | 321 | } | |
| 319 | 322 | ||
| 323 | + | /// A branching screen's residual fills to what the renderer builds, on | |
| 324 | + | /// every branch. | |
| 325 | + | /// | |
| 326 | + | /// `/fan-plus` is the first residual on the seam that carries branches: it | |
| 327 | + | /// asks four questions about the reader and holds the markup of every | |
| 328 | + | /// answer, so one filled render proves nothing. What is checked is every | |
| 329 | + | /// combination of the three standings and both banner states, each against | |
| 330 | + | /// the tree the screen has always built. | |
| 331 | + | /// | |
| 332 | + | /// This is the test that would have caught the span overlap the derivation | |
| 333 | + | /// panicked on (quasi-webview `stage::placed`): two guarded siblings whose | |
| 334 | + | /// markup shares a boundary can be located on top of each other, and a | |
| 335 | + | /// residual built on that serves one branch's markup inside another's. | |
| 336 | + | #[test] | |
| 337 | + | fn the_fan_plus_residual_fills_to_what_the_renderer_builds_on_every_branch() { | |
| 338 | + | use crate::quasi::fan_plus::{Standing, page_region, page_region_serve}; | |
| 339 | + | use quasi_axum::Serves as _; | |
| 340 | + | ||
| 341 | + | let standings = [ | |
| 342 | + | Standing::Visitor, | |
| 343 | + | Standing::Unsubscribed, | |
| 344 | + | Standing::Member { period_end: None }, | |
| 345 | + | Standing::Member { | |
| 346 | + | period_end: Some("March 3, 2027".to_owned()), | |
| 347 | + | }, | |
| 348 | + | ]; | |
| 349 | + | ||
| 350 | + | for standing in &standings { | |
| 351 | + | for just_subscribed in [false, true] { | |
| 352 | + | let filled = page_region_serve(&FAN_PLUS, standing, just_subscribed); | |
| 353 | + | let rendered = | |
| 354 | + | Webview::new().fragment(&Node::Region(page_region(standing, just_subscribed))); | |
| 355 | + | ||
| 356 | + | assert_eq!(filled, rendered, "just_subscribed={just_subscribed}"); | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | /// The branches a reader never sees are still in the compiled markup. | |
| 362 | + | /// | |
| 363 | + | /// The property that makes the seam worth having on a screen like this: all | |
| 364 | + | /// three readers get a compiled page, so nothing is rendered per request for | |
| 365 | + | /// the sake of the two answers this reader did not give. A literal going | |
| 366 | + | /// missing here means a branch stopped being derived, which the equality | |
| 367 | + | /// above would still pass if both paths lost it together. | |
| 368 | + | #[test] | |
| 369 | + | fn the_fan_plus_residual_holds_every_reader_s_markup() { | |
| 370 | + | let compiled = format!("{:?}", FAN_PLUS.ops()); | |
| 371 | + | ||
| 372 | + | for words in [ | |
| 373 | + | "Fan+ membership is active", | |
| 374 | + | "Support the platform", | |
| 375 | + | "Join Fan+", | |
| 376 | + | "Create an account", | |
| 377 | + | "now a Fan+ member", | |
| 378 | + | ] { | |
| 379 | + | assert!(compiled.contains(words), "{words} is not in the residual"); | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 320 | 383 | /// Every screen the roster names is checked above. | |
| 321 | 384 | /// | |
| 322 | 385 | /// The gap this closes is the one a table opens: a screen added to | |
| @@ -330,8 +393,8 @@ | |||
| 330 | 393 | /// the two counts have to be kept by hand or not at all. | |
| 331 | 394 | #[test] | |
| 332 | 395 | fn every_screen_on_the_seam_is_checked() { | |
| 333 | - | /// Screens with their own filling test: `/use-cases`. | |
| 334 | - | const HOLED: usize = 1; | |
| 396 | + | /// Screens with their own filling test: `/use-cases`, `/fan-plus`. | |
| 397 | + | const HOLED: usize = 2; | |
| 335 | 398 | ||
| 336 | 399 | assert_eq!( | |
| 337 | 400 | roster().len(), |
| @@ -60,3 +60,48 @@ | |||
| 60 | 60 | )), | |
| 61 | 61 | ], | |
| 62 | 62 | ); | |
| 63 | + | ||
| 64 | + | pub static FAN_PLUS: ::quasi_router::stage::Residual = ::quasi_router::stage::Residual::compiled( | |
| 65 | + | &[ | |
| 66 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 67 | + | "<div id=\"fan-plus\" class=\"region pane\"><h1 class=\"heading\">Fan+</h1", | |
| 68 | + | )), | |
| 69 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 70 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 71 | + | "><div class=\"banner\" data-tone=\"success\" role=\"status\" aria-live=\"polite\">You're now a Fan+ member. Welcome.</div", | |
| 72 | + | )), | |
| 73 | + | ])), | |
| 74 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 75 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 76 | + | "><section id=\"fan-plus-membership\" class=\"region group\"><p class=\"text\">Your Fan+ membership is active.", | |
| 77 | + | )), | |
| 78 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 79 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 80 | + | "</p><p class=\"text\">Current period ends: ", | |
| 81 | + | )), | |
| 82 | + | ::quasi_router::stage::Op::Hole { scope: 1, id: 0 }, | |
| 83 | + | ])), | |
| 84 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 85 | + | "</p><p class=\"text\">You'll receive a $5 credit code each billing cycle via email.</p><div class=\"anchored\" id=\"fan-plus-membership-anchored\" data-menu=\"anchored\" hidden></div></section", | |
| 86 | + | )), | |
| 87 | + | ])), | |
| 88 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 89 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 90 | + | "><section id=\"fan-plus-pitch\" class=\"region group\"><p class=\"text\">Support the platform and get something back every month.</p><h2 class=\"heading\">What you get</h2><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">$5 monthly credit</span><span class=\"row-secondary\">A promo code delivered by email each billing cycle, usable toward any purchase on the platform</span></li><li class=\"row\" data-row><span class=\"row-primary\">+ badge</span><span class=\"row-secondary\">Displayed next to your name on your forum posts</span></li><li class=\"row\" data-row><span class=\"row-primary\">Forum signatures</span><span class=\"row-secondary\">A signature block rendered under everything you post</span></li><li class=\"row\" data-row><span class=\"row-primary\">Image embeds</span><span class=\"row-secondary\">Post images in forum threads</span></li></ul><div class=\"figures\"><div class=\"figure\" aria-label=\"per month: $8\"><span class=\"figure-value\" aria-hidden=\"true\">$8</span><span class=\"figure-caption\" aria-hidden=\"true\">per month</span></div></div><p class=\"text\">Makenotwork is built on 0% platform fees. Fan+ is how you directly support the platform's development and operations, while getting real value back each month.</p><div class=\"anchored\" id=\"fan-plus-pitch-anchored\" data-menu=\"anchored\" hidden></div></section", | |
| 91 | + | )), | |
| 92 | + | ])), | |
| 93 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 94 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 95 | + | "><button type=\"button\" class=\"button\" data-act hx-post=\"/stripe/fan-plus\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Join Fan+</button", | |
| 96 | + | )), | |
| 97 | + | ])), | |
| 98 | + | ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[ | |
| 99 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 100 | + | "><div class=\"rich\" data-disable-scripting><p><a href=\"/join\" rel=\"noopener noreferrer\">Create an account</a> to join, or <a href=\"/login\" rel=\"noopener noreferrer\">log in</a> if you already have one.</p>\n</div", | |
| 101 | + | )), | |
| 102 | + | ])), | |
| 103 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 104 | + | "><div class=\"anchored\" id=\"fan-plus-anchored\" data-menu=\"anchored\" hidden></div></div>", | |
| 105 | + | )), | |
| 106 | + | ], | |
| 107 | + | ); |
| @@ -1,0 +1,33 @@ | |||
| 1 | + | # The copy at `/fan-plus`, which is words and nothing else. | |
| 2 | + | # | |
| 3 | + | # Read at build time by the `declare!` in `src/quasi/fan_plus.rs`. Editing this | |
| 4 | + | # file is editing the page. See `content/policy.toml` for the split this follows. | |
| 5 | + | # | |
| 6 | + | # KEEP `benefits` AND THE FAN+ CARD ON THE LANDING PAGE IDENTICAL, AND KEEP BOTH | |
| 7 | + | # TO WHAT THE CODE GRANTS. Audited 2026-08-05, carried from the template because | |
| 8 | + | # it is a rule rather than a note: a benefit listed here and not granted by the | |
| 9 | + | # code is a promise nobody implemented. | |
| 10 | + | ||
| 11 | + | [[benefits]] | |
| 12 | + | name = "$5 monthly credit" | |
| 13 | + | detail = "A promo code delivered by email each billing cycle, usable toward any purchase on the platform" | |
| 14 | + | ||
| 15 | + | [[benefits]] | |
| 16 | + | name = "+ badge" | |
| 17 | + | detail = "Displayed next to your name on your forum posts" | |
| 18 | + | ||
| 19 | + | [[benefits]] | |
| 20 | + | name = "Forum signatures" | |
| 21 | + | detail = "A signature block rendered under everything you post" | |
| 22 | + | ||
| 23 | + | [[benefits]] | |
| 24 | + | name = "Image embeds" | |
| 25 | + | detail = "Post images in forum threads" | |
| 26 | + | ||
| 27 | + | # What a reader with no account is told, markdown through docengine. In the file | |
| 28 | + | # rather than a Rust `const` so the sentence folds into the residual: a `const` | |
| 29 | + | # is a path and not a literal, so an `include` of one opens a staged scope and | |
| 30 | + | # the sentence is built and rendered on every request. | |
| 31 | + | ||
| 32 | + | [[visitor]] | |
| 33 | + | body = "[Create an account](/join) to join, or [log in](/login) if you already have one." |