Skip to main content

max / makenotwork

Declare the five embeds, the creator page and the contacts tab Thirteen screens, taking MNW to declared 80 of 172. The first two files cost the form nothing; embeds bought both of the wave's productions, being the last MNW file that draws a picture and the only one that draws a compact strip. Three restructurings, all the rule that a description names what it draws. `Tier` held `price: fn(&TierPrices) -> i32` and a call through a field is not a name, so the fields are `monthly` and `total` with methods over them. `Standing` gained three predicates and `call_to_action` stopped being a `Slot` taken and handed back: each standing offers a different number of things, so it is a `-> Vec<Node>` panel rather than a dispatch. The three embed views gained a has/read pair each, because `if let Some(url)` is a binding pattern the form has none of. `embeds::one` is deleted rather than declared. It wrapped a node in a screen with one region and both its callers return `Screen` themselves, so neither could reach it through an `include`.
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-04 00:16 UTC
Signed with PGP, not checked
Commit: 8e9a085ac7eb2b19d51dfac8b46b4ce8d0ffed7c
Parent: ad7a152
3 files changed, +487 insertions, -376 deletions
@@ -37,10 +37,9 @@
37 37 //! whether to apply is told how many creators are actually here.
38 38
39 39 use makeover_layout as layout;
40 - use quasi_router::screen::{Cell, Cells, Column, Figure, Table};
41 - use quasi_router::{
42 - Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
43 - };
40 + use quasi_declare::declare;
41 + use quasi_router::screen::Figure;
42 + use quasi_router::{Document, Request, Response, RouteError};
44 43 use quasi_webview::Webview;
45 44
46 45 use crate::db;
@@ -56,14 +55,32 @@
56 55
57 56 /// One row of the tier table.
58 57 ///
59 - /// `price` and `storage` are read off [`TierPrices`] rather than written here,
60 - /// for the reason `/use-cases` gives: a formatted price in a table is a price
61 - /// that goes stale on its own.
58 + /// The two figures are read off [`TierPrices`] rather than written here, for
59 + /// the reason `/use-cases` gives: a formatted price in a table is a price that
60 + /// goes stale on its own.
61 + ///
62 + /// [`price`](Self::price) and [`storage`](Self::storage) are what the table
63 + /// reads, and the two fields behind them are what those read. A description
64 + /// names what it draws, and `(tier.price)(prices)` is a call through a field
65 + /// rather than a name -- the same ruling policy's `OTHER` got when it was a
66 + /// slice of tuples.
62 67 struct Tier {
63 68 name: &'static str,
64 69 best_for: &'static str,
65 - price: fn(&TierPrices) -> i32,
66 - storage: fn(&TierPrices) -> String,
70 + monthly: fn(&TierPrices) -> i32,
71 + total: fn(&TierPrices) -> String,
72 + }
73 +
74 + impl Tier {
75 + /// The monthly fee, formatted as the table shows it.
76 + fn price(&self, prices: &TierPrices) -> String {
77 + format!("${}", (self.monthly)(prices))
78 + }
79 +
80 + /// The storage envelope this tier buys.
81 + fn storage(&self, prices: &TierPrices) -> String {
82 + (self.total)(prices)
83 + }
67 84 }
68 85
69 86 /// The four, in the order the shipped table listed them.
@@ -71,26 +88,26 @@
71 88 Tier {
72 89 name: "Basic",
73 90 best_for: "Text, blogs, newsletters",
74 - price: |p| p.basic_std,
75 - storage: |p| p.basic_total.clone(),
91 + monthly: |p| p.basic_std,
92 + total: |p| p.basic_total.clone(),
76 93 },
77 94 Tier {
78 95 name: "Small Files",
79 96 best_for: "Audio, plugins, small software",
80 - price: |p| p.small_files_std,
81 - storage: |p| p.small_files_total.clone(),
97 + monthly: |p| p.small_files_std,
98 + total: |p| p.small_files_total.clone(),
82 99 },
83 100 Tier {
84 101 name: "Big Files",
85 102 best_for: "Video, games, large software",
86 - price: |p| p.big_files_std,
87 - storage: |p| p.big_files_total.clone(),
103 + monthly: |p| p.big_files_std,
104 + total: |p| p.big_files_total.clone(),
88 105 },
89 106 Tier {
90 107 name: "Everything",
91 108 best_for: "All features, current and future",
92 - price: |p| p.everything_std,
93 - storage: |p| p.everything_total.clone(),
109 + monthly: |p| p.everything_std,
110 + total: |p| p.everything_total.clone(),
94 111 },
95 112 ];
96 113
@@ -104,6 +121,27 @@
104 121 Creator,
105 122 }
106 123
124 + impl Standing {
125 + /// Nobody signed in.
126 + ///
127 + /// Three predicates rather than a pattern, because the form reaches a value
128 + /// through a name and not through a match arm, and each of the three offers
129 + /// a different number of things afterwards.
130 + const fn is_visitor(&self) -> bool {
131 + matches!(self, Self::Visitor)
132 + }
133 +
134 + /// Signed in, not yet a creator.
135 + const fn is_reader(&self) -> bool {
136 + matches!(self, Self::Reader)
137 + }
138 +
139 + /// Already has creator access.
140 + const fn is_creator(&self) -> bool {
141 + matches!(self, Self::Creator)
142 + }
143 + }
144 +
107 145 /// The page.
108 146 pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
109 147 use axum::extract::FromRef as _;
@@ -123,122 +161,134 @@
123 161 Ok(page_screen(&standing, total_creators, &billing.tier_prices).into())
124 162 }
125 163
126 - /// The whole document: the title, the measure, the body.
127 - fn page_screen(standing: &Standing, total_creators: i64, prices: &TierPrices) -> Described {
128 - let page = Slot::new(PAGE_REGION, RegionKind::Pane)
129 - .with(Node::page("Become a Creator"))
130 - .with(Node::text(
131 - "Anyone can sign up to browse and buy. To create projects and sell your work, apply \
132 - for creator access. Most applications are approved within a few days. Makenotwork is \
133 - in private alpha; we're approving applications one cohort at a time.",
134 - ))
135 - .with(Node::section("How It Works"))
136 - .with(super::own_prose(
137 - "1. **Sign up** and verify your email\n\
138 - 2. **Apply** from your dashboard: tell us what you make and which tier fits\n\
139 - 3. **Get approved**: we review applications individually, usually within a few days\n\
140 - \n\
141 - We review applications to make sure applicants are here to share and sell creative \
142 - work. If you make something and want to sell it, you'll likely get in. Link to your \
143 - existing work (a portfolio, channel, or profile elsewhere) to speed things up.\n\
144 - \n\
145 - **Important:** You sell in the currency your Stripe account settles in, and \
146 - receiving payouts requires a [Stripe](https://stripe.com/global) account in a \
147 - supported country that settles in one of the six we support: **USD, CAD, GBP, AUD, \
148 - NZD or EUR**. Check both with Stripe before applying.",
149 - ))
150 - .with(Node::stats([Figure::new(
151 - total_creators.to_string(),
152 - "Active Creators",
153 - )]))
154 - .with(Node::section("Pricing"))
155 - .with(Node::text(
156 - "Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments is \
157 - the payment processor's fee (~3%).",
158 - ))
159 - .with(tier_table(prices))
160 - .with(super::own_prose(
161 - "Every tier is the complete platform: `/u/username` profile, project and item pages, \
162 - project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \
163 - analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \
164 - not the feature set. You sell in your Stripe account's currency (USD, CAD, GBP, AUD, \
165 - NZD or EUR); receiving payouts requires [Stripe](https://stripe.com/global) in a \
166 - supported country. [Full tier details](/docs/tiers) | \
167 - [Pricing models](/docs/pricing)",
168 - ))
169 - .with(super::own_prose(
170 - "**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \
171 - you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \
172 - up.",
173 - ))
174 - .with(Node::section("Who Runs This"))
175 - .with(super::own_prose(
176 - "Makenotwork is built and operated by one person. No investors, no board, no outside \
177 - pressure. Decisions are fast and aligned with creators, but there's no large team \
178 - behind the scenes. Read the full picture in our \
179 - [continuity guarantee](/docs/guarantees#continuity) and \
180 - [platform economics](/docs/economics).",
181 - ));
164 + declare! {
165 + /// The whole document: the title, the measure, the body.
166 + shape page_screen(standing: &Standing, total_creators: i64, prices: &TierPrices) -> Screen;
182 167
183 - let page = call_to_action(page, standing);
168 + screen single "Creators - Makenotwork" {
169 + measured MEASURE;
170 + documented Document::default().classed(crate::shell::body_class(MEASURE, &["creators-page"]));
171 + summarised "Apply for creator access: a flat monthly fee, no cut of your revenue, and four \
172 + tiers that pick a file-size envelope rather than a feature set.";
184 173
185 - Described::single("Creators - Makenotwork")
186 - .measured(MEASURE)
187 - .documented(
188 - Document::default().classed(crate::shell::body_class(MEASURE, &["creators-page"])),
189 - )
190 - .summarised(
191 - "Apply for creator access: a flat monthly fee, no cut of your revenue, and four \
192 - tiers that pick a file-size envelope rather than a feature set.",
193 - )
194 - .with(page)
195 - }
174 + region PAGE_REGION as Pane {
175 + page "Become a Creator";
176 + text "Anyone can sign up to browse and buy. To create projects and sell your work, \
177 + apply for creator access. Most applications are approved within a few days. \
178 + Makenotwork is in private alpha; we're approving applications one cohort at a \
179 + time.";
196 180
197 - /// The four tiers, priced from the live figures.
198 - fn tier_table(prices: &TierPrices) -> Node {
199 - // Four columns and four cells, written together, with no branch between
200 - // them: every tier is a full row, so position is checkable by eye here and
201 - // naming the columns would be ceremony.
202 - Table::new([
203 - Column::new("Tier")
204 - .width(layout::Width::Content)
205 - .priority(layout::Priority::Essential),
206 - Column::new("Monthly").width(layout::Width::Content),
207 - Column::new("Best For").width(layout::Width::Fill),
208 - Column::new("Storage").width(layout::Width::Content),
209 - ])
210 - .rows(TIERS.iter().map(|tier| {
211 - Cells::new([
212 - Cell::new(tier.name),
213 - Cell::new(format!("${}", (tier.price)(prices))),
214 - Cell::new(tier.best_for),
215 - Cell::new((tier.storage)(prices)),
216 - ])
217 - }))
218 - .into()
219 - }
181 + section "How It Works";
182 + include super::own_prose(
183 + "1. **Sign up** and verify your email\n\
184 + 2. **Apply** from your dashboard: tell us what you make and which tier fits\n\
185 + 3. **Get approved**: we review applications individually, usually within a few days\n\
186 + \n\
187 + We review applications to make sure applicants are here to share and sell creative \
188 + work. If you make something and want to sell it, you'll likely get in. Link to your \
189 + existing work (a portfolio, channel, or profile elsewhere) to speed things up.\n\
190 + \n\
191 + **Important:** You sell in the currency your Stripe account settles in, and \
192 + receiving payouts requires a [Stripe](https://stripe.com/global) account in a \
193 + supported country that settles in one of the six we support: **USD, CAD, GBP, AUD, \
194 + NZD or EUR**. Check both with Stripe before applying."
195 + );
220 196
221 - /// What the page asks of this reader, which is the only thing on it that
222 - /// differs by who is asking.
223 - fn call_to_action(page: Slot, standing: &Standing) -> Slot {
224 - match standing {
225 - Standing::Creator => page
226 - .with(Node::text("You have creator access."))
227 - .with(Node::act(
228 - "Go to Dashboard",
229 - Action::get("/dashboard").navigating(),
230 - )),
231 - Standing::Reader => page.with(Node::text("Ready to create?")).with(Node::act(
232 - "Apply from Dashboard",
233 - Action::get("/dashboard?tab=settings&section=creator").navigating(),
234 - )),
235 - Standing::Visitor => page
236 - .with(Node::text("Join to get started."))
237 - .with(Node::act("Join", Action::get("/join").navigating()))
238 - .with(Node::act("Login", Action::get("/login").navigating())),
197 + stats [Figure::new(total_creators.to_string(), "Active Creators")];
198 +
199 + section "Pricing";
200 + text "Flat monthly fee. 0% cut of your revenue. The only deduction from fan payments \
201 + is the payment processor's fee (~3%).";
202 + include tier_table(prices);
203 + include super::own_prose(
204 + "Every tier is the complete platform: `/u/username` profile, project and item pages, \
205 + project forum, Discover listing, memberships, pay-what-you-want, promo codes, RSS, \
206 + analytics, full data export, 2FA/passkeys. The tier picks the file-size envelope, \
207 + not the feature set. You sell in your Stripe account's currency (USD, CAD, GBP, AUD, \
208 + NZD or EUR); receiving payouts requires [Stripe](https://stripe.com/global) in a \
209 + supported country. [Full tier details](/docs/tiers) | \
210 + [Pricing models](/docs/pricing)"
211 + );
212 + include super::own_prose(
213 + "**Not ready to commit?** Request a **free trial** (2-6 weeks, no credit card) when \
214 + you apply. Or [try sandbox mode](/sandbox) to explore the dashboard without signing \
215 + up."
216 + );
217 +
218 + section "Who Runs This";
219 + include super::own_prose(
220 + "Makenotwork is built and operated by one person. No investors, no board, no outside \
221 + pressure. Decisions are fast and aligned with creators, but there's no large team \
222 + behind the scenes. Read the full picture in our \
223 + [continuity guarantee](/docs/guarantees#continuity) and \
224 + [platform economics](/docs/economics)."
225 + );
226 +
227 + // The one part of the page that differs by who is asking, spread
228 + // where it was appended. `feeds` does the same with its body.
229 + for node in call_to_action(standing) {
230 + include node;
231 + }
232 + }
239 233 }
240 234 }
241 235
236 + declare! {
237 + /// The four tiers, priced from the live figures.
238 + ///
239 + /// Four columns and four cells, written together, with no branch between
240 + /// them: every tier is a full row, so position is checkable by eye here and
241 + /// naming the columns would be ceremony.
242 + shape tier_table(prices: &TierPrices) -> Node;
243 +
244 + table {
245 + column "Tier" {
246 + width Content;
247 + priority Essential;
248 + }
249 + column "Monthly" {
250 + width Content;
251 + }
252 + column "Best For" {
253 + width Fill;
254 + }
255 + column "Storage" {
256 + width Content;
257 + }
258 +
259 + for tier in TIERS {
260 + cells {
261 + cell tier.name;
262 + cell tier.price(prices);
263 + cell tier.best_for;
264 + cell tier.storage(prices);
265 + }
266 + }
267 + }
268 + }
269 +
270 + declare! {
271 + /// What the page asks of this reader, which is the only thing on it that
272 + /// differs by who is asking.
273 + ///
274 + /// A panel rather than a `Slot` taken and handed back: each standing offers
275 + /// a different number of things -- a visitor two controls, the other two one
276 + /// each -- so the answer is a run of members and not one node. The three
277 + /// guards are exhaustive and disjoint by construction.
278 + shape call_to_action(standing: &Standing) -> Vec<Node>;
279 +
280 + text "You have creator access." when standing.is_creator();
281 + act "Go to Dashboard" to get "/dashboard" navigating when standing.is_creator();
282 +
283 + text "Ready to create?" when standing.is_reader();
284 + act "Apply from Dashboard" to get "/dashboard?tab=settings&section=creator" navigating
285 + when standing.is_reader();
286 +
287 + text "Join to get started." when standing.is_visitor();
288 + act "Join" to get "/join" navigating when standing.is_visitor();
289 + act "Login" to get "/login" navigating when standing.is_visitor();
290 + }
291 +
242 292 /// The document this screen is drawn in.
243 293 #[must_use]
244 294 pub fn renderer(viewer: &super::Viewer) -> Webview {
@@ -44,10 +44,9 @@
44 44 //! is a host contradicting the description rather than adding to it. The
45 45 //! description now says the shape and the host adds only its own furniture.
46 46
47 - use makeover_layout as layout;
48 47 use quasi_axum::Serves as _;
49 - use quasi_router::screen::{Act, Image, Row};
50 - use quasi_router::{Action, Chrome, Document, Node, RegionKind, Screen, Slot};
48 + use quasi_declare::declare;
49 + use quasi_router::{Chrome, Document, RegionKind, Screen};
51 50 use quasi_webview::{Shell, Webview};
52 51
53 52 use crate::templates::{EMBED_GEOMETRY_CSS, EMBED_TYPOGRAPHY_CSS, embed_theme_css};
@@ -145,20 +144,22 @@
145 144 )
146 145 }
147 146
148 - /// A screen with one region, holding one node.
149 - fn one(title: &str, node: Node) -> Screen {
150 - Screen::single(title).with(Slot::new(REGION, RegionKind::Pane).with(node))
151 - }
147 + declare! {
148 + /// A cover picture, cropped to its box.
149 + ///
150 + /// `Fit::Cover` because a cover is a fixed square here and the art it holds
151 + /// is any shape: the alternative is letterboxing inside a 40-pixel box,
152 + /// which is the art unreadable and the box the wrong colour.
153 + ///
154 + /// The alt text is empty on purpose. A cover repeats the title beside it, so
155 + /// a reader who cannot see the bytes is told nothing by a second copy of the
156 + /// name -- which is what [`Image::speaks`](quasi_router::Image::speaks) is
157 + /// for.
158 + shape cover(url: &str) -> Node;
152 159
153 - /// A cover picture, cropped to its box.
154 - ///
155 - /// `Fit::Cover` because a cover is a fixed square here and the art it holds is
156 - /// any shape: the alternative is letterboxing inside a 40-pixel box, which is
157 - /// the art unreadable and the box the wrong colour.
158 - fn cover(url: &str) -> Node {
159 - let mut picture = Image::new(url, "");
160 - picture.fit = layout::Fit::Cover;
161 - Node::Image(picture)
160 + picture url "" {
161 + fit Cover;
162 + }
162 163 }
163 164
164 165 /// What an item embed is drawn from.
@@ -184,92 +185,118 @@
184 185 pub description_excerpt: String,
185 186 }
186 187
187 - /// The buy control: a link out to makenot.work.
188 - ///
189 - /// [`Destination::External`](quasi_router::Destination::External), which is what
190 - /// makes it an anchor with `rel="noopener noreferrer"` in the webview rather
191 - /// than a button that asks a route. An embed's every control is one of these.
192 - fn buy(view: &ItemView) -> Act {
193 - Act::new(&view.button_text, Action::external(&view.purchase_url))
188 + impl ItemView {
189 + /// Whether there is cover art to draw.
190 + ///
191 + /// A predicate and a reader rather than an `Option` the description reaches
192 + /// into: a guard asks one question and the picture is built either way, so
193 + /// the absent case hands [`cover`] an empty source and nothing places it.
194 + fn has_cover(&self) -> bool {
195 + self.cover_image_url.is_some()
196 + }
197 +
198 + /// The cover art's address, or nothing.
199 + fn cover_url(&self) -> &str {
200 + self.cover_image_url.as_deref().unwrap_or_default()
201 + }
194 202 }
195 203
196 - /// The buy button: cover, title, price, and the control, on one line.
197 - ///
198 - /// The one embed that is genuinely a [`Row`]: a compact strip where the cover
199 - /// is a thumbnail beside the title rather than the card's own picture. Drawn
200 - /// with the `embed-button` body class, which is what sizes that thumbnail.
201 - #[must_use]
202 - pub fn item_button(view: &ItemView) -> Screen {
203 - let mut row = Row::new("");
204 - if let Some(url) = &view.cover_image_url {
205 - row = row.part(layout::RowPart::Primary, cover(url));
206 - }
207 - let row = row
208 - .part(layout::RowPart::Primary, Node::text(&view.title))
209 - .meta(&view.price)
210 - .part(layout::RowPart::Actions, Node::Act(buy(view)));
211 - one(&view.title, Node::list([row]))
204 + declare! {
205 + /// The buy control: a link out to makenot.work.
206 + ///
207 + /// [`Destination::External`](quasi_router::Destination::External), which is
208 + /// what makes it an anchor with `rel="noopener noreferrer"` in the webview
209 + /// rather than a button that asks a route. An embed's every control is one
210 + /// of these.
211 + shape buy(view: &ItemView) -> Act;
212 +
213 + act &view.button_text to external &view.purchase_url;
212 214 }
213 215
214 - /// The product card: the cover, what it is, who made it, and the control.
215 - ///
216 - /// Blocks rather than one row, which is the difference between a card and a
217 - /// button. A [`Row`] is an inline run and its parts share a line by role, so a
218 - /// card said as a row would read "coverTitle" with the excerpt and the price
219 - /// crushed in beside it. What a card actually is — a picture, a heading, a line
220 - /// about who made it, a paragraph, a price and a control, each on its own line —
221 - /// is a region holding six nodes, every one of which the vocabulary already
222 - /// names.
223 - #[must_use]
224 - pub fn item_card(view: &ItemView) -> Screen {
225 - let mut slot = Slot::new(REGION, RegionKind::Pane);
226 - if let Some(url) = &view.cover_image_url {
227 - slot = slot.with(cover(url));
216 + declare! {
217 + /// The buy button: cover, title, price, and the control, on one line.
218 + ///
219 + /// The one embed that is genuinely a [`Row`](quasi_router::Row): a compact
220 + /// strip where the cover is a thumbnail beside the title rather than the
221 + /// card's own picture. Drawn with the `embed-button` body class, which is
222 + /// what sizes that thumbnail.
223 + ///
224 + /// The price is a setting and the other three are parts, which is the
225 + /// difference between a short trailing fact and something placed by role.
226 + #[must_use]
227 + pub shape item_button(view: &ItemView) -> Screen;
228 +
229 + screen single &view.title {
230 + region REGION as Pane {
231 + list {
232 + row "" {
233 + beside Primary include cover(view.cover_url()) when view.has_cover();
234 + beside Primary text &view.title;
235 + meta &view.price;
236 + beside Actions include buy(view);
237 + }
238 + }
239 + }
228 240 }
229 - slot = slot.with(Node::section(&view.title)).with(Node::Link {
230 - text: format!("by {}", view.creator_display_name),
231 - action: Action::external(&view.profile_url),
232 - });
233 - if !view.description_excerpt.is_empty() {
234 - slot = slot.with(Node::text(&view.description_excerpt));
235 - }
236 - let slot = slot
237 - .with(Node::text(&view.price))
238 - .with(Node::Act(buy(view)));
239 - Screen::single(&view.title).with(slot)
240 241 }
241 242
242 - /// The audio player: the card, with the transport in a bespoke region.
243 - ///
244 - /// **A handover for now, a widget eventually.** A play button, a scrub bar and an
245 - /// elapsed readout are a media transport, and the vocabulary names none of the
246 - /// three on purpose — describing playback would
247 - /// put scrub, rate and chapters into a core two of the three renderers could only
248 - /// degrade. So this screen describes the chrome around the player and leaves the
249 - /// player alone, which is exactly what a [`RegionKind::Handover`] is for: the
250 - /// fill is owed, and a renderer without one should say so rather than draw an
251 - /// empty box where the transport goes.
252 - ///
253 - /// The markup and the script that fills it are [`player_markup`], unchanged from
254 - /// the template this replaces.
255 - #[must_use]
256 - pub fn item_player(view: &ItemView) -> Screen {
257 - let mut slot = Slot::new(REGION, RegionKind::Pane);
258 - if let Some(url) = &view.cover_image_url {
259 - slot = slot.with(cover(url));
260 - }
261 - let slot = slot
262 - .with(Node::section(&view.title))
263 - .with(Node::text(format!("by {}", view.creator_display_name)))
264 - .with(Node::text(&view.price))
265 - .with(Node::Act(buy(view)));
243 + declare! {
244 + /// The product card: the cover, what it is, who made it, and the control.
245 + ///
246 + /// Blocks rather than one row, which is the difference between a card and a
247 + /// button. A row is an inline run and its parts share a line by role, so a
248 + /// card said as a row would read "coverTitle" with the excerpt and the price
249 + /// crushed in beside it. What a card actually is -- a picture, a heading, a
250 + /// line about who made it, a paragraph, a price and a control, each on its
251 + /// own line -- is a region holding six nodes, every one of which the
252 + /// vocabulary already names.
253 + #[must_use]
254 + pub shape item_card(view: &ItemView) -> Screen;
266 255
267 - Screen::single(&view.title).with(slot).with(Slot::new(
268 - PLAYER_REGION,
269 - RegionKind::Handover {
270 - name: "media-transport".into(),
271 - },
272 - ))
256 + screen single &view.title {
257 + region REGION as Pane {
258 + include cover(view.cover_url()) when view.has_cover();
259 + section &view.title;
260 + link "by {view.creator_display_name}" to external &view.profile_url;
261 + text &view.description_excerpt unless view.description_excerpt.is_empty();
262 + text &view.price;
263 + include buy(view);
264 + }
265 + }
266 + }
267 +
268 + declare! {
269 + /// The audio player: the card, with the transport in a bespoke region.
270 + ///
271 + /// **A handover for now, a widget eventually.** A play button, a scrub bar
272 + /// and an elapsed readout are a media transport, and the vocabulary names
273 + /// none of the three on purpose -- describing playback would put scrub, rate
274 + /// and chapters into a core two of the three renderers could only degrade.
275 + /// So this screen describes the chrome around the player and leaves the
276 + /// player alone, which is exactly what a handover region is for: the fill is
277 + /// owed, and a renderer without one should say so rather than draw an empty
278 + /// box where the transport goes.
279 + ///
280 + /// The markup and the script that fills it are [`player_markup`], unchanged
281 + /// from the template this replaces.
282 + ///
283 + /// Who made it is text here and a link on the card, which the template had
284 + /// too: the player's chrome is a caption over a control, not a place to send
285 + /// somebody else.
286 + #[must_use]
287 + pub shape item_player(view: &ItemView) -> Screen;
288 +
289 + screen single &view.title {
290 + region REGION as Pane {
291 + include cover(view.cover_url()) when view.has_cover();
292 + section &view.title;
293 + text "by {view.creator_display_name}";
294 + text &view.price;
295 + include buy(view);
296 + }
297 +
298 + region PLAYER_REGION as RegionKind::handover("media-transport") {}
299 + }
273 300 }
274 301
275 302 /// The handover region the transport is mounted in.
@@ -368,38 +395,45 @@
368 395 pub category_label: String,
369 396 }
370 397
371 - /// The project card: [`item_card`]'s shape, about a project.
372 - #[must_use]
373 - pub fn project_card(view: &ProjectView) -> Screen {
374 - let mut slot = Slot::new(REGION, RegionKind::Pane);
375 - if let Some(url) = &view.cover_image_url {
376 - slot = slot.with(cover(url));
398 + impl ProjectView {
399 + /// Whether there is cover art to draw. See [`ItemView::has_cover`].
400 + fn has_cover(&self) -> bool {
401 + self.cover_image_url.is_some()
377 402 }
378 - slot = slot.with(Node::section(&view.title)).with(Node::Link {
379 - text: format!("by {}", view.creator_display_name),
380 - action: Action::external(&view.profile_url),
381 - });
382 - if !view.description_excerpt.is_empty() {
383 - slot = slot.with(Node::text(&view.description_excerpt));
403 +
404 + /// The cover art's address, or nothing.
405 + fn cover_url(&self) -> &str {
406 + self.cover_image_url.as_deref().unwrap_or_default()
407 + }
408 +
409 + /// "item" or "items", for the count line.
410 + fn items_word(&self) -> &'static str {
411 + if self.item_count == 1 {
412 + "item"
413 + } else {
414 + "items"
415 + }
416 + }
417 + }
418 +
419 + declare! {
420 + /// The project card: [`item_card`]'s shape, about a project.
421 + ///
422 + /// The count and the kind read together and neither stands on its own, so
423 + /// they are one line rather than two nodes.
424 + #[must_use]
425 + pub shape project_card(view: &ProjectView) -> Screen;
426 +
427 + screen single &view.title {
428 + region REGION as Pane {
429 + include cover(view.cover_url()) when view.has_cover();
430 + section &view.title;
431 + link "by {view.creator_display_name}" to external &view.profile_url;
432 + text &view.description_excerpt unless view.description_excerpt.is_empty();
433 + text "{view.item_count} {view.items_word()} \u{b7} {view.category_label}";
434 + act "View project" to external &view.project_url;
435 + }
384 436 }
385 - // The count and the kind read together and neither stands on its own, so
386 - // they are one line rather than two nodes.
387 - let slot = slot
388 - .with(Node::text(format!(
389 - "{} {} \u{b7} {}",
390 - view.item_count,
391 - if view.item_count == 1 {
392 - "item"
393 - } else {
394 - "items"
395 - },
396 - view.category_label
397 - )))
398 - .with(Node::Act(Act::new(
399 - "View project",
400 - Action::external(&view.project_url),
401 - )));
402 - Screen::single(&view.title).with(slot)
403 437 }
404 438
405 439 /// What a tip embed is drawn from.
@@ -414,23 +448,37 @@
414 448 pub avatar_url: Option<String>,
415 449 }
416 450
417 - /// The tip button.
418 - #[must_use]
419 - pub fn tip_button(view: &TipView) -> Screen {
420 - let mut row = Row::new("");
421 - if let Some(url) = &view.avatar_url {
422 - row = row.part(layout::RowPart::Primary, cover(url));
451 + impl TipView {
452 + /// Whether there is an avatar to draw. See [`ItemView::has_cover`].
453 + fn has_avatar(&self) -> bool {
454 + self.avatar_url.is_some()
455 + }
456 +
457 + /// The avatar's address, or nothing.
458 + fn avatar(&self) -> &str {
459 + self.avatar_url.as_deref().unwrap_or_default()
460 + }
461 + }
462 +
463 + declare! {
464 + /// The tip button.
465 + ///
466 + /// [`item_button`]'s strip with nothing between the label and the control:
467 + /// a tip has no price to trail.
468 + #[must_use]
469 + pub shape tip_button(view: &TipView) -> Screen;
470 +
471 + screen single "Support {view.display_name}" {
472 + region REGION as Pane {
473 + list {
474 + row "" {
475 + beside Primary include cover(view.avatar()) when view.has_avatar();
476 + beside Primary text "Support @{view.username}";
477 + beside Actions act "Support" to external &view.tip_url;
478 + }
479 + }
480 + }
423 481 }
424 - let row = row
425 - .part(
426 - layout::RowPart::Primary,
427 - Node::text(format!("Support @{}", view.username)),
428 - )
429 - .part(
430 - layout::RowPart::Actions,
431 - Node::Act(Act::new("Support", Action::external(&view.tip_url))),
432 - );
433 - one(&format!("Support {}", view.display_name), Node::list([row]))
434 482 }
435 483
436 484 #[cfg(test)]
@@ -27,9 +27,8 @@
27 27 //! question hanging over S4's safety argument, and picking a screen that renders
28 28 //! its own data is the answer for this batch.
29 29
30 - use makeover_layout as layout;
31 - use quasi_router::screen::{Act, Cell, Cells, Column, Table};
32 - use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot};
30 + use quasi_declare::declare;
31 + use quasi_router::{Method, Request, Response, RouteError};
33 32 use quasi_webview::Webview;
34 33
35 34 use super::Viewer;
@@ -150,118 +149,132 @@
150 149 screen(viewer, Request::get(PATH))
151 150 }
152 151
153 - /// Everything inside the tab pane.
154 - ///
155 - /// Split from the handler so a test can build it without a database, the same
156 - /// split `ssh_keys` uses and the reason a described screen is testable at all.
157 - fn pane(buyers: &[BuyerView], shared: &[SharedView]) -> Node {
158 - let mut slot = Slot::new(REGION, RegionKind::Pane);
152 + declare! {
153 + /// Everything inside the tab pane.
154 + ///
155 + /// Split from the handler so a test can build it without a database, the
156 + /// same split `ssh_keys` uses and the reason a described screen is testable
157 + /// at all.
158 + ///
159 + /// The Askama version says "both empty" in a third `{% if %}` over the same
160 + /// two conditions and returns early. Said as guards, the two halves each
161 + /// carry their own, and the both-empty case is the one where neither is
162 + /// placed and the empty line is.
163 + shape pane(buyers: &[BuyerView], shared: &[SharedView]) -> Node;
159 164
160 - // Both empty is its own screen and not two empty tables. The Askama version
161 - // says this in a third `{% if %}` over the same two conditions.
162 - if buyers.is_empty() && shared.is_empty() {
163 - return Node::Region(slot.with(Node::empty("No contacts yet.")));
165 + region REGION as Pane {
166 + empty "No contacts yet." when buyers.is_empty() and shared.is_empty();
167 +
168 + section "Your Buyers ({buyers.len()})" unless buyers.is_empty();
169 + text "Buyers who opted to share their email with you at purchase time."
170 + unless buyers.is_empty();
171 + include buyers_table(buyers) unless buyers.is_empty();
172 +
173 + section "Shared With" unless shared.is_empty();
174 + text "You've shared your email with these creators. You can revoke sharing at any time."
175 + unless shared.is_empty();
176 + include shared_table(shared) unless shared.is_empty();
164 177 }
165 -
166 - if !buyers.is_empty() {
167 - slot = slot
168 - .with(Node::section(format!("Your Buyers ({})", buyers.len())))
169 - .with(Node::text(
170 - "Buyers who opted to share their email with you at purchase time.",
171 - ))
172 - .with(buyers_table(buyers));
173 - }
174 -
175 - if !shared.is_empty() {
176 - slot = slot
177 - .with(Node::section("Shared With"))
178 - .with(Node::text(
179 - "You've shared your email with these creators. You can revoke sharing at any time.",
180 - ))
181 - .with(shared_table(shared));
182 - }
183 -
184 - Node::Region(slot)
185 178 }
186 179
187 - /// The buyers who shared an email.
188 - fn buyers_table(buyers: &[BuyerView]) -> Node {
189 - // Positional cells, deliberately: the headings and the row are the one
190 - // expression below and every buyer contributes the same five cells, so
191 - // naming each column would buy nothing a reader cannot already see. Nothing
192 - // is paged, so no `more`: this is a whole set the handler already counted.
193 - Table::new(vec![
194 - Column::new("Username")
195 - .width(layout::Width::Content)
196 - .priority(layout::Priority::Essential),
197 - Column::new("Email")
198 - .width(layout::Width::Fill)
199 - .priority(layout::Priority::Essential),
200 - Column::new("Purchases").width(layout::Width::Content),
201 - Column::new("Total Spent").width(layout::Width::Content),
202 - Column::new("Last Purchase")
203 - .width(layout::Width::Content)
204 - .priority(layout::Priority::Optional),
205 - ])
206 - .rows(buyers.iter().map(|buyer| {
207 - Cells::new([
208 - // The two flavours of a linked value in one row. A profile
209 - // is a route this server answers, so it stays inside the
210 - // app; an address is not, so it leaves.
211 - //
212 - // Navigating (`00ee7af5`): a profile is a whole document,
213 - // not a region, so the anchor is the whole of it and the
214 - // swap is dropped. Without it htmx would morph a
215 - // `<!doctype html>` page over the cell it was clicked in.
216 - Cell::new(buyer.username.clone())
217 - .activate(Action::get(format!("/u/{}", buyer.username)).navigating()),
218 - Cell::new(buyer.email.clone())
219 - .activate(Action::external(format!("mailto:{}", buyer.email))),
220 - Cell::new(buyer.purchases.clone()),
221 - Cell::new(buyer.spent.clone()),
222 - Cell::new(buyer.last_purchase.clone()),
223 - ])
224 - }))
225 - .into()
180 + declare! {
181 + /// The buyers who shared an email.
182 + ///
183 + /// Positional cells, deliberately: the headings and the row are one
184 + /// declaration and every buyer contributes the same five cells, so naming
185 + /// each column would buy nothing a reader cannot already see. Nothing is
186 + /// paged, so no `more`: this is a whole set the handler already counted.
187 + shape buyers_table(buyers: &[BuyerView]) -> Node;
188 +
189 + table {
190 + column "Username" {
191 + width Content;
192 + priority Essential;
193 + }
194 + column "Email" {
195 + width Fill;
196 + priority Essential;
197 + }
198 + column "Purchases" {
199 + width Content;
200 + }
201 + column "Total Spent" {
202 + width Content;
203 + }
204 + column "Last Purchase" {
205 + width Content;
206 + priority Optional;
207 + }
208 +
209 + for buyer in buyers.iter() {
210 + cells {
211 + // The two flavours of a linked value in one row. A profile is a
212 + // route this server answers, so it stays inside the app; an
213 + // address is not, so it leaves.
214 + //
215 + // Navigating (`00ee7af5`): a profile is a whole document, not a
216 + // region, so the anchor is the whole of it and the swap is
217 + // dropped. Without it htmx would morph a `<!doctype html>` page
218 + // over the cell it was clicked in.
219 + cell buyer.username.clone() {
220 + activate to get "/u/{buyer.username}" navigating;
221 + }
222 + cell buyer.email.clone() {
223 + activate to external "mailto:{buyer.email}";
224 + }
225 + cell buyer.purchases.clone();
226 + cell buyer.spent.clone();
227 + cell buyer.last_purchase.clone();
228 + }
229 + }
230 + }
226 231 }
227 232
228 - /// The creators this reader has shared an email with.
229 - fn shared_table(shared: &[SharedView]) -> Node {
230 - // Positional, and here it is the only sensible reading: the act column has
231 - // no heading to name, so a named cell would have to spell the empty string
232 - // and the pairing would be less obvious than the order already makes it.
233 - // Both cells are drawn for every creator. Nothing is paged, so no `more`:
234 - // this is a whole set the handler already counted.
235 - Table::new(vec![
236 - Column::new("Creator")
237 - .width(layout::Width::Fill)
238 - .priority(layout::Priority::Essential),
239 - Column::new("")
240 - .width(layout::Width::Content)
241 - .priority(layout::Priority::Essential),
242 - ])
243 - .rows(shared.iter().map(|creator| {
244 - Cells::new([
245 - // The display name is what the row reads as and the username
246 - // is where it goes, which is the pairing the template made
247 - // with a nested `{% if let %}` inside the anchor.
248 - Cell::new(creator.name.clone())
249 - .activate(Action::get(format!("/u/{}", creator.username)).navigating()),
250 - Cell::acts([Act::new(
251 - "Revoke",
252 - // This screen's own route, under its own nest. The API's
253 - // answers 204, which htmx never swaps, so the row stayed
254 - // after a successful revoke. See `revoke`.
255 - Action::delete(format!("{PATH}/revoke/{}", creator.seller_id)).awaiting(),
256 - )
257 - // The template asked with hx-confirm. Said here, a
258 - // terminal host asks in its own way and no host can
259 - // forget to ask.
260 - .confirm(format!("Revoke contact sharing with {}?", creator.username))
261 - .tone(layout::Tone::Danger)]),
262 - ])
263 - }))
264 - .into()
233 + declare! {
234 + /// The creators this reader has shared an email with.
235 + ///
236 + /// Positional, and here it is the only sensible reading: the act column has
237 + /// no heading to name, so a named cell would have to spell the empty string
238 + /// and the pairing would be less obvious than the order already makes it.
239 + /// Both cells are drawn for every creator. Nothing is paged, so no `more`:
240 + /// this is a whole set the handler already counted.
241 + shape shared_table(shared: &[SharedView]) -> Node;
242 +
243 + table {
244 + column "Creator" {
245 + width Fill;
246 + priority Essential;
247 + }
248 + column "" {
249 + width Content;
250 + priority Essential;
251 + }
252 +
253 + for creator in shared.iter() {
254 + cells {
255 + // The display name is what the row reads as and the username is
256 + // where it goes, which is the pairing the template made with a
257 + // nested `{% if let %}` inside the anchor.
258 + cell creator.name.clone() {
259 + activate to get "/u/{creator.username}" navigating;
260 + }
261 + // An empty cell holding one control is the same value as
262 + // `Cell::acts`, which `Cell::new`'s own doc says.
263 + cell "" {
264 + // This screen's own route, under its own nest. The API
265 + // answers 204, which htmx never swaps, so the row stayed
266 + // after a successful revoke. See `revoke`.
267 + act "Revoke" to delete "{PATH}/revoke/{creator.seller_id}" awaiting {
268 + // The template asked with hx-confirm. Said here, a
269 + // terminal host asks in its own way and no host can
270 + // forget to ask.
271 + confirm "Revoke contact sharing with {creator.username}?";
272 + tone Danger;
273 + }
274 + }
275 + }
276 + }
277 + }
265 278 }
266 279
267 280 /// The renderer this screen is drawn with.
@@ -292,7 +305,7 @@
292 305 }
293 306 }
294 307
295 - fn render(node: &Node) -> String {
308 + fn render(node: &quasi_router::Node) -> String {
296 309 Webview::new().fragment(node)
297 310 }
298 311