Skip to main content

max / makenotwork

Declare the git notes panel, Fan+, the repo listing, Support, the export portal and the item files Wave 4 of the declaration transition. `progress.py` moves from `declared 33` to `declared 49`; MNW is 123 hand-written shapes of a 173 baseline. Sixteen shapes declared and four deleted. The deletions are the rule that a shape the form makes unnecessary is converted by removing it: user_support's `placeholder` and `length` assigned public `Field` fields that `Field::placeholder` and `Field::limited_to` have set the whole time, its `categories` built six choices through a closure over a const array, and its `response_times` was a named call to `own_prose` on a literal. What the data grew instead of the form. `Standing` gained `is_member`, `is_unsubscribed`, `is_visitor` and a `period_end` that answers with the empty string, because `Standing::Member` carries a value and a dispatch arm cannot name it. `Repo` gained `visibility_label` for the same reason. fan_plus's `BENEFITS` became a slice of a named struct, on policy's rule: a description names what it draws, and `.1` is not a name. Three suppliers written, each holding a branch inside an `Option` the form has no binding pattern to reach: git_notes' `attribution`, item_files' `file` and its `number_tone`, which hands back a `Tone` rather than the `Tag` a hand-written helper would have and so stays out of the population. Nothing on the acceptance list moved. 2688 lib tests pass, clippy clean.
Author: Max Johnson <me@maxj.phd> · 2026-09-03 20:14 UTC
Signed with PGP, not checked
Commit: 38e1e496ac2b1b10fc1bccbcd030f1162bdf6d47
Parent: 5920f4c
6 files changed, +520 insertions, -474 deletions
@@ -48,11 +48,8 @@
48 48 //! The problem holds that question.
49 49
50 50 use makeover_layout as layout;
51 - use quasi_router::screen::Act;
52 - use quasi_router::{
53 - Action, Document, Node, RegionKind, Request, Response, RouteError, Row, Screen as Described,
54 - Slot,
55 - };
51 + use quasi_declare::declare;
52 + use quasi_router::{Document, Request, Response, RouteError};
56 53 use quasi_webview::Webview;
57 54
58 55 /// The address, registered whole. See [`super::document_mount`].
@@ -169,83 +166,70 @@
169 166 Ok(page_screen(&page).into())
170 167 }
171 168
172 - fn page_screen(page: &Page) -> Described {
173 - Described::single("Export Your Data - Makenotwork")
174 - .measured(MEASURE)
169 + declare! {
170 + /// The whole document: the title, the measure, the body.
171 + shape page_screen(page: &Page) -> Screen;
172 +
173 + screen single "Export Your Data - Makenotwork" {
174 + measured MEASURE;
175 175 // `padded-page export-page`, which is what
176 176 // `dashboards/dashboard-export.html:4` rendered. Composed rather than
177 - // written out: `Document::classed` replaces, so a screen naming only
178 - // its own token would drop its measure (`2790e5c4`).
179 - .documented(
180 - Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"])),
181 - )
182 - .summarised("Download your content, projects, and transaction history.")
183 - .with(
184 - Slot::new(PAGE_REGION, RegionKind::Pane)
185 - .with(Node::Link {
186 - text: "Back to Dashboard".to_string(),
187 - action: Action::get("/dashboard").navigating(),
188 - })
189 - .with(Node::page("Export Your Data"))
190 - .with(Node::text(
191 - "Download your content, projects, and transaction history.",
192 - ))
193 - .with(Node::Region(cards(page)))
194 - .with(Node::section("About Your Data"))
195 - .with(Node::text(
196 - "Your data belongs to you. These exports contain everything we store \
197 - about your account and content. If you're planning to delete your \
198 - account, we recommend downloading your data first.",
199 - )),
200 - )
177 + // written out: `Document::classed` replaces, so a screen naming only its
178 + // own token would drop its measure (`2790e5c4`).
179 + documented Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"]));
180 + summarised "Download your content, projects, and transaction history.";
181 +
182 + region PAGE_REGION as Pane {
183 + link "Back to Dashboard" to get "/dashboard" navigating;
184 + page "Export Your Data";
185 + text "Download your content, projects, and transaction history.";
186 + include cards(page);
187 + section "About Your Data";
188 + text "Your data belongs to you. These exports contain everything we store \
189 + about your account and content. If you're planning to delete your \
190 + account, we recommend downloading your data first.";
191 + }
192 + }
201 193 }
202 194
203 - /// The card set: five direct exports, then the content archive when there is one.
204 - fn cards(page: &Page) -> Slot {
205 - let mut group = Slot::new(CARDS_REGION, RegionKind::Group);
195 + declare! {
196 + /// The card set: five direct exports, then the content archive when there is
197 + /// one.
198 + ///
199 + /// The five go through [`super::export_act::control`], which is this
200 + /// server's one sentence about posting a route and keeping the answer as a
201 + /// file. What a caller must not restate is that sentence, so the control
202 + /// arrives built and the row places it.
203 + shape cards(page: &Page) -> Slot;
206 204
207 - let mut rows: Vec<Row> = DIRECT
208 - .iter()
209 - .map(|export| {
210 - Row::new(export.title)
211 - .secondary(export.description)
212 - .meta(export.meta)
213 - .act(super::export_act::control(
214 - "Download",
215 - export.route,
216 - export.filename,
217 - ))
218 - })
219 - .collect();
205 + region CARDS_REGION as Group {
206 + list {
207 + for export in DIRECT {
208 + row export.title {
209 + secondary export.description;
210 + meta export.meta;
211 + include super::export_act::control("Download", export.route, export.filename);
212 + }
213 + }
220 214
221 - if page.has_content {
222 - rows.push(content_row(&page.content_size));
215 + include content_row(&page.content_size) when page.has_content;
216 + }
217 +
218 + // The status panel's home. Empty until the export is asked for, which is
219 + // what the shipped `<div class="export-status" id="content-status">` was.
220 + region CONTENT_STATUS as Group when page.has_content {}
223 221 }
224 -
225 - group = group.with(Node::List { rows, more: None });
226 -
227 - // The status panel's home. Empty until the export is asked for, which is
228 - // what the shipped `<div class="export-status" id="content-status">` was.
229 - if page.has_content {
230 - group = group.with(Node::Region(Slot::new(CONTENT_STATUS, RegionKind::Group)));
231 - }
232 -
233 - group
234 222 }
235 223
236 - /// The asynchronous one. See the module header for why it targets a region.
237 - fn content_row(size: &str) -> Row {
238 - Row::new("Content Files")
239 - .secondary(
240 - "All your uploaded audio files, cover images, version downloads, and dynamic clips.",
241 - )
242 - .meta(format!("ZIP archive ({size})"))
243 - .act(Act::new(
244 - "Download",
245 - Action::post("/api/export/content")
246 - .replacing(CONTENT_STATUS)
247 - .awaiting(),
248 - ))
224 + declare! {
225 + /// The asynchronous one. See the module header for why it targets a region.
226 + shape content_row(size: &str) -> Row;
227 +
228 + row "Content Files" {
229 + secondary "All your uploaded audio files, cover images, version downloads, and dynamic clips.";
230 + meta "ZIP archive ({size})";
231 + act "Download" to post "/api/export/content" replacing CONTENT_STATUS awaiting;
232 + }
249 233 }
250 234
251 235 #[must_use]
@@ -52,10 +52,9 @@
52 52 //! separate from its richness one (quasicoherent `24a3b1df`, quasi 0.94).
53 53
54 54 use makeover_layout as layout;
55 - use quasi_router::screen::{Figure, Row};
56 - use quasi_router::{
57 - Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
58 - };
55 + use quasi_declare::declare;
56 + use quasi_router::screen::Figure;
57 + use quasi_router::{Document, Request, Response, RouteError};
59 58 use quasi_webview::Webview;
60 59
61 60 use crate::db;
@@ -76,25 +75,56 @@
76 75 /// What the membership costs, in whole dollars per month.
77 76 const PRICE: &str = "$8";
78 77
78 + /// One thing a membership buys.
79 + ///
80 + /// Named members rather than a tuple, for `policy`'s reason: a description
81 + /// names what it draws, and `.1` is not a name.
82 + struct Benefit {
83 + name: &'static str,
84 + detail: &'static str,
85 + }
86 +
79 87 /// What a member gets.
80 88 ///
81 89 /// **Keep this list and the Fan+ card on the landing page identical, and keep
82 90 /// both to what the code grants.** Audited 2026-08-05, carried over from the
83 91 /// template's comment because it is a rule rather than a note: a benefit listed
84 92 /// here and not granted by the code is a promise nobody implemented.
85 - const BENEFITS: &[(&str, &str)] = &[
86 - (
87 - "$5 monthly credit",
88 - "A promo code delivered by email each billing cycle, usable toward any purchase on the platform",
89 - ),
90 - ("+ badge", "Displayed next to your name on your forum posts"),
91 - (
92 - "Forum signatures",
93 - "A signature block rendered under everything you post",
94 - ),
95 - ("Image embeds", "Post images in forum threads"),
93 + const BENEFITS: &[Benefit] = &[
94 + Benefit {
95 + name: "$5 monthly credit",
96 + detail: "A promo code delivered by email each billing cycle, usable toward any purchase on the platform",
97 + },
98 + Benefit {
99 + name: "+ badge",
100 + detail: "Displayed next to your name on your forum posts",
101 + },
102 + Benefit {
103 + name: "Forum signatures",
104 + detail: "A signature block rendered under everything you post",
105 + },
106 + Benefit {
107 + name: "Image embeds",
108 + detail: "Post images in forum threads",
109 + },
96 110 ];
97 111
112 + /// The region a member's own state is drawn in.
113 + const MEMBERSHIP: &str = "fan-plus-membership";
114 +
115 + /// The region the pitch is drawn in.
116 + const PITCH: &str = "fan-plus-pitch";
117 +
118 + /// What a visitor is offered instead of a control they cannot use.
119 + ///
120 + /// One sentence with two inline links, not two buttons. The sentence says which
121 + /// link is for whom and a pair of buttons does not, and that distinction is the
122 + /// whole point of `loose-wire g1-23`: Fan+ needs an account, so without the
123 + /// second link this page is a dead end for exactly the visitor it is written
124 + /// for. It is prose, so it is `own_prose`, on `/policy`'s rule.
125 + const VISITOR: &str =
126 + "[Create an account](/join) to join, or [log in](/login) if you already have one.";
127 +
98 128 /// What this request knows about the reader's membership.
99 129 enum Standing {
100 130 /// Nobody is signed in. The page is a pitch plus a way to get an account.
@@ -106,6 +136,40 @@
106 136 Member { period_end: Option<String> },
107 137 }
108 138
139 + /// The three questions the page asks about the reader, spelled once.
140 + ///
141 + /// Predicates rather than a dispatch: `Member` carries a value, and the form
142 + /// has no binding pattern to reach it through an arm.
143 + impl Standing {
144 + /// Signed in and paying.
145 + const fn is_member(&self) -> bool {
146 + matches!(self, Self::Member { .. })
147 + }
148 +
149 + /// Signed in, not paying: the one reader who can be sold to directly.
150 + const fn is_unsubscribed(&self) -> bool {
151 + matches!(self, Self::Unsubscribed)
152 + }
153 +
154 + /// Nobody is signed in.
155 + const fn is_visitor(&self) -> bool {
156 + matches!(self, Self::Visitor)
157 + }
158 +
159 + /// When the current billing period ends, or nothing.
160 + ///
161 + /// Empty rather than `None`, so the description asks one question and reads
162 + /// one answer instead of matching an `Option` it cannot spell a pattern for.
163 + fn period_end(&self) -> &str {
164 + match self {
165 + Self::Member {
166 + period_end: Some(end),
167 + } => end,
168 + _ => "",
169 + }
170 + }
171 + }
172 +
109 173 /// The page.
110 174 pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
111 175 // Moved out of the request rather than borrowed: the signature is quasi's,
@@ -140,81 +204,66 @@
140 204 })
141 205 }
142 206
143 - /// The whole document: the title, the measure, the body.
144 - fn page_screen(standing: &Standing, just_subscribed: bool) -> Described {
145 - let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Fan+"));
207 + declare! {
208 + /// The whole document: the title, the measure, the body.
209 + ///
210 + /// Three readers at one address, which is what the optional user bought:
211 + /// a visitor gets the pitch and both ways to get an account, a signed-in
212 + /// reader gets the pitch and the control that subscribes, and a member gets
213 + /// their membership's state and nothing to buy.
214 + shape page_screen(standing: &Standing, just_subscribed: bool) -> Screen;
146 215
147 - if just_subscribed {
148 - page = page.with(Node::banner(
149 - layout::Tone::Success,
150 - "You're now a Fan+ member. Welcome.",
151 - ));
216 + screen single "Fan+ - Makenotwork" {
217 + measured MEASURE;
218 + documented Document::default().classed(crate::shell::body_class(MEASURE, &["fan-plus-page"]));
219 + summarised "Support the platform and get $5 of credit back every month, plus forum badges, \
220 + signatures and image embeds.";
221 +
222 + region PAGE_REGION as Pane {
223 + page "Fan+";
224 + banner layout::Tone::Success "You're now a Fan+ member. Welcome." when just_subscribed;
225 +
226 + include membership(standing) when standing.is_member();
227 + include pitch() unless standing.is_member();
228 +
229 + act "Join Fan+" to post SUBSCRIBE awaiting when standing.is_unsubscribed();
230 + include super::own_prose(VISITOR) when standing.is_visitor();
231 + }
152 232 }
153 -
154 - page = match standing {
155 - Standing::Member { period_end } => membership(page, period_end.as_deref()),
156 - Standing::Unsubscribed => {
157 - pitch(page).with(Node::act("Join Fan+", Action::post(SUBSCRIBE).awaiting()))
158 - }
159 - Standing::Visitor => {
160 - // Fan+ needs an account, so a visitor gets both paths: the one for
161 - // people who already have one and the one for people who do not.
162 - // Without the second this page is a dead end for exactly the
163 - // visitor it is written for (`loose-wire g1-23`, and
164 - // `fan_plus_page_renders_for_anonymous` is the seal).
165 - //
166 - // One sentence with two inline links, not two buttons. The sentence
167 - // says which link is for whom and a pair of buttons does not, and
168 - // that distinction is the whole point of the finding above. It is
169 - // prose, so it is `Node::rich`, on `/policy`'s rule.
170 - pitch(page).with(super::own_prose(
171 - "[Create an account](/join) to join, or [log in](/login) if you already have one.",
172 - ))
173 - }
174 - };
175 -
176 - Described::single("Fan+ - Makenotwork")
177 - .measured(MEASURE)
178 - .documented(
179 - Document::default().classed(crate::shell::body_class(MEASURE, &["fan-plus-page"])),
180 - )
181 - .summarised(
182 - "Support the platform and get $5 of credit back every month, plus forum badges, \
183 - signatures and image embeds.",
184 - )
185 - .with(page)
186 233 }
187 234
188 - /// What a member is shown: the state of the thing they are paying for.
189 - fn membership(page: Slot, period_end: Option<&str>) -> Slot {
190 - let mut page = page.with(Node::text("Your Fan+ membership is active."));
235 + declare! {
236 + /// What a member is shown: the state of the thing they are paying for.
237 + shape membership(standing: &Standing) -> Slot;
191 238
192 - if let Some(end) = period_end {
193 - page = page.with(Node::text(format!("Current period ends: {end}")));
239 + region MEMBERSHIP as Group {
240 + text "Your Fan+ membership is active.";
241 + text "Current period ends: {standing.period_end()}"
242 + unless standing.period_end().is_empty();
243 + text "You'll receive a $5 credit code each billing cycle via email.";
194 244 }
195 -
196 - page.with(Node::text(
197 - "You'll receive a $5 credit code each billing cycle via email.",
198 - ))
199 245 }
200 246
201 - /// What somebody who is not a member is shown, whether or not they have an
202 - /// account. The two branches differ only in what they are offered afterwards.
203 - fn pitch(page: Slot) -> Slot {
204 - page.with(Node::text(
205 - "Support the platform and get something back every month.",
206 - ))
207 - .with(Node::section("What you get"))
208 - .with(Node::list(
209 - BENEFITS
210 - .iter()
211 - .map(|(name, detail)| Row::new(*name).secondary(*detail)),
212 - ))
213 - .with(Node::stats([Figure::new(PRICE, "per month")]))
214 - .with(Node::text(
215 - "Makenotwork is built on 0% platform fees. Fan+ is how you directly support the \
216 - platform's development and operations, while getting real value back each month.",
217 - ))
247 + declare! {
248 + /// What somebody who is not a member is shown, whether or not they have an
249 + /// account. The two branches differ only in what they are offered
250 + /// afterwards.
251 + shape pitch() -> Slot;
252 +
253 + region PITCH as Group {
254 + text "Support the platform and get something back every month.";
255 + section "What you get";
256 + list {
257 + for benefit in BENEFITS {
258 + row benefit.name {
259 + secondary benefit.detail;
260 + }
261 + }
262 + }
263 + stats [Figure::new(PRICE, "per month")];
264 + text "Makenotwork is built on 0% platform fees. Fan+ is how you directly support the \
265 + platform's development and operations, while getting real value back each month.";
266 + }
218 267 }
219 268
220 269 /// The document this screen is drawn in.
@@ -350,8 +399,8 @@
350 399 let html = html(&Standing::Unsubscribed, false);
351 400
352 401 assert_eq!(BENEFITS.len(), 4);
353 - for (name, _) in BENEFITS {
354 - assert!(html.contains(name), "{name} missing");
402 + for benefit in BENEFITS {
403 + assert!(html.contains(benefit.name), "{} missing", benefit.name);
355 404 }
356 405 }
357 406
@@ -29,10 +29,9 @@
29 29 //! here, because the push instructions are not theirs to act on.
30 30
31 31 use makeover_layout as layout;
32 - use quasi_router::screen::{Cell, Cells, Column, Table, Tag};
33 - use quasi_router::{
34 - Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
35 - };
32 + use quasi_declare::declare;
33 + use quasi_router::screen::Tag;
34 + use quasi_router::{Document, Request, Response, RouteError};
36 35 use quasi_webview::Webview;
37 36
38 37 use crate::db;
@@ -45,6 +44,9 @@
45 44
46 45 const MEASURE: layout::Measure = layout::Measure::Wide;
47 46
47 + /// The region an account with nothing published is drawn in.
48 + const EMPTY: &str = "git-repos-empty";
49 +
48 50 /// Everything the screen draws, resolved before it is drawn.
49 51 struct Loaded {
50 52 owner: String,
@@ -61,6 +63,16 @@
61 63 visibility: Option<String>,
62 64 }
63 65
66 + impl Repo {
67 + /// What the visibility cell says, or the empty string.
68 + ///
69 + /// The cell is guarded on the `Option` and this reads it, because a
70 + /// description reads a value and does not bind one out of a pattern.
71 + fn visibility_label(&self) -> &str {
72 + self.visibility.as_deref().unwrap_or_default()
73 + }
74 + }
75 +
64 76 /// The page.
65 77 pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
66 78 // Moved out because the handler signature is quasi's: the request is
@@ -115,88 +127,87 @@
115 127 })
116 128 }
117 129
118 - /// The whole document: the title, the measure, the body.
119 - fn page_screen(loaded: &Loaded) -> Described {
120 - let heading = format!("{}'s Repositories", loaded.owner);
130 + declare! {
131 + /// The whole document: the title, the measure, the body.
132 + shape page_screen(loaded: &Loaded) -> Screen;
121 133
122 - let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page(heading.clone()));
134 + let heading = "{loaded.owner}'s Repositories";
123 135
124 - page = if loaded.repos.is_empty() {
125 - empty(page, loaded.is_owner, &loaded.owner)
126 - } else {
127 - page.with(listing(loaded))
128 - };
136 + screen single "{heading} - Git - Makenotwork" {
137 + measured MEASURE;
138 + documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
129 139
130 - Described::single(format!("{heading} - Git - Makenotwork"))
131 - .measured(MEASURE)
132 - .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
133 - .with(page)
140 + region PAGE_REGION as Pane {
141 + page heading.clone();
142 + include empty(loaded.is_owner, &loaded.owner) when loaded.repos.is_empty();
143 + include listing(loaded) unless loaded.repos.is_empty();
144 + }
145 + }
134 146 }
135 147
136 - /// An account with nothing published.
137 - ///
138 - /// The owner gets the two commands that fix it; a visitor gets the fact. The
139 - /// shipped template made the same split and it is worth keeping: `git remote
140 - /// add` is not advice a stranger can take.
141 - fn empty(page: Slot, is_owner: bool, owner: &str) -> Slot {
142 - let page = page.with(Node::empty("No repositories yet."));
148 + declare! {
149 + /// An account with nothing published.
150 + ///
151 + /// The owner gets the two commands that fix it; a visitor gets the fact.
152 + /// The shipped template made the same split and it is worth keeping: `git
153 + /// remote add` is not advice a stranger can take.
154 + ///
155 + /// `own_prose` rather than `rich`, and the interpolation is the reason to
156 + /// say why: `owner` is a `Username`, which `validate_username` restricts to
157 + /// letters, digits and underscores, so it cannot carry markup into a source
158 + /// the renderer no longer hardens. A value that could would want `rich`
159 + /// instead, whatever else is in the string.
160 + shape empty(is_owner: bool, owner: &str) -> Slot;
143 161
144 - if !is_owner {
145 - return page;
162 + region EMPTY as Group {
163 + empty "No repositories yet.";
164 + include super::own_prose(
165 + "Push a new repository:\n\n```\ngit remote add origin \
166 + https://makenot.work/git/{owner}/my-repo.git\ngit push -u origin main\n```"
167 + ) when is_owner;
146 168 }
147 -
148 - // `own_prose` rather than `Node::rich`, and the interpolation is the reason
149 - // to say why: `owner` is a `Username`, which `validate_username` restricts
150 - // to letters, digits and underscores, so it cannot carry markup into a
151 - // source the renderer no longer hardens. A value that could would want
152 - // `Node::rich` instead, whatever else is in the string.
153 - page.with(super::own_prose(format!(
154 - "Push a new repository:\n\
155 - \n\
156 - ```\n\
157 - git remote add origin https://makenot.work/git/{owner}/my-repo.git\n\
158 - git push -u origin main\n\
159 - ```"
160 - )))
161 169 }
162 170
163 - /// The repositories, as a table.
164 - ///
165 - /// The template drew a `<ul>` of two-line entries. As a table the description
166 - /// says which column carries the identity and which can be dropped on a narrow
167 - /// viewport, rather than leaving a stack of divs to wrap however it wraps.
168 - fn listing(loaded: &Loaded) -> Node {
169 - let mut columns = vec![
170 - Column::new("Repository")
171 - .width(layout::Width::Content)
172 - .priority(layout::Priority::Essential),
173 - Column::new("Description").width(layout::Width::Fill),
174 - ];
175 - // Nobody but the owner is shown a visibility column, because for everybody
176 - // else every row in it would say the same word.
177 - if loaded.is_owner {
178 - columns.push(Column::new("Visibility").width(layout::Width::Content));
179 - }
171 + declare! {
172 + /// The repositories, as a table.
173 + ///
174 + /// The template drew a `<ul>` of two-line entries. As a table the
175 + /// description says which column carries the identity and which can be
176 + /// dropped on a narrow viewport, rather than leaving a stack of divs to wrap
177 + /// however it wraps.
178 + ///
179 + /// Nobody but the owner is shown a visibility column, because for everybody
180 + /// else every row in it would say the same word. The visibility cell names
181 + /// its column rather than counting to it, so the guard above is the only
182 + /// place ownership is decided: built by position, this row and that column
183 + /// list were two conditionals that had to agree, and nothing checked that
184 + /// they did.
185 + shape listing(loaded: &Loaded) -> Node;
180 186
181 - // The visibility cell names its column rather than counting to it, so the
182 - // conditional above is the only place ownership is decided. Built by
183 - // position, this row and that column list were two conditionals that had to
184 - // agree, and nothing checked that they did.
185 - Table::new(columns)
186 - .rows(loaded.repos.iter().map(|repo| {
187 - let visibility = repo
188 - .visibility
189 - .as_deref()
190 - .map(|visibility| Cell::new(String::new()).token(Tag::badge(visibility)));
191 - let mut row = Cells::default()
192 - .at("Repository", Cell::new(repo.name.clone()))
193 - .at("Description", Cell::new(repo.description.clone()));
194 - if let Some(cell) = visibility {
195 - row = row.at("Visibility", cell);
187 + table {
188 + column "Repository" {
189 + width Content;
190 + priority Essential;
191 + }
192 + column "Description" {
193 + width Fill;
194 + }
195 + column "Visibility" when loaded.is_owner {
196 + width Content;
197 + }
198 +
199 + for repo in loaded.repos.iter() {
200 + cells {
201 + cell at "Repository" repo.name.clone();
202 + cell at "Description" repo.description.clone();
203 + cell at "Visibility" "" when repo.visibility.is_some() {
204 + token Tag::badge(repo.visibility_label());
205 + }
206 +
207 + activate to get "/git/{loaded.owner}/{repo.name}" navigating;
196 208 }
197 - row.activate(Action::get(format!("/git/{}/{}", loaded.owner, repo.name)).navigating())
198 - }))
199 - .into()
209 + }
210 + }
200 211 }
201 212
202 213 /// The document this screen is drawn in.
@@ -169,8 +169,9 @@
169 169 //! [`Action::replacing_enclosing`]: quasi_router::Action::replacing_enclosing
170 170
171 171 use makeover_layout as layout;
172 - use quasi_router::screen::{Act, Cell, Cells, Column, Table, Tag};
173 - use quasi_router::{Action, Node, RegionKind, Slot};
172 + use quasi_declare::declare;
173 + use quasi_router::RegionKind;
174 + use quasi_router::screen::Tag;
174 175 use quasi_webview::Webview;
175 176
176 177 use crate::types::Version;
@@ -202,50 +203,61 @@
202 203 .fragment(&pane(item_id, versions))
203 204 }
204 205
205 - /// The panel in its region.
206 - fn pane(item_id: &str, versions: &[Version]) -> Node {
207 - let mut slot = Slot::new(REGION, RegionKind::Pane).with(Node::section("Files & Versions"));
206 + declare! {
207 + /// The panel in its region.
208 + ///
209 + /// The upload machine sits below the table on an empty panel too, which is
210 + /// what "create your first version below" points at.
211 + shape pane(item_id: &str, versions: &[Version]) -> Node;
208 212
209 - if versions.is_empty() {
210 - slot = slot.with(Node::empty(
211 - "No versions uploaded yet. Create your first version below.",
212 - ));
213 - } else {
214 - slot = slot.with(table(item_id, versions));
213 + region REGION as Pane {
214 + section "Files & Versions";
215 + empty "No versions uploaded yet. Create your first version below."
216 + when versions.is_empty();
217 + include table(item_id, versions) unless versions.is_empty();
218 +
219 + region UPLOADER_SLOT as RegionKind::handover("version-upload") {}
215 220 }
216 -
217 - // The upload machine, whatever state it is in. Below the table on an empty
218 - // panel too, which is what "create your first version below" points at.
219 - slot = slot.with(Node::Region(Slot::handover(
220 - UPLOADER_SLOT,
221 - "version-upload",
222 - )));
223 -
224 - Node::Region(slot)
225 221 }
226 222
227 - /// The versions that exist.
228 - ///
229 - /// The column list lives here and the cells live in [`row`], a function down,
230 - /// so the cells name their columns rather than counting to them: position is
231 - /// only safe while both halves are in front of you at once, and these two are
232 - /// not. The heading strings below are the whole of what [`row`] has to match.
233 - ///
234 - /// No `.more(..)`: every version of an item arrives in one query, so nothing is
235 - /// held back and there is no rest to ask for.
236 - fn table(item_id: &str, versions: &[Version]) -> Node {
237 - Table::new([
238 - Column::new(COL_VERSION).width(layout::Width::Content),
239 - Column::new(COL_LABEL).width(layout::Width::Fill),
240 - Column::new(COL_FILE)
241 - .width(layout::Width::Fill)
242 - .priority(layout::Priority::Essential),
243 - Column::new(COL_SIZE).width(layout::Width::Content),
244 - Column::new(COL_DOWNLOADS).width(layout::Width::Content),
245 - Column::new(COL_ACTS).width(layout::Width::Content),
246 - ])
247 - .rows(versions.iter().map(|version| row(item_id, version)))
248 - .into()
223 + declare! {
224 + /// The versions that exist.
225 + ///
226 + /// The column list lives here and the cells live in [`row`], a shape down,
227 + /// so the cells name their columns rather than counting to them: position is
228 + /// only safe while both halves are in front of you at once, and these two
229 + /// are not. The heading strings below are the whole of what [`row`] has to
230 + /// match.
231 + ///
232 + /// Nothing held back: every version of an item arrives in one query, so
233 + /// there is no rest to ask for.
234 + shape table(item_id: &str, versions: &[Version]) -> Node;
235 +
236 + table {
237 + column COL_VERSION {
238 + width Content;
239 + }
240 + column COL_LABEL {
241 + width Fill;
242 + }
243 + column COL_FILE {
244 + width Fill;
245 + priority Essential;
246 + }
247 + column COL_SIZE {
248 + width Content;
249 + }
250 + column COL_DOWNLOADS {
251 + width Content;
252 + }
253 + column COL_ACTS {
254 + width Content;
255 + }
256 +
257 + for version in versions.iter() {
258 + include row(item_id, version);
259 + }
260 + }
249 261 }
250 262
251 263 /// The headings, written once.
@@ -262,46 +274,52 @@
262 274 const COL_DOWNLOADS: &str = "Downloads";
263 275 const COL_ACTS: &str = "";
264 276
265 - /// One version.
266 - fn row(item_id: &str, version: &Version) -> Cells {
267 - let mut number = Tag::badge(format!("v{}", version.number));
277 + /// The current version's badge is the one that reads as a state rather than a
278 + /// number.
279 + fn number_tone(version: &Version) -> layout::Tone {
268 280 if version.is_current {
269 - number.tone = layout::Tone::Success;
270 - }
271 -
272 - // What the template said in a three-way `{% if %}`/`{% match %}`: the
273 - // file's name when it has one, "1 file" when it has a file the name of
274 - // which was not recorded, and "No file" when it has none.
275 - let file = if version.has_file {
276 - version
277 - .file_name
278 - .clone()
279 - .unwrap_or_else(|| "1 file".to_string())
281 + layout::Tone::Success
280 282 } else {
281 - "No file".to_string()
282 - };
283 -
284 - let mut acts = Vec::new();
285 - if version.has_file {
286 - // `9dbe9206`. The route answers 303 to the presigned URL, so going
287 - // there is the download and no host code has to know that.
288 - acts.push(Act::new(
289 - "Download",
290 - Action::get(format!("/api/versions/{}/download", version.id)),
291 - ));
283 + layout::Tone::Neutral
292 284 }
293 - acts.push(super::version_delete_act::act(item_id, &version.id));
285 + }
294 286
295 - Cells::default()
296 - .at(COL_VERSION, Cell::new(String::new()).token(number))
297 - .at(
298 - COL_LABEL,
299 - Cell::new(version.label.clone().unwrap_or_default()),
300 - )
301 - .at(COL_FILE, Cell::new(file))
302 - .at(COL_SIZE, Cell::new(version.size.clone()))
303 - .at(COL_DOWNLOADS, Cell::new(version.downloads.to_string()))
304 - .at(COL_ACTS, Cell::acts(acts))
287 + /// What the file cell says.
288 + ///
289 + /// The template's three-way `{% if %}`/`{% match %}`: the file's name when it
290 + /// has one, "1 file" when it has a file the name of which was not recorded, and
291 + /// "No file" when it has none. A supplier because the middle case is a fallback
292 + /// computed from an `Option`, which the form has no expression to hold.
293 + fn file(version: &Version) -> String {
294 + if !version.has_file {
295 + return "No file".to_string();
296 + }
297 + version
298 + .file_name
299 + .clone()
300 + .unwrap_or_else(|| "1 file".to_string())
301 + }
302 +
303 + declare! {
304 + /// One version.
305 + shape row(item_id: &str, version: &Version) -> Cells;
306 +
307 + cells {
308 + cell at COL_VERSION "" {
309 + token Tag::badge("v{version.number}").tone(number_tone(version));
310 + }
311 + cell at COL_LABEL version.label.clone().unwrap_or_default();
312 + cell at COL_FILE file(version);
313 + cell at COL_SIZE version.size.clone();
314 + cell at COL_DOWNLOADS version.downloads.to_string();
315 + cell at COL_ACTS "" {
316 + // `9dbe9206`. The route answers 303 to the presigned URL, so going
317 + // there is the download and no host code has to know that.
318 + act "Download" to get "/api/versions/{version.id}/download"
319 + when version.has_file;
320 + include super::version_delete_act::act(item_id, &version.id);
321 + }
322 + }
305 323 }
306 324
307 325 #[cfg(test)]
@@ -37,9 +37,9 @@
37 37 //! source rather than a described list: a terminal renders the same three lines
38 38 //! without being handed `<ul>`.
39 39
40 - use makeover_layout as layout;
41 - use quasi_router::screen::{Choice, Field};
42 - use quasi_router::{Action, Node, RegionKind, Slot};
40 + use quasi_declare::declare;
41 + use quasi_router::screen::Choice;
42 + use quasi_router::{Node, RegionKind, Slot};
43 43 use quasi_webview::Webview;
44 44
45 45 /// The region the answer replaces, keeping the id the page already used.
@@ -52,6 +52,17 @@
52 52 /// empty until something is submitted.
53 53 const RESULT: &str = "support-result";
54 54
55 + /// How long an answer takes, and the way round the queue for a security issue.
56 + ///
57 + /// Prose. It is three durations and a mailto, not a set of things with
58 + /// addresses, so it is markdown carrying the source rather than a described
59 + /// list: a terminal renders the same three lines without being handed a `<ul>`.
60 + const RESPONSE_TIMES: &str = "Response times:\n\n\
61 + - Security issues: same day\n\
62 + - Billing and account access: 24 hours\n\
63 + - Everything else: 1-2 business days\n\n\
64 + For urgent security issues, email <security@makenot.work> directly.";
65 +
55 66 /// The panel as the route answers it: the region, carrying its own id.
56 67 #[must_use]
57 68 pub fn fragment(email: &str) -> String {
@@ -76,99 +87,74 @@
76 87 out
77 88 }
78 89
79 - /// The panel's contents, in order.
80 - fn body(email: &str) -> Vec<Node> {
81 - vec![
82 - Node::section("Support"),
83 - Node::text(
84 - "Every response is from a real person. We will never use AI to handle your questions.",
85 - ),
86 - Node::text(format!(
87 - "We monitor the platform proactively and may have already opened a ticket for your \
88 - issue. Check your email at {email} before submitting."
89 - )),
90 - ticket_form(),
91 - // Where the answer lands. Named by the form's own action, and empty
92 - // until something is submitted.
93 - Node::Region(Slot::new(RESULT, RegionKind::Pane)),
94 - response_times(),
95 - ]
90 + declare! {
91 + /// The panel's contents, in order.
92 + ///
93 + /// A list of members and not a region: the dashboard strip draws the frame
94 + /// and its `id`, so [`fill`] must add no second one, and [`fragment`] wraps
95 + /// the same members itself for the tab that answers over htmx.
96 + shape body(email: &str) -> Vec<Node>;
97 +
98 + section "Support";
99 + text "Every response is from a real person. We will never use AI to handle your questions.";
100 + text "We monitor the platform proactively and may have already opened a ticket for your \
101 + issue. Check your email at {email} before submitting.";
102 +
103 + include ticket_form();
104 +
105 + // Where the answer lands. Named by the form's own action, and empty until
106 + // something is submitted.
107 + region RESULT as Pane {}
108 +
109 + include super::own_prose(RESPONSE_TIMES);
96 110 }
97 111
98 - /// The ticket form.
99 - fn ticket_form() -> Node {
100 - Node::Form {
101 - // `awaiting` is the whole of what `hx-indicator="#support-spinner"` and
102 - // the span it pointed at were doing. See the module header.
103 - action: Action::post(TICKET).awaiting(),
104 - submit: "Submit".into(),
105 - fields: vec![
106 - Field::select("category", "Category", categories()).required(),
107 - length(
108 - placeholder(
109 - Field::new(layout::FieldKind::Text, "subject", "Subject").required(),
110 - "Brief description of the issue",
111 - ),
112 - 200,
113 - ),
114 - length(
115 - placeholder(
116 - Field::new(layout::FieldKind::Textarea, "message", "Message").required(),
117 - "What happened? What did you expect? Include any relevant details.",
118 - ),
119 - 5000,
120 - ),
121 - ],
112 + declare! {
113 + /// The ticket form.
114 + ///
115 + /// `awaiting` is the whole of what `hx-indicator="#support-spinner"` and the
116 + /// span it pointed at were doing. See the module header.
117 + ///
118 + /// The category select opened with `<option value="" disabled selected>`, a
119 + /// markup idiom for "nothing chosen yet" that a description says by the
120 + /// field simply having no value. Six real options remain, and a renderer
121 + /// that is not a browser no longer has to know that one of the seven was a
122 + /// placeholder.
123 + ///
124 + /// The two lengths are the same limits the API validates against, and a
125 + /// form that lets a reader type 6000 characters before refusing them is
126 + /// worse than one that stops at 5000.
127 + shape ticket_form() -> Node;
128 +
129 + form post TICKET awaiting {
130 + submit "Submit";
131 +
132 + field Select "category" "Category" {
133 + options [
134 + Choice::new("bug", "Bug report"),
135 + Choice::new("billing", "Billing or payments"),
136 + Choice::new("account", "Account access"),
137 + Choice::new("content", "Content or uploads"),
138 + Choice::new("security", "Security concern"),
139 + Choice::new("other", "Other"),
140 + ];
141 + required;
142 + }
143 +
144 + field Text "subject" "Subject" {
145 + required;
146 + placeholder "Brief description of the issue";
147 + limited_to 200;
148 + }
149 +
150 + field Textarea "message" "Message" {
151 + required;
152 + placeholder "What happened? What did you expect? Include any relevant details.";
153 + limited_to 5000;
154 + }
122 155 }
123 156 }
124 157
125 - /// What a ticket can be about.
126 - ///
127 - /// The template's seventh option was `value=""`, disabled and selected: the
128 - /// markup spelling of "nothing chosen yet". A field with no value says it, so
129 - /// only the six real categories are here.
130 - fn categories() -> Vec<Choice> {
131 - [
132 - ("bug", "Bug report"),
133 - ("billing", "Billing or payments"),
134 - ("account", "Account access"),
135 - ("content", "Content or uploads"),
136 - ("security", "Security concern"),
137 - ("other", "Other"),
138 - ]
139 - .into_iter()
140 - .map(|(value, label)| Choice::new(value, label))
141 - .collect()
142 - }
143 -
144 - /// How long an answer takes, and the way round the queue for a security issue.
145 - fn response_times() -> Node {
146 - super::own_prose(
147 - "Response times:\n\n\
148 - - Security issues: same day\n\
149 - - Billing and account access: 24 hours\n\
150 - - Everything else: 1-2 business days\n\n\
151 - For urgent security issues, email <security@makenot.work> directly.",
152 - )
153 - }
154 -
155 - /// Set a field's placeholder. A plain field assignment, since `Field` has no
156 - /// builder for it and the field is public.
157 - fn placeholder(mut field: Field, text: &str) -> Field {
158 - field.placeholder = Some(text.to_owned());
159 - field
160 - }
161 -
162 - /// Cap what a field accepts, matching the template's `maxlength`.
163 - ///
164 - /// Kept rather than dropped: it is the same limit the API validates against, and
165 - /// a form that lets a reader type 6000 characters before refusing them is worse
166 - /// than one that stops at 5000.
167 - fn length(mut field: Field, max: u32) -> Field {
168 - field.max_length = Some(max);
169 - field
170 - }
171 -
172 158 #[cfg(test)]
173 159 mod tests {
174 160 use super::*;
@@ -28,9 +28,8 @@
28 28 //! reading, and the shipped rule set `font-family: var(--font-mono)` on it for
29 29 //! the same reason without being able to say so.
30 30
31 - use makeover_layout as layout;
32 - use quasi_router::screen::Lexeme;
33 - use quasi_router::{Node, RegionKind, Run, Slot};
31 + use quasi_declare::declare;
32 + use quasi_router::RegionKind;
34 33 use quasi_webview::Webview;
35 34
36 35 use crate::routes::git::notes_view::CommitNote;
@@ -61,22 +60,20 @@
61 60 .collect()
62 61 }
63 62
64 - /// The panel, for a screen that owns its whole document.
65 - ///
66 - /// `None` when there is nothing to show. The shipped partial wrapped itself in
67 - /// `{% if !notes.is_empty() %}`, and an empty panel here would be a bordered
68 - /// box with nothing in it rather than the absence the page wants.
69 - #[must_use]
70 - pub fn region(notes: &[CommitNote]) -> Option<Node> {
71 - if notes.is_empty() {
72 - return None;
73 - }
63 + declare! {
64 + /// The panel, for a screen that owns its whole document.
65 + ///
66 + /// `None` when there is nothing to show. The shipped partial wrapped itself
67 + /// in `{{% if !notes.is_empty() %}}`, and an empty panel here would be a
68 + /// bordered box with nothing in it rather than the absence the page wants.
69 + #[must_use]
70 + pub shape region(notes: &[CommitNote]) -> Option<Node>;
74 71
75 - let mut panel = Slot::new(REGION, RegionKind::Group);
76 - for note in notes {
77 - panel = panel.with(Node::Region(one(note)));
72 + region REGION as Group unless notes.is_empty() {
73 + for note in notes {
74 + include one(note);
75 + }
78 76 }
79 - Some(Node::Region(panel))
80 77 }
81 78
82 79 /// The markup, for an Askama template to drop in.
@@ -120,65 +117,50 @@
120 117 webview
121 118 }
122 119
123 - /// One note: what it is, where it came from, and what it says.
124 - fn one(note: &CommitNote) -> Slot {
125 - // The header is one line: a name on the left and a provenance on the
126 - // right. Wrapping keeps both when there is no room for one line, which
127 - // is what the shipped rule's `flex-wrap` did.
128 - let mut header = Run::new(layout::Fallback::Wrap).beside(
129 - Node::Code {
130 - runs: vec![Lexeme::plain(&note.namespace)],
131 - language: None,
132 - inline: true,
133 - },
134 - layout::Priority::Essential,
135 - );
136 -
137 - if note.read_only {
138 - // Says where it came from, so a note nobody on the repo wrote does not
139 - // read as one of theirs they have forgotten.
140 - header = header.beside(
141 - Node::Text {
142 - text: "written by makenot.work".to_owned(),
143 - tone: layout::Tone::Neutral,
144 - },
145 - layout::Priority::Secondary,
146 - );
120 + /// Who last edited the note, or the bound the walk stopped at.
121 + ///
122 + /// A supplier because the line is decided by `exact`, which is a field inside
123 + /// an `Option` the form has no binding pattern to reach. Empty when there is no
124 + /// attribution, which is what R9 asks of a supplier: the value is built whether
125 + /// or not the guard places it.
126 + ///
127 + /// `exact` is the load-bearing field. When the walk hit its budget the commit
128 + /// it stopped at is not the one that made the edit, so the line names a bound
129 + /// and never a person.
130 + fn attribution(note: &CommitNote) -> String {
131 + let Some(attribution) = &note.attribution else {
132 + return String::new();
133 + };
134 + if attribution.exact {
135 + format!("{} - {}", attribution.by, attribution.when)
136 + } else {
137 + format!(
138 + "edited since {} - {}",
139 + attribution.short_commit, attribution.when
140 + )
147 141 }
142 + }
148 143
149 - if let Some(attribution) = &note.attribution {
150 - // `exact` is the load-bearing field. When the walk hit its budget the
151 - // commit it stopped at is not the one that made the edit, so the line
152 - // names a bound and never a person.
153 - let text = if attribution.exact {
154 - format!("{} - {}", attribution.by, attribution.when)
155 - } else {
156 - format!(
157 - "edited since {} - {}",
158 - attribution.short_commit, attribution.when
159 - )
160 - };
161 - header = header.beside(
162 - Node::Text {
163 - text,
164 - tone: layout::Tone::Neutral,
165 - },
166 - layout::Priority::Secondary,
167 - );
144 + declare! {
145 + /// One note: what it is, where it came from, and what it says.
146 + ///
147 + /// The header is one line: a name on the left and a provenance on the
148 + /// right. Wrapping keeps both when there is no room for one line, which is
149 + /// what the shipped rule's `flex-wrap` did. A note nobody on the repo wrote
150 + /// says so, so it does not read as one of theirs they have forgotten.
151 + shape one(note: &CommitNote) -> Slot;
152 +
153 + region "note-{slug(&note.namespace)}" as Group {
154 + region "note-header-{slug(&note.namespace)}" as Group {
155 + across Wrap {
156 + beside Essential literal &note.namespace;
157 + beside Secondary text "written by makenot.work" when note.read_only;
158 + beside Secondary text attribution(note) when note.attribution.is_some();
159 + }
160 + }
161 +
162 + region body_region(&note.namespace) as RegionKind::handover("a rendered note") {}
168 163 }
169 -
170 - Slot::new(format!("note-{}", slug(&note.namespace)), RegionKind::Group)
171 - .with(Node::Region(
172 - Slot::new(
173 - format!("note-header-{}", slug(&note.namespace)),
174 - RegionKind::Group,
175 - )
176 - .across(header),
177 - ))
178 - .with(Node::Region(Slot::handover(
179 - body_region(&note.namespace),
180 - "a rendered note",
181 - )))
182 164 }
183 165
184 166 #[cfg(test)]
@@ -254,6 +236,22 @@
254 236 assert!(!inexact.contains("ada"), "{inexact}");
255 237 }
256 238
239 + /// Each note is its own region, under the id the panel builds from the
240 + /// namespace. Two notes on one page are two boxes, and the header is a box
241 + /// inside each of them.
242 + #[test]
243 + fn every_note_is_its_own_region() {
244 + let html = html(&[
245 + note("commits", false, None),
246 + note("mnw/builds", false, None),
247 + ]);
248 +
249 + assert!(html.contains("id=\"note-commits\""), "{html}");
250 + assert!(html.contains("id=\"note-header-commits\""), "{html}");
251 + assert!(html.contains("id=\"note-mnw-builds\""), "{html}");
252 + assert!(html.contains("id=\"note-header-mnw-builds\""), "{html}");
253 + }
254 +
257 255 /// The namespace is machine text in a line of reading, which is what the
258 256 /// inline code member is for.
259 257 #[test]