Skip to main content

max / makenotwork

Declare the git landing, the project blog and the buyer contacts Wave 2, six shapes: git_explore's page_screen and listing, project_blog's screen, buyer_contacts' pane, export and table. `progress.py` moves from declared 15 to declared 21. 2682 lib tests pass, clippy clean. git_explore's `rest` stops returning an `Option`. The condition it hid -- page 1 with nothing after it -- is now the guard on the table's `more`, said where a reader of the description can see it, and the supplier answers with a `Rest` nobody asks for otherwise. buyer_contacts drops `export`'s wrapper prose into the declaration and keeps the function, because a test renders it directly. Its table is the second one described and needed no new production, which is the first zero in the per-file production count. Two shapes at the head of the queue were refused rather than converted, and both refusals are the same one. `quasi/mod.rs`'s `own_prose` is a named combination of two constructors, which is a supplier by construction, and `widgets/carousel.rs`'s `region` maps a template frame through a closure holding a `let mut` and an `if let`, which the hard limit refuses outright. Four assertions added for loss-list items the files did not already carry: the table column headings in both table screens, the creator and project addresses on the blog, and the blog's social kind. Needs quasi-declare from quasi b7ecad9 and quasi-router 0.101.5.
Author: Max Johnson <me@maxj.phd> · 2026-09-03 18:50 UTC
Signed with PGP, not checked
Commit: c04a4f01baaba3691a3480a15229779156adea2d
Parent: 2f80051
3 files changed, +257 insertions, -194 deletions
@@ -35,9 +35,8 @@
35 35 //! is the ability to collapse a section that starts expanded, and no reader
36 36 //! loses anything they can currently see.
37 37
38 - use makeover_layout as layout;
39 - use quasi_router::screen::{Cell, Cells, Column, Table};
40 - use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
38 + use quasi_declare::declare;
39 + use quasi_router::{Request, Response, RouteError};
41 40 use quasi_webview::Webview;
42 41
43 42 use super::Viewer;
@@ -89,84 +88,98 @@
89 88 Ok(Response::fragment(REGION, pane(&buyers)))
90 89 }
91 90
92 - /// Everything inside the section.
93 - fn pane(buyers: &[BuyerView]) -> Node {
94 - let slot = Slot::new(REGION, RegionKind::Pane)
95 - .with(Node::section(format!("Shared Contacts ({})", buyers.len())))
96 - .with(Node::text(
97 - "Buyers who opted to share their email at checkout. \
98 - They can revoke sharing from their library.",
99 - ));
91 + declare! {
92 + /// Everything inside the section.
93 + shape pane(buyers: &[BuyerView]) -> Node;
100 94
101 - if buyers.is_empty() {
102 - return Node::Region(slot.with(Node::empty(
103 - "No shared contacts yet. When buyers opt to share their email at checkout, \
104 - they will appear here.",
105 - )));
95 + region REGION as Pane {
96 + section "Shared Contacts ({buyers.len()})";
97 + text "Buyers who opted to share their email at checkout. \
98 + They can revoke sharing from their library.";
99 +
100 + empty "No shared contacts yet. When buyers opt to share their email at checkout, \
101 + they will appear here." when buyers.is_empty();
102 +
103 + // The template hides the button and the table together: an export of an
104 + // empty set is a file nobody wants.
105 + include export() unless buyers.is_empty();
106 + include table(buyers) unless buyers.is_empty();
106 107 }
107 -
108 - Node::Region(slot.with(export()).with(table(buyers)))
109 108 }
110 109
111 - /// The Export CSV control.
112 - ///
113 - /// `Action::saving` is the whole of what used to be
114 - /// `data-action="exportCsvButton" data-arg="/api/export/contacts"
115 - /// data-arg2="contacts.csv"`: a class naming a behaviour, plus the two things
116 - /// the behaviour needed, positionally. Said here it is one sentence, the host
117 - /// performs it from one attribute, and a terminal renderer can write the file to
118 - /// disk without being told which button this is.
119 - ///
120 - /// `awaiting` because the server assembles the file before any of it comes
121 - /// back, which is the report case `Action::awaiting`'s own docs name. Nothing
122 - /// countable to say about it: the row count is known here but the bytes are
123 - /// not, and `layout::Awaiting` takes a measurement rather than a stand-in for
124 - /// one.
125 - fn export() -> Node {
110 + declare! {
111 + /// The Export CSV control.
112 + ///
113 + /// `Action::saving` is the whole of what used to be
114 + /// `data-action="exportCsvButton" data-arg="/api/export/contacts"
115 + /// data-arg2="contacts.csv"`: a class naming a behaviour, plus the two
116 + /// things the behaviour needed, positionally. Said here it is one sentence,
117 + /// the host performs it from one attribute, and a terminal renderer can
118 + /// write the file to disk without being told which button this is.
119 + ///
120 + /// `awaiting` because the server assembles the file before any of it comes
121 + /// back, which is the report case `Action::awaiting`'s own docs name.
122 + /// Nothing countable to say about it: the row count is known here but the
123 + /// bytes are not, and `layout::Awaiting` takes a measurement rather than a
124 + /// stand-in for one.
125 + #[must_use]
126 + shape export() -> Node;
127 +
126 128 // Through `export_act` rather than spelled again here. This screen had the
127 129 // only described copy when it was written; there are five call sites now
128 130 // (`27d5e5b8`, the glue-module ruling), and one of them is the Askama
129 131 // fallback for this very tab. Two spellings of one control is what the
130 132 // conversion is for removing.
131 - super::export_act::act("/api/export/contacts", "contacts.csv")
133 + include super::export_act::act("/api/export/contacts", "contacts.csv");
132 134 }
133 135
134 - /// The buyers who shared an email.
135 - fn table(buyers: &[BuyerView]) -> Node {
136 - // Cells by position rather than by name, which is the safe half of the
137 - // choice `Table` offers: the columns and the rows are the one expression
138 - // below, and every buyer contributes the same five cells, so there is no
139 - // seam for a heading and a cell to drift across. Nothing is paged either,
140 - // so no `more`: this is a whole set the handler already counted.
141 - Table::new(vec![
142 - Column::new("Username")
143 - .width(layout::Width::Content)
144 - .priority(layout::Priority::Essential),
145 - Column::new("Email")
146 - .width(layout::Width::Fill)
147 - .priority(layout::Priority::Essential),
148 - Column::new("Purchases").width(layout::Width::Content),
149 - Column::new("Total Spent").width(layout::Width::Content),
150 - Column::new("Last Purchase")
151 - .width(layout::Width::Content)
152 - .priority(layout::Priority::Optional),
153 - ])
154 - .rows(buyers.iter().map(|buyer| {
155 - Cells::new([
156 - Cell::new(buyer.username.clone())
157 - .activate(Action::get(format!("/u/{}", buyer.username)).navigating()),
158 - // Plain text, unlike `library_contacts`, and the templates
159 - // differ the same way: a creator's own list does not link
160 - // the address it is showing. Kept rather than harmonised,
161 - // because which of the two is right is a design question
162 - // and this batch is a conversion.
163 - Cell::new(buyer.email.clone()),
164 - Cell::new(buyer.purchases.clone()),
165 - Cell::new(buyer.spent.clone()),
166 - Cell::new(buyer.last_purchase.clone()),
167 - ])
168 - }))
169 - .into()
136 + declare! {
137 + /// The buyers who shared an email.
138 + ///
139 + /// Cells by position rather than by name, which is the safe half of the
140 + /// choice `Table` offers: the columns and the rows are the one block below,
141 + /// and every buyer contributes the same five cells, so there is no seam for
142 + /// a heading and a cell to drift across. Nothing is paged either, so no
143 + /// `more`: this is a whole set the handler already counted.
144 + shape table(buyers: &[BuyerView]) -> Node;
145 +
146 + table {
147 + column "Username" {
148 + width Content;
149 + priority Essential;
150 + }
151 + column "Email" {
152 + width Fill;
153 + priority Essential;
154 + }
155 + column "Purchases" {
156 + width Content;
157 + }
158 + column "Total Spent" {
159 + width Content;
160 + }
161 + column "Last Purchase" {
162 + width Content;
163 + priority Optional;
164 + }
165 +
166 + for buyer in buyers {
167 + cells {
168 + cell buyer.username.clone() {
169 + activate to get "/u/{buyer.username}" navigating;
170 + }
171 + // Plain text, unlike `library_contacts`, and the templates
172 + // differ the same way: a creator's own list does not link the
173 + // address it is showing. Kept rather than harmonised, because
174 + // which of the two is right is a design question and this batch
175 + // is a conversion.
176 + cell buyer.email.clone();
177 + cell buyer.purchases.clone();
178 + cell buyer.spent.clone();
179 + cell buyer.last_purchase.clone();
180 + }
181 + }
182 + }
170 183 }
171 184
172 185 /// The renderer this screen is drawn with.
@@ -176,8 +189,10 @@
176 189
177 190 #[cfg(test)]
178 191 mod tests {
179 - use super::*;
180 192 use quasi_axum::Serves;
193 + use quasi_router::Node;
194 +
195 + use super::*;
181 196
182 197 fn buyer(username: &str) -> BuyerView {
183 198 BuyerView {
@@ -252,6 +267,24 @@
252 267 assert!(!html.contains("mailto:"), "{html}");
253 268 }
254 269
270 + /// The five headings, which moved from a hand-written `Table::new` list into
271 + /// the declaration. A column dropped on the way is silent: the cells still
272 + /// render and land under the wrong name.
273 + #[test]
274 + fn every_column_the_table_had_is_still_named() {
275 + let html = render(&table(&[buyer("ada")]));
276 +
277 + for heading in [
278 + "Username",
279 + "Email",
280 + "Purchases",
281 + "Total Spent",
282 + "Last Purchase",
283 + ] {
284 + assert!(html.contains(heading), "{heading} is gone from {html}");
285 + }
286 + }
287 +
255 288 #[test]
256 289 fn a_username_cannot_smuggle_markup() {
257 290 let html = render(&table(&[buyer("<script>x()</script>")]));
@@ -28,10 +28,9 @@
28 28 //! markup and drops the reason leaves the next reader to rediscover it.
29 29
30 30 use makeover_layout as layout;
31 - use quasi_router::screen::{Cell, Cells, Column, Rest, Table};
32 - use quasi_router::{
33 - Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
34 - };
31 + use quasi_declare::declare;
32 + use quasi_router::screen::Rest;
33 + use quasi_router::{Action, Document, Request, Response, RouteError};
35 34 use quasi_webview::Webview;
36 35
37 36 use crate::{constants, db};
@@ -109,79 +108,89 @@
109 108 })
110 109 }
111 110
112 - /// The whole document: the title, the measure, the body.
113 - fn page_screen(loaded: &Loaded) -> Described {
114 - let mut page = Slot::new(PAGE_REGION, RegionKind::Pane)
115 - .with(Node::page("Repositories"))
116 - // Notes are the one thing this browser does that no other forge does,
117 - // and nothing on a repository page says so to somebody who has never
118 - // seen one. The landing page is where that sentence reaches everybody.
119 - .with(super::own_prose(
120 - "Every repository here renders [git notes](/docs/git-notes): annotation attached \
121 - to a commit without rewriting it, stored in the repository and carried by a clone.",
122 - ));
111 + declare! {
112 + /// The whole document: the title, the measure, the body.
113 + shape page_screen(loaded: &Loaded) -> Screen;
123 114
124 - // The only route to an annotation whose target repository is gone: nothing
125 - // else links to it once there is no commit page to link from.
126 - if loaded.signed_in {
127 - page = page.with(super::own_prose(
128 - "[Your annotations](/git/my-annotations), private to you, across every repository \
129 - you have read here.",
130 - ));
131 - }
115 + screen single "Repositories - Git - Makenotwork" {
116 + measured MEASURE;
117 + documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
118 + summarised "Public repositories on Makenotwork, with git notes rendered on every commit.";
132 119
133 - page = if loaded.repos.is_empty() {
134 - page.with(Node::empty("No public repositories yet."))
135 - } else {
136 - page.with(listing(loaded))
137 - };
120 + region PAGE_REGION as Pane {
121 + page "Repositories";
138 122
139 - Described::single("Repositories - Git - Makenotwork")
140 - .measured(MEASURE)
141 - .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
142 - .summarised("Public repositories on Makenotwork, with git notes rendered on every commit.")
143 - .with(page)
144 - }
123 + // Notes are the one thing this browser does that no other forge
124 + // does, and nothing on a repository page says so to somebody who
125 + // has never seen one. The landing page is where that sentence
126 + // reaches everybody.
127 + include super::own_prose(
128 + "Every repository here renders [git notes](/docs/git-notes): annotation attached \
129 + to a commit without rewriting it, stored in the repository and carried by a clone."
130 + );
145 131
146 - /// The repositories, as a table, with whatever pages remain.
147 - fn listing(loaded: &Loaded) -> Node {
148 - // Two columns, two cells, all four written here, so the row stays
149 - // positional: naming would buy nothing a reader cannot already check by
150 - // looking up four lines.
151 - let table = Table::new(vec![
152 - Column::new("Repository")
153 - .width(layout::Width::Content)
154 - .priority(layout::Priority::Essential),
155 - Column::new("Description").width(layout::Width::Fill),
156 - ])
157 - .rows(loaded.repos.iter().map(|repo| {
158 - Cells::new([
159 - Cell::new(format!("{}/{}", repo.owner, repo.name)),
160 - Cell::new(repo.description.clone()),
161 - ])
162 - .activate(Action::get(format!("/git/{}/{}", repo.owner, repo.name)).navigating())
163 - }));
132 + // The only route to an annotation whose target repository is gone:
133 + // nothing else links to it once there is no commit page to link
134 + // from.
135 + include super::own_prose(
136 + "[Your annotations](/git/my-annotations), private to you, across every repository \
137 + you have read here."
138 + ) when loaded.signed_in;
164 139
165 - // One page of one is the whole listing, and [`rest`] answers `None` for it.
166 - match rest(loaded) {
167 - Some(rest) => table.more(rest).into(),
168 - None => table.into(),
140 + given loaded.repos.is_empty() {
141 + true -> empty "No public repositories yet.";
142 + otherwise -> include listing(loaded);
143 + }
144 + }
169 145 }
170 146 }
171 147
172 - /// What the reader has not been shown, when there is any.
148 + declare! {
149 + /// The repositories, as a table, with whatever pages remain.
150 + ///
151 + /// Two columns, two cells, all four written here, so the row stays
152 + /// positional: naming would buy nothing a reader cannot already check by
153 + /// looking up four lines.
154 + shape listing(loaded: &Loaded) -> Node;
155 +
156 + table {
157 + column "Repository" {
158 + width Content;
159 + priority Essential;
160 + }
161 + column "Description" {
162 + width Fill;
163 + }
164 +
165 + for repo in loaded.repos.iter() {
166 + cells {
167 + cell "{repo.owner}/{repo.name}";
168 + cell repo.description.clone();
169 + activate to get "/git/{repo.owner}/{repo.name}" navigating;
170 + }
171 + }
172 +
173 + // One page of one is the whole listing, and the pager is what says so.
174 + // The condition is `rest`'s own, said here rather than answered with an
175 + // `Option`: a table either shows what it has not shown or does not, and
176 + // that is what a guard is for.
177 + more rest(loaded) when loaded.page over 1 or loaded.has_more;
178 + }
179 + }
180 +
181 + /// What the reader has not been shown.
173 182 ///
174 183 /// Prev/next only. The template drew `Newer` and `Older` and no numbers, and
175 184 /// `Rest` draws numbers only for a screen that calls `jumping`, so this is a
176 185 /// parity conversion rather than a reduction.
177 - fn rest(loaded: &Loaded) -> Option<Rest> {
186 + ///
187 + /// Whether there is anything to show is [`listing`]'s guard rather than an
188 + /// `Option` here: built for a page that offers neither direction this is a
189 + /// `Rest` with no controls, and nothing asks for it.
190 + fn rest(loaded: &Loaded) -> Rest {
178 191 let per = constants::GIT_REPOS_PER_PAGE;
179 192 let from = (loaded.page - 1) * per;
180 193
181 - if loaded.page == 1 && !loaded.has_more {
182 - return None;
183 - }
184 -
185 194 let mut rest = Rest::page(from, per);
186 195 if loaded.page > 1 {
187 196 rest = rest.back(Action::get(format!("{PATH}?page={}", loaded.page - 1)).navigating());
@@ -190,7 +199,7 @@
190 199 rest = rest.forward(Action::get(format!("{PATH}?page={}", loaded.page + 1)).navigating());
191 200 }
192 201
193 - Some(rest)
202 + rest
194 203 }
195 204
196 205 /// The document this screen is drawn in.
@@ -265,11 +274,23 @@
265 274 assert!(!html(&loaded(1, 1, false, false)).contains("/git/my-annotations"));
266 275 }
267 276
277 + /// Both headings, which moved from a hand-written `Table::new` list into the
278 + /// declaration. A column dropped on the way is silent.
279 + #[test]
280 + fn every_column_the_listing_had_is_still_named() {
281 + let html = html(&loaded(2, 1, false, false));
282 +
283 + assert!(html.contains("Repository"), "{html}");
284 + assert!(html.contains("Description"), "{html}");
285 + }
286 +
268 287 /// A single page of results offers no paging at all, rather than two
269 288 /// disabled controls.
270 289 #[test]
271 290 fn one_page_of_repositories_has_no_rest() {
272 - assert!(rest(&loaded(3, 1, false, false)).is_none());
291 + let html = html(&loaded(3, 1, false, false));
292 +
293 + assert!(!html.contains("page="), "{html}");
273 294 }
274 295
275 296 /// Older on the first page, both on a middle page, Newer on the last.
@@ -20,8 +20,8 @@
20 20 //! it is missing. What is described is the document.
21 21
22 22 use makeover_layout as layout;
23 - use quasi_router::screen::Row;
24 - use quasi_router::{Action, Document, Feed, FeedKind, Node, RegionKind, Screen as Described, Slot};
23 + use quasi_declare::declare;
24 + use quasi_router::{Document, Feed, FeedKind, Screen as Described};
25 25 use quasi_webview::Webview;
26 26
27 27 use crate::types::BlogPostSummary;
@@ -38,67 +38,54 @@
38 38 format!("/p/{project_slug}/blog/feed.xml")
39 39 }
40 40
41 - /// The whole document.
42 - #[must_use]
43 - pub fn screen(
44 - project_title: &str,
45 - project_slug: &str,
46 - creator_username: &str,
47 - posts: &[BlogPostSummary],
48 - ) -> Described {
41 + declare! {
42 + /// The whole document.
43 + #[must_use]
44 + pub shape screen(
45 + project_title: &str,
46 + project_slug: &str,
47 + creator_username: &str,
48 + posts: &[BlogPostSummary],
49 + ) -> Screen;
50 +
49 51 let feed = feed_path(project_slug);
50 52
51 - let listing = if posts.is_empty() {
52 - Node::empty("No blog posts yet.")
53 - } else {
54 - Node::list(posts.iter().map(|post| {
55 - Row::new(post.title.clone())
56 - .meta(post.published_at.clone())
57 - .activate(Action::get(format!("/p/{project_slug}/blog/{}", post.slug)).navigating())
58 - }))
59 - };
53 + screen single "Blog - {project_title}" {
54 + measured MEASURE;
55 + documented Document::default()
56 + .classed(crate::shell::body_class(MEASURE, &["project-blog-page"]));
57 + summarised "Posts from {project_title}, by {creator_username}.";
58 + about quasi_router::SocialKind::Article;
59 + syndicating Feed::new(FeedKind::Rss, "{project_title} - Blog RSS", feed);
60 60
61 - let page = Slot::new(PAGE_REGION, RegionKind::Pane)
62 - .with(Node::page(format!("{project_title} Blog")))
63 - .with(Node::Link {
64 - text: creator_username.to_owned(),
65 - action: Action::get(format!("/u/{creator_username}")).navigating(),
66 - })
67 - .with(Node::Link {
68 - text: "View project".to_owned(),
69 - action: Action::get(format!("/p/{project_slug}")).navigating(),
70 - })
71 - // The visible offer, beside the autodiscovery tag rather than instead
72 - // of it: one is what a reader clicks and the other is what a reader's
73 - // app finds, and a page that has a feed owes both.
74 - .with(Node::act(
75 - "RSS Feed",
76 - Action::get(feed.clone()).navigating(),
77 - ))
78 - .with(listing)
79 - // The attribution the template's footer carried. Content rather than
80 - // decoration: it is where a reader on a creator's blog finds out whose
81 - // platform they are on.
82 - .with(Node::Link {
83 - text: "Powered by Makenot.work".to_owned(),
84 - action: Action::get("/").navigating(),
85 - });
61 + region PAGE_REGION as Pane {
62 + page "{project_title} Blog";
63 + link creator_username to get "/u/{creator_username}" navigating;
64 + link "View project" to get "/p/{project_slug}" navigating;
86 65
87 - Described::single(format!("Blog - {project_title}"))
88 - .measured(MEASURE)
89 - .documented(
90 - Document::default().classed(crate::shell::body_class(MEASURE, &["project-blog-page"])),
91 - )
92 - .summarised(format!(
93 - "Posts from {project_title}, by {creator_username}."
94 - ))
95 - .about(quasi_router::SocialKind::Article)
96 - .syndicating(Feed::new(
97 - FeedKind::Rss,
98 - format!("{project_title} - Blog RSS"),
99 - feed,
100 - ))
101 - .with(page)
66 + // The visible offer, beside the autodiscovery tag rather than
67 + // instead of it: one is what a reader clicks and the other is what
68 + // a reader's app finds, and a page that has a feed owes both.
69 + act "RSS Feed" to get feed.clone() navigating;
70 +
71 + given posts.is_empty() {
72 + true -> empty "No blog posts yet.";
73 + otherwise -> list {
74 + for post in posts.iter() {
75 + row post.title.clone() {
76 + meta post.published_at.clone();
77 + activate to get "/p/{project_slug}/blog/{post.slug}" navigating;
78 + }
79 + }
80 + }
81 + }
82 +
83 + // The attribution the template's footer carried. Content rather
84 + // than decoration: it is where a reader on a creator's blog finds
85 + // out whose platform they are on.
86 + link "Powered by Makenot.work" to get "/" navigating;
87 + }
88 + }
102 89 }
103 90
104 91 /// The document this screen is drawn in.
@@ -203,6 +190,28 @@
203 190 assert!(rendered.contains("No blog posts yet."), "{rendered}");
204 191 }
205 192
193 + /// The two links the page carries besides the feed and the attribution.
194 + /// Every address the screen offers is what the conversion had to keep, and
195 + /// a link whose text survives with the wrong route reads as fine.
196 + #[test]
197 + fn the_creator_and_the_project_are_both_still_reachable() {
198 + let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
199 +
200 + assert!(rendered.contains("href=\"/u/maxj\""), "{rendered}");
201 + assert!(rendered.contains("href=\"/p/blue-hour\""), "{rendered}");
202 + assert!(rendered.contains(">maxj<"), "{rendered}");
203 + assert!(rendered.contains(">View project<"), "{rendered}");
204 + }
205 +
206 + /// What the page is, for anything reading the social tags. Said by the
207 + /// screen rather than by a template's `og:type`.
208 + #[test]
209 + fn a_blog_index_says_it_is_an_article() {
210 + let screen = screen("Blue Hour", "blue-hour", "maxj", &posts());
211 +
212 + assert_eq!(screen.discovery.kind, quasi_router::SocialKind::Article);
213 + }
214 +
206 215 /// `2790e5c4`. The template wrote the measure and the page token on the
207 216 /// body; a described document has no container, so both land there.
208 217 #[test]