Skip to main content

max / makenotwork

Describe the use-cases page, and delete the marketing CSS it owned The second public document, and the first with real content: nine creator profiles, four platform promises, a call to action and three onward links. Two places the vocabulary pushed back, both recorded in the module header. A card holding a bullet list is a region rather than a row, because Row refuses to carry a node on purpose; the four cards that hold only a sentence stay rows. And the grid is not described: nine equal cards are nine sibling groups, and how they sit is the design system's answer rather than a grid-template-columns in this page's CSS. Every tier line is built from TierPrices rather than written out, so a price change in assumptions.toml moves the page. Priced names the shape of the line, which keeps the one card that fits two tiers (Educators) from being flattened into a formatted string. Deletes templates/pages/use_cases.html, UseCasesTemplate, landing::use_cases_page, and 60 lines of style.css: every .use-case-*, .feature-*, .explore-links and .landing-cta rule had zero users left across templates, static and frontend once the template went. The measure moved onto .use-cases-page main, as .team-page main did.
Author: Max Johnson <me@maxj.phd> · 2026-09-01 12:33 UTC
Signed with PGP, not checked
Commit: 84f0886bf70cc477d4c991bab3c481e65f274d46
Parent: c17687d
8 files changed, +469 insertions, -330 deletions
@@ -1275,7 +1275,7 @@
1275 1275 Variants:
1276 1276 .card: filled, hover-step (default content card).
1277 1277 .card.card-muted: surface-muted fill, no hover (dashboard stat / analytics blocks).
1278 - .card.card--bordered: bordered, padded, no fill (marketing cards: feature/tier/use-case).
1278 + .card.card--bordered: bordered, padded, no fill (marketing cards: tier, fork).
1279 1279 .card.card--selectable: radio-card pattern; pair with .is-selected.
1280 1280 .card.card--grid: grid-cell card (discover grid).
1281 1281 No aliases. A template names the modifier it wants and adds its own
@@ -1305,15 +1305,15 @@
1305 1305 padding: var(--gap-pane);
1306 1306 }
1307 1307 /* Bordered card. Templates put this modifier on the element alongside any
1308 - component class of their own (.tier-card, .use-case-card, .fork-card), so
1309 - the name the charter teaches is the name in the markup. */
1308 + component class of their own (.tier-card, .fork-card), so the name the
1309 + charter teaches is the name in the markup. A described screen names none of
1310 + them: a card there is a region, and the design system draws it. */
1310 1311 .card--bordered {
1311 1312 background: transparent;
1312 1313 padding: var(--gap-section);
1313 1314 border: 1px solid var(--border);
1314 1315 margin-bottom: 0;
1315 1316 }
1316 - .use-case-card { padding: var(--gap-section); }
1317 1317 .fork-card { padding: var(--gap-pane); display: flex; flex-direction: column; }
1318 1318
1319 1319 .card-title {
@@ -6223,14 +6223,6 @@
6223 6223 border-style: dashed;
6224 6224 position: relative;
6225 6225 }
6226 - .landing-cta {
6227 - display: flex;
6228 - align-items: center;
6229 - justify-content: center;
6230 - gap: var(--gap-page);
6231 - width: fit-content;
6232 - margin: var(--gap-pane) auto var(--gap-page);
6233 - }
6234 6226 .secondary-links {
6235 6227 display: flex;
6236 6228 gap: var(--gap-pane);
@@ -6260,25 +6252,6 @@
6260 6252 opacity: 1;
6261 6253 }
6262 6254
6263 - .feature-grid {
6264 - display: grid;
6265 - grid-template-columns: 1fr 1fr;
6266 - gap: var(--gap-section);
6267 - text-align: left;
6268 - }
6269 -
6270 - .feature-name {
6271 - font-family: var(--font-mono);
6272 - font-size: var(--text-note);
6273 - font-weight: 600;
6274 - margin-bottom: var(--gap-bound);
6275 - }
6276 -
6277 - .feature-desc {
6278 - font-size: var(--text-note);
6279 - opacity: 0.7;
6280 - }
6281 -
6282 6255 .how-list {
6283 6256 list-style: none;
6284 6257 margin: 0;
@@ -6407,24 +6380,6 @@
6407 6380 border-bottom-color: currentColor;
6408 6381 }
6409 6382
6410 - .explore-links {
6411 - display: flex;
6412 - flex-wrap: wrap;
6413 - gap: var(--gap-section) var(--gap-pane);
6414 - justify-content: center;
6415 - }
6416 -
6417 - .explore-links a {
6418 - font-family: var(--font-mono);
6419 - font-size: var(--text-note);
6420 - opacity: 0.7;
6421 - transition: opacity 0.2s ease;
6422 - }
6423 -
6424 - .explore-links a:hover {
6425 - opacity: 1;
6426 - }
6427 -
6428 6383 /* ===========================================
6429 6384 PRICING CALCULATOR
6430 6385 =========================================== */
@@ -6723,73 +6678,19 @@
6723 6678 USE CASES PAGE
6724 6679 =========================================== */
6725 6680
6726 - .use-cases-page {
6681 + /* The use-cases page moved to the description layer; what the template's own
6682 + classes said is drawn by the design system now. What is left is the measure,
6683 + which a described document carries on <main> rather than on a container div,
6684 + the same shape as `.team-page main` and `.feed-page main`.
6685 +
6686 + The grid went with it deliberately: nine equal cards are nine sibling groups
6687 + in the description, and how they sit is the design system's answer for every
6688 + consumer rather than a `grid-template-columns` here. */
6689 + .use-cases-page main {
6727 6690 max-width: 900px;
6728 6691 margin: 0 auto;
6729 6692 }
6730 6693
6731 - .use-cases-intro {
6732 - font-size: var(--text-lead);
6733 - margin-bottom: var(--gap-page);
6734 - line-height: 1.6;
6735 - }
6736 -
6737 - .use-case-grid {
6738 - display: grid;
6739 - grid-template-columns: 1fr 1fr 1fr;
6740 - gap: var(--gap-section);
6741 - text-align: left;
6742 - }
6743 -
6744 - .use-case-grid-2col {
6745 - grid-template-columns: 1fr 1fr;
6746 - }
6747 -
6748 - .use-case-title {
6749 - font-family: var(--font-display);
6750 - font-size: var(--text-lead);
6751 - margin-bottom: var(--gap-bound);
6752 - }
6753 -
6754 - .use-case-who {
6755 - font-family: var(--font-mono);
6756 - font-size: var(--text-fine);
6757 - opacity: 0.6;
6758 - margin-bottom: var(--gap-peer);
6759 - }
6760 -
6761 - .use-case-desc {
6762 - font-size: var(--text-note);
6763 - line-height: 1.5;
6764 - margin-bottom: var(--gap-peer);
6765 - }
6766 -
6767 - .use-case-features {
6768 - list-style: none;
6769 - margin: 0 0 var(--gap-section);
6770 - padding: 0;
6771 - }
6772 -
6773 - .use-case-features li {
6774 - font-size: var(--text-note);
6775 - padding-left: var(--gap-section);
6776 - position: relative;
6777 - margin-bottom: var(--gap-bound);
6778 - }
6779 -
6780 - .use-case-features li::before {
6781 - content: "-";
6782 - position: absolute;
6783 - left: 0;
6784 - opacity: 0.5;
6785 - }
6786 -
6787 - .use-case-tier {
6788 - font-family: var(--font-mono);
6789 - font-size: var(--text-fine);
6790 - opacity: 0.5;
6791 - }
6792 -
6793 6694 /* ===========================================
6794 6695 RESPONSIVE, 768px (tablet)
6795 6696 =========================================== */
@@ -7601,19 +7502,10 @@
7601 7502
7602 7503 /* Landing page (merged from 500px) */
7603 7504 .landing-fork,
7604 - .tier-selector,
7605 - .feature-grid,
7606 - .use-case-grid,
7607 - .use-case-grid-2col {
7505 + .tier-selector {
7608 7506 grid-template-columns: 1fr;
7609 7507 }
7610 7508
7611 - .explore-links {
7612 - flex-direction: column;
7613 - align-items: center;
7614 - gap: var(--gap-section);
7615 - }
7616 -
7617 7509 .pricing-input {
7618 7510 font-size: var(--text-title);
7619 7511 }
@@ -7636,11 +7528,6 @@
7636 7528 gap: var(--gap-section);
7637 7529 }
7638 7530
7639 - .landing-cta {
7640 - flex-direction: column;
7641 - gap: var(--gap-section);
7642 - }
7643 -
7644 7531 .toast-container {
7645 7532 left: 0.75rem;
7646 7533 right: 0.75rem;
@@ -68,6 +68,7 @@
68 68 pub mod ssh_keys;
69 69 pub mod team;
70 70 pub mod upload_field;
71 + pub mod use_cases;
71 72 pub mod user_analytics;
72 73 pub mod user_projects;
73 74 pub mod user_support;
@@ -385,7 +386,7 @@
385 386 /// mechanism: those screens resolve nothing per request and take a state built
386 387 /// once at startup. These resolve a session when there is one, so they carry a
387 388 /// per-request viewer and can mint a CSRF token for the form on them.
388 - pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[team::PATH];
389 + pub const PUBLIC_DOCUMENT_PATHS: &[&str] = &[team::PATH, use_cases::PATH];
389 390
390 391 /// Every screen's switch name, in the same order as [`PATHS`].
391 392 ///
@@ -562,10 +563,16 @@
562 563 /// See [`PUBLIC_DOCUMENT_PATHS`] for why these are neither in
563 564 /// [`document_mounts`] nor in [`public_mounts`].
564 565 pub fn public_document_mounts(app: &AppState) -> Vec<(&'static str, axum::Router)> {
565 - vec![(
566 - team::PATH,
567 - public_document_mount(app, team::PATH, team::screen, team::renderer),
568 - )]
566 + vec![
567 + (
568 + team::PATH,
569 + public_document_mount(app, team::PATH, team::screen, team::renderer),
570 + ),
571 + (
572 + use_cases::PATH,
573 + public_document_mount(app, use_cases::PATH, use_cases::screen, use_cases::renderer),
574 + ),
575 + ]
569 576 }
570 577
571 578 /// A described document a reader NAVIGATES to with or without a session.
@@ -177,7 +177,6 @@
177 177 // Platform economics + runway disclosure
178 178 EconomicsTemplate,
179 179 // Use cases
180 - UseCasesTemplate,
181 180 // Team
182 181 // Fan+
183 182 FanPlusTemplate,
@@ -904,15 +904,6 @@
904 904 pub trialing_or_grace: i64,
905 905 }
906 906
907 - /// Use cases page showcasing creator types.
908 - #[derive(Template)]
909 - #[template(path = "pages/use_cases.html")]
910 - pub struct UseCasesTemplate {
911 - pub csrf_token: CsrfTokenOption,
912 - pub session_user: Option<SessionUser>,
913 - pub tier_prices: crate::tier_prices::TierPrices,
914 - }
915 -
916 907 // Creator Invite System
917 908
918 909 /// Public page: creator signup, tier pricing, and the active-creator count.
@@ -24,7 +24,7 @@
24 24 templates::{
25 25 CarouselFrame, CartTemplate, EconomicsTemplate, FanPlusTemplate, IndexTemplate,
26 26 LandingVelocity, LibraryCollectionsTabTemplate, LibraryPurchasesTabTemplate,
27 - LibraryTemplate, LoginTemplate, PolicyTemplate, UseCasesTemplate,
27 + LibraryTemplate, LoginTemplate, PolicyTemplate,
28 28 },
29 29 types::{Collection, UserSubscription},
30 30 };
@@ -485,20 +485,6 @@
485 485 )
486 486 }
487 487
488 - /// Render the use cases page.
489 - #[tracing::instrument(skip_all, name = "landing::use_cases_page")]
490 - pub(super) async fn use_cases_page(
491 - State(payments): State<Billing>,
492 - session: Session,
493 - MaybeUserUnverified(maybe_user): MaybeUserUnverified,
494 - ) -> impl IntoResponse {
495 - UseCasesTemplate {
496 - csrf_token: get_csrf_token(&session).await,
497 - session_user: maybe_user,
498 - tier_prices: payments.tier_prices.clone(),
499 - }
500 - }
501 -
502 488 /// Render the content policy page.
503 489 #[tracing::instrument(skip_all, name = "landing::policy_page")]
504 490 pub(super) async fn policy_page(
@@ -134,7 +134,6 @@
134 134 .route_get("/receipt/{transaction_id}", get(content::receipt_page))
135 135 .route_get("/buy/{item_id}", get(content::buy_page))
136 136 .route_get("/checkout/complete", get(landing::checkout_complete))
137 - .route_get("/use-cases", get(landing::use_cases_page))
138 137 .route_get("/policy", get(landing::policy_page))
139 138 .route_get("/fan-plus", get(landing::fan_plus_page))
140 139 // Landing "notify me". CSRF-protected like the other public forms, and
@@ -1,0 +1,442 @@
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 + 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 + };
48 + use quasi_webview::Webview;
49 +
50 + use crate::tier_prices::TierPrices;
51 +
52 + /// The address, registered whole. See [`super::public_document_mount`].
53 + pub const PATH: &str = "/use-cases";
54 +
55 + /// The page's own region, and what the skip link points at.
56 + pub const PAGE_REGION: &str = "use-cases";
57 +
58 + /// The nine profiles.
59 + const PROFILES: &str = "use-case-profiles";
60 +
61 + /// What every tier includes.
62 + const UNIVERSAL: &str = "everyone-gets";
63 +
64 + const MEASURE: layout::Measure = layout::Measure::Wide;
65 +
66 + /// One creator profile.
67 + ///
68 + /// `anchor` is the template's `id=`, kept because the marketing site links to
69 + /// these cards directly (`/use-cases#podcasters`) and a conversion that drops
70 + /// the id breaks somebody else's link rather than this page.
71 + struct Profile {
72 + anchor: &'static str,
73 + title: &'static str,
74 + who: &'static str,
75 + description: &'static str,
76 + features: &'static [&'static str],
77 + /// Which tier line the card ends with. See [`Priced`].
78 + priced: Priced,
79 + }
80 +
81 + /// How a card states its price.
82 + ///
83 + /// The template wrote three shapes of tier line by hand, and one of them
84 + /// (Educators) names two tiers. Naming the shape rather than the string is what
85 + /// keeps every price on this page coming from `TierPrices` -- a formatted string
86 + /// in the table here would be a fourth place a price can go stale.
87 + #[derive(Clone, Copy)]
88 + enum Priced {
89 + Basic,
90 + SmallFiles,
91 + BigFiles,
92 + /// "Basic $X/mo or Small Files $Y/mo", which is Educators and only
93 + /// Educators: the work fits in either tier depending on what is uploaded.
94 + BasicOrSmallFiles,
95 + }
96 +
97 + impl Priced {
98 + /// The line the card ends with, built from the live prices.
99 + fn line(self, prices: &TierPrices) -> String {
100 + match self {
101 + Self::Basic => format!(
102 + "Basic ${}/mo · {}, {}/file",
103 + prices.basic_std, prices.basic_total, prices.basic_per_file
104 + ),
105 + Self::SmallFiles => format!(
106 + "Small Files ${}/mo · {}, {}/file",
107 + prices.small_files_std, prices.small_files_total, prices.small_files_per_file
108 + ),
109 + Self::BigFiles => format!(
110 + "Big Files ${}/mo · {}, {}/file",
111 + prices.big_files_std, prices.big_files_total, prices.big_files_per_file
112 + ),
113 + Self::BasicOrSmallFiles => format!(
114 + "Basic ${}/mo or Small Files ${}/mo",
115 + prices.basic_std, prices.small_files_std
116 + ),
117 + }
118 + }
119 + }
120 +
121 + /// The nine, in the order the template listed them.
122 + const PROFILE_LIST: &[Profile] = &[
123 + Profile {
124 + anchor: "musicians",
125 + title: "Musicians",
126 + who: "Bands, solo artists, producers",
127 + description: "Sell albums, singles, and EPs with in-browser streaming, chapter markers, and cover art.",
128 + features: &[
129 + "MP3, FLAC, WAV, OGG, AAC, AIFF upload",
130 + "In-browser audio player with chapters",
131 + "Cover art and rich metadata",
132 + "RSS feed per project",
133 + "Pay-what-you-want pricing",
134 + ],
135 + priced: Priced::SmallFiles,
136 + },
137 + Profile {
138 + anchor: "podcasters",
139 + title: "Podcasters",
140 + who: "Independent shows, networks",
141 + description: "Host and distribute your podcast with full RSS support for every major directory.",
142 + features: &[
143 + "RSS for Apple, Spotify, Pocket Casts",
144 + "Chapter markers",
145 + "Subscriber-only feeds",
146 + "Broadcast emails to followers",
147 + ],
148 + priced: Priced::SmallFiles,
149 + },
150 + Profile {
151 + anchor: "writers",
152 + title: "Writers",
153 + who: "Bloggers, newsletter writers, authors",
154 + description: "Publish long-form writing with Markdown, per-project blogs, and subscriber broadcasts.",
155 + features: &[
156 + "Markdown editor with formatting",
157 + "Per-project blog",
158 + "RSS feed",
159 + "Scheduled publishing",
160 + "Broadcast emails",
161 + ],
162 + priced: Priced::Basic,
163 + },
164 + Profile {
165 + anchor: "developers",
166 + title: "Software Developers",
167 + who: "Indie devs, tool makers, plugin authors",
168 + description: "Distribute software with versioned releases, license keys, and a built-in git browser.",
169 + features: &[
170 + "Versioned releases with changelogs",
171 + "License keys with activation tracking",
172 + "Git source browser",
173 + "Promo codes and discounts",
174 + ],
175 + priced: Priced::SmallFiles,
176 + },
177 + Profile {
178 + anchor: "sample-packs",
179 + title: "Sample Packs",
180 + who: "Sound designers, beat makers, foley artists",
181 + description: "Sell sample packs and sound effects with audio previews and versioned downloads.",
182 + features: &[
183 + "Audio previews in-browser",
184 + "Versioned downloads",
185 + "Hierarchical tags for organization",
186 + "Pay-what-you-want pricing",
187 + "Cover art",
188 + ],
189 + priced: Priced::SmallFiles,
190 + },
191 + Profile {
192 + anchor: "digital-art",
193 + title: "Digital Art & Photography",
194 + who: "Illustrators, photographers, font designers",
195 + description: "Sell digital art, photography, and fonts with versioned releases and subscription tiers.",
196 + features: &[
197 + "Any file type supported",
198 + "Versioned releases",
199 + "Subscription tiers",
200 + "Cover images",
201 + ],
202 + priced: Priced::SmallFiles,
203 + },
204 + Profile {
205 + anchor: "educators",
206 + title: "Educators",
207 + who: "Course creators, tutors, workshop leaders",
208 + description: "Publish courses and teaching materials with text content, downloads, and subscriptions.",
209 + features: &[
210 + "Text content with Markdown",
211 + "Downloadable materials",
212 + "Subscription tiers",
213 + "Scheduled publishing",
214 + ],
215 + priced: Priced::BasicOrSmallFiles,
216 + },
217 + Profile {
218 + anchor: "game-devs",
219 + title: "Game Developers",
220 + who: "Indie games, mods, TTRPG content",
221 + description: "Distribute games up to 20GB per file with versioned downloads, license keys, and promo codes. Need more than 20GB? Request a size increase from your dashboard.",
222 + features: &[
223 + "Up to 20GB per file (increase on request)",
224 + "Versioned downloads with changelogs",
225 + "License keys with activation limits",
226 + "Promo codes",
227 + ],
228 + priced: Priced::BigFiles,
229 + },
230 + Profile {
231 + anchor: "comic-creators",
232 + title: "Comic Creators",
233 + who: "Webcomic artists, graphic novelists",
234 + description: "Sell digital comics and graphic novels with project organization and scheduled publishing.",
235 + features: &[
236 + "PDF, CBZ, and any file type",
237 + "Project organization",
238 + "Subscriptions",
239 + "Scheduled publishing",
240 + ],
241 + priced: Priced::SmallFiles,
242 + },
243 + ];
244 +
245 + /// 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 + ),
263 + ];
264 +
265 + /// Where the page sends a reader who is convinced, and where it sends one who
266 + /// is not yet.
267 + const ONWARD: &[(&str, &str)] = &[
268 + ("Pricing Calculator", "/pricing"),
269 + ("Browse as guest", "/discover"),
270 + ("Creator Guide", "/docs/getting-started"),
271 + ];
272 +
273 + /// The page. Reads no database, only the prices already in memory.
274 + pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
275 + use axum::extract::FromRef as _;
276 +
277 + let billing = crate::Billing::from_ref(&viewer.app);
278 + Ok(page_screen(&billing.tier_prices).into())
279 + }
280 +
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)));
286 + }
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 + }
325 +
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)));
332 +
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)))
339 + }
340 +
341 + /// The document this screen is drawn in. Same shape as `/team`'s: the skip
342 + /// link and the site header, which reads the viewer's user through an `Option`
343 + /// because on this mount there may not be one.
344 + #[must_use]
345 + pub fn renderer(viewer: &super::Viewer) -> Webview {
346 + Webview::new().with_shell(viewer.document_shell().with_body_first(format!(
347 + "{}{}",
348 + crate::shell::skip_link(PAGE_REGION),
349 + crate::shell::site_header(viewer.user.as_ref(), Some(&viewer.csrf)),
350 + )))
351 + }
352 +
353 + #[cfg(test)]
354 + mod tests {
355 + use super::*;
356 +
357 + fn prices() -> TierPrices {
358 + TierPrices::default()
359 + }
360 +
361 + fn html() -> String {
362 + use quasi_axum::Serves as _;
363 +
364 + Webview::new().screen(&page_screen(&prices()))
365 + }
366 +
367 + /// `2790e5c4`. The template wrote the measure on the body and
368 + /// `use-cases-page` on a container div; a described document has no
369 + /// container, so both land on the body. Same merge `/team` found.
370 + #[test]
371 + fn the_document_carries_the_classes_the_template_carried() {
372 + let screen = page_screen(&prices());
373 +
374 + assert_eq!(
375 + screen.document.body_class.as_deref(),
376 + Some("padded-page use-cases-page")
377 + );
378 + }
379 +
380 + /// Every card the template drew is still drawn, and still reachable by the
381 + /// anchor the marketing links use.
382 + #[test]
383 + fn every_profile_keeps_its_card_and_its_anchor() {
384 + let html = html();
385 +
386 + assert_eq!(PROFILE_LIST.len(), 9, "the template drew nine");
387 + for profile in PROFILE_LIST {
388 + assert!(html.contains(profile.anchor), "{} missing", profile.anchor);
389 + assert!(html.contains(profile.who), "{} missing", profile.who);
390 + for feature in profile.features {
391 + assert!(
392 + html.contains(feature),
393 + "{feature} missing from {}",
394 + profile.title
395 + );
396 + }
397 + }
398 + }
399 +
400 + /// No price is written into this module. Each tier line is built from
401 + /// `TierPrices`, so a price change in `assumptions.toml` moves this page
402 + /// with it.
403 + #[test]
404 + fn every_tier_line_comes_from_the_live_prices() {
405 + let mut prices = prices();
406 + prices.basic_std = 4321;
407 + prices.small_files_std = 5678;
408 + prices.big_files_std = 8765;
409 +
410 + let html = {
411 + use quasi_axum::Serves as _;
412 + Webview::new().screen(&page_screen(&prices))
413 + };
414 +
415 + assert!(html.contains("4321"), "the Basic price is not read: {html}");
416 + assert!(html.contains("5678"), "the Small Files price is not read");
417 + assert!(html.contains("8765"), "the Big Files price is not read");
418 + }
419 +
420 + /// The educators card is the only one naming two tiers, and it is the case
421 + /// a formatted string in the table would have quietly flattened.
422 + #[test]
423 + fn the_educators_card_names_both_tiers_it_fits_in() {
424 + let mut prices = prices();
425 + prices.basic_std = 4321;
426 + prices.small_files_std = 5678;
427 +
428 + let line = Priced::BasicOrSmallFiles.line(&prices);
429 +
430 + assert!(line.contains("4321") && line.contains("5678"), "{line}");
431 + }
432 +
433 + /// `736f45a5`: this screen's markup carries none of the four spellings.
434 + #[test]
435 + fn the_page_spells_no_spinner() {
436 + let html = html();
437 +
438 + for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
439 + assert!(!html.contains(spelling), "{spelling} survives in {html}");
440 + }
441 + }
442 + }
@@ -1,172 +1,0 @@
1 - {% extends "base.html" %}
2 -
3 - {% block title %}Use Cases - Makenotwork{% endblock %}
4 - {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }}"{% endblock %}
5 -
6 - {% block content %}
7 - {% include "partials/site_header.html" %}
8 -
9 - <div class="container use-cases-page">
10 - <h1 class="page-title">Use Cases</h1>
11 - <p class="use-cases-intro">A flat monthly fee. 0% platform cut. Who it's built for:</p>
12 -
13 - <div class="tier-section">
14 - <h2 class="section-label">Available now</h2>
15 - <div class="use-case-grid">
16 - <div class="card--bordered use-case-card" id="musicians">
17 - <div class="use-case-title">Musicians</div>
18 - <div class="use-case-who">Bands, solo artists, producers</div>
19 - <p class="use-case-desc">Sell albums, singles, and EPs with in-browser streaming, chapter markers, and cover art.</p>
20 - <ul class="use-case-features">
21 - <li>MP3, FLAC, WAV, OGG, AAC, AIFF upload</li>
22 - <li>In-browser audio player with chapters</li>
23 - <li>Cover art and rich metadata</li>
24 - <li>RSS feed per project</li>
25 - <li>Pay-what-you-want pricing</li>
26 - </ul>
27 - <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo &middot; {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div>
28 - </div>
29 -
30 - <div class="card--bordered use-case-card" id="podcasters">
31 - <div class="use-case-title">Podcasters</div>
32 - <div class="use-case-who">Independent shows, networks</div>
33 - <p class="use-case-desc">Host and distribute your podcast with full RSS support for every major directory.</p>
34 - <ul class="use-case-features">
35 - <li>RSS for Apple, Spotify, Pocket Casts</li>
36 - <li>Chapter markers</li>
37 - <li>Subscriber-only feeds</li>
38 - <li>Broadcast emails to followers</li>
39 - </ul>
40 - <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo &middot; {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div>
41 - </div>
42 -
43 - <div class="card--bordered use-case-card" id="writers">
44 - <div class="use-case-title">Writers</div>
45 - <div class="use-case-who">Bloggers, newsletter writers, authors</div>
46 - <p class="use-case-desc">Publish long-form writing with Markdown, per-project blogs, and subscriber broadcasts.</p>
47 - <ul class="use-case-features">
48 - <li>Markdown editor with formatting</li>
49 - <li>Per-project blog</li>
50 - <li>RSS feed</li>
51 - <li>Scheduled publishing</li>
52 - <li>Broadcast emails</li>
53 - </ul>
54 - <div class="use-case-tier">Basic ${{ tier_prices.basic_std }}/mo &middot; {{ tier_prices.basic_total }}, {{ tier_prices.basic_per_file }}/file</div>
55 - </div>
56 -
57 - <div class="card--bordered use-case-card" id="developers">
58 - <div class="use-case-title">Software Developers</div>
59 - <div class="use-case-who">Indie devs, tool makers, plugin authors</div>
60 - <p class="use-case-desc">Distribute software with versioned releases, license keys, and a built-in git browser.</p>
61 - <ul class="use-case-features">
62 - <li>Versioned releases with changelogs</li>
63 - <li>License keys with activation tracking</li>
64 - <li>Git source browser</li>
65 - <li>Promo codes and discounts</li>
66 - </ul>
67 - <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo &middot; {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div>
68 - </div>
69 -
70 - <div class="card--bordered use-case-card" id="sample-packs">
71 - <div class="use-case-title">Sample Packs</div>
72 - <div class="use-case-who">Sound designers, beat makers, foley artists</div>
73 - <p class="use-case-desc">Sell sample packs and sound effects with audio previews and versioned downloads.</p>
74 - <ul class="use-case-features">
75 - <li>Audio previews in-browser</li>
76 - <li>Versioned downloads</li>
77 - <li>Hierarchical tags for organization</li>
78 - <li>Pay-what-you-want pricing</li>
79 - <li>Cover art</li>
80 - </ul>
81 - <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo &middot; {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div>
82 - </div>
83 -
84 - <div class="card--bordered use-case-card" id="digital-art">
85 - <div class="use-case-title">Digital Art &amp; Photography</div>
86 - <div class="use-case-who">Illustrators, photographers, font designers</div>
87 - <p class="use-case-desc">Sell digital art, photography, and fonts with versioned releases and subscription tiers.</p>
88 - <ul class="use-case-features">
89 - <li>Any file type supported</li>
90 - <li>Versioned releases</li>
91 - <li>Subscription tiers</li>
92 - <li>Cover images</li>
93 - </ul>
94 - <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo &middot; {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div>
95 - </div>
96 -
97 - <div class="card--bordered use-case-card" id="educators">
98 - <div class="use-case-title">Educators</div>
99 - <div class="use-case-who">Course creators, tutors, workshop leaders</div>
100 - <p class="use-case-desc">Publish courses and teaching materials with text content, downloads, and subscriptions.</p>
101 - <ul class="use-case-features">
102 - <li>Text content with Markdown</li>
103 - <li>Downloadable materials</li>
104 - <li>Subscription tiers</li>
105 - <li>Scheduled publishing</li>
106 - </ul>
107 - <div class="use-case-tier">Basic ${{ tier_prices.basic_std }}/mo or Small Files ${{ tier_prices.small_files_std }}/mo</div>
108 - </div>
109 -
110 - <div class="card--bordered use-case-card" id="game-devs">
111 - <div class="use-case-title">Game Developers</div>
112 - <div class="use-case-who">Indie games, mods, TTRPG content</div>
113 - <p class="use-case-desc">Distribute games up to 20GB per file with versioned downloads, license keys, and promo codes. Need more than 20GB? Request a size increase from your dashboard.</p>
114 - <ul class="use-case-features">
115 - <li>Up to 20GB per file (increase on request)</li>
116 - <li>Versioned downloads with changelogs</li>
117 - <li>License keys with activation limits</li>
118 - <li>Promo codes</li>
119 - </ul>
120 - <div class="use-case-tier">Big Files ${{ tier_prices.big_files_std }}/mo &middot; {{ tier_prices.big_files_total }}, {{ tier_prices.big_files_per_file }}/file</div>
121 - </div>
122 -
123 - <div class="card--bordered use-case-card" id="comic-creators">
124 - <div class="use-case-title">Comic Creators</div>
125 - <div class="use-case-who">Webcomic artists, graphic novelists</div>
126 - <p class="use-case-desc">Sell digital comics and graphic novels with project organization and scheduled publishing.</p>
127 - <ul class="use-case-features">
128 - <li>PDF, CBZ, and any file type</li>
129 - <li>Project organization</li>
130 - <li>Subscriptions</li>
131 - <li>Scheduled publishing</li>
132 - </ul>
133 - <div class="use-case-tier">Small Files ${{ tier_prices.small_files_std }}/mo &middot; {{ tier_prices.small_files_total }}, {{ tier_prices.small_files_per_file }}/file</div>
134 - </div>
135 - </div>
136 - </div>
137 -
138 - <div class="tier-section">
139 - <h2 class="section-label">Everyone gets</h2>
140 - <div class="feature-grid">
141 - <div class="card--bordered">
142 - <div class="feature-name">0% platform fee</div>
143 - <div class="feature-desc">Only ~3% payment processing. No percentage cuts, no revenue sharing.</div>
144 - </div>
145 - <div class="card--bordered">
146 - <div class="feature-name">Full data export</div>
147 - <div class="feature-desc">Download everything anytime. Projects, items, sales, contacts: all yours.</div>
148 - </div>
149 - <div class="card--bordered">
150 - <div class="feature-name">No lock-in</div>
151 - <div class="feature-desc">Month-to-month. Cancel anytime. Take your audience with you.</div>
152 - </div>
153 - <div class="card--bordered">
154 - <div class="feature-name">Source available</div>
155 - <div class="feature-desc">Read the codebase. Verify every claim about privacy and data handling.</div>
156 - </div>
157 - </div>
158 - </div>
159 -
160 - <div class="landing-cta">
161 - <a class="btn-primary btn--large" href="/join">Join the Alpha</a>
162 - </div>
163 -
164 - <div class="tier-section">
165 - <div class="explore-links">
166 - <a href="/pricing">Pricing Calculator</a>
167 - <a href="/discover">Browse as guest</a>
168 - <a href="/docs/getting-started">Creator Guide</a>
169 - </div>
170 - </div>
171 - </div>
172 - {% endblock %}