Skip to main content

max / makenotwork

Declare the collection page, blame, the feed, project analytics and the projects panel Wave 5 of the declaration transition. `progress.py` moves from `declared 49` to `declared 67`; MNW is 106 hand-written shapes of a 173 baseline. `git_blame` is finished. It was the walking skeleton in wave 0 and has carried a declared header and a hand-written table since; the table and its rows are declared now, which is what wave 3's named cells and this wave's cell parts were for. Eighteen shapes across five files, and the form grew twice in the whole wave. What the conversions needed instead was vocabulary and data with names: - `RANGES` in project_analytics becomes a slice of a named struct, on policy's rule that a description names what it draws and `.1` is not a name. - `Loaded` in collections gains a `description` that answers with the empty string, so the screen asks one question rather than matching an `Option` it cannot spell a pattern for. - feeds' `rest` answers with a `Rest` rather than an `Option<Rest>`, because the guard on the table's `more` setting is what the `None` was saying. Six suppliers written, and every one of them hands back a `String`, a `bool` or a `Tone` rather than a vocabulary type, so none of them is counted in the population. That is the habit wave 4 found and this wave applied on purpose. Nothing on the acceptance list moved. 2688 lib tests pass, clippy clean.
Author: Max Johnson <me@maxj.phd> · 2026-09-03 22:39 UTC
Signed with PGP, not checked
Commit: ad7a152431dcc0f08a17858a1b1b9243de77f7e3
Parent: 5c30732
5 files changed, +550 insertions, -463 deletions
@@ -31,10 +31,8 @@
31 31 //! and the design system draws the control and the confirmation.
32 32
33 33 use makeover_layout as layout;
34 - use quasi_router::screen::{Act, Cell, Cells, Column, Table};
35 - use quasi_router::{
36 - Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
37 - };
34 + use quasi_declare::declare;
35 + use quasi_router::{Document, Request, Response, RouteError};
38 36 use quasi_webview::Webview;
39 37
40 38 use crate::db;
@@ -62,6 +60,16 @@
62 60 items: Vec<Item>,
63 61 }
64 62
63 + impl Loaded {
64 + /// What the collection says about itself, or nothing.
65 + ///
66 + /// Empty rather than `None`, so the description asks one question and reads
67 + /// one answer instead of matching an `Option` it cannot spell a pattern for.
68 + fn description(&self) -> &str {
69 + self.description.as_deref().unwrap_or_default()
70 + }
71 + }
72 +
65 73 /// One item in the collection.
66 74 ///
67 75 /// `item_type` reads as a repetition of the struct's name and is not one: it is
@@ -162,95 +170,96 @@
162 170 })
163 171 }
164 172
165 - /// The whole document: the title, the measure, the body.
166 - fn page_screen(loaded: &Loaded) -> Described {
167 - let mut page = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page(loaded.title.clone()));
173 + declare! {
174 + /// The whole document: the title, the measure, the body.
175 + shape page_screen(loaded: &Loaded) -> Screen;
168 176
169 - // "by <owner>", with the owner's name as a link rather than a button: it is
170 - // a name that goes somewhere, which is exactly what `Node::Link` is for and
171 - // what `Node::act` would draw a bevel around.
172 - page = page.with(Node::Link {
173 - text: format!("by {}", loaded.owner_shown),
174 - action: Action::get(format!("/u/{}", loaded.owner_username)).navigating(),
175 - });
177 + screen single "{loaded.title} - {loaded.owner_username} - Makenotwork" {
178 + measured MEASURE;
179 + documented Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"]));
176 180
177 - // The template put a `Private` badge beside the owner's name. A `Tag` is
178 - // not a `Node` -- tokens belong to rows and cells -- and rather than invent
179 - // a row to hold one, this says it as a banner. That is the better reading
180 - // anyway: the only person who sees it is the owner, and what they need to
181 - // know is that nobody else can open the link they are looking at, which a
182 - // small badge next to a name says quietly and a banner says once.
183 - if !loaded.is_public {
184 - page = page.with(Node::banner(
185 - layout::Tone::Info,
186 - "This collection is private. Only you can see it.",
187 - ));
181 + region PAGE_REGION as Pane {
182 + page loaded.title.clone();
183 +
184 + // "by <owner>", with the owner's name as a link rather than a
185 + // button: it is a name that goes somewhere, which is exactly what a
186 + // link is for and what an act would draw a bevel around.
187 + link "by {loaded.owner_shown}" to get "/u/{loaded.owner_username}" navigating;
188 +
189 + // The template put a `Private` badge beside the owner's name. A
190 + // `Tag` is not a `Node` -- tokens belong to rows and cells -- and
191 + // rather than invent a row to hold one, this says it as a banner.
192 + // That is the better reading anyway: the only person who sees it is
193 + // the owner, and what they need to know is that nobody else can open
194 + // the link they are looking at, which a small badge next to a name
195 + // says quietly and a banner says once.
196 + banner layout::Tone::Info "This collection is private. Only you can see it."
197 + unless loaded.is_public;
198 +
199 + text loaded.description() unless loaded.description().is_empty();
200 + text "{loaded.items.len()} items";
201 +
202 + empty "This collection is empty." when loaded.items.is_empty();
203 + include items_table(&loaded.items) unless loaded.items.is_empty();
204 +
205 + act "View profile" to get "/u/{loaded.owner_username}" navigating;
206 +
207 + // `copying` is a setting on the control and not a modifier of the
208 + // action: it sets the destination to `local` itself, because a copy
209 + // asks no route and the two facts are one sentence.
210 + act "Copy link" to local {
211 + copying "/c/{loaded.owner_username}/{loaded.slug}";
212 + }
213 + }
188 214 }
189 -
190 - if let Some(description) = loaded.description.as_deref() {
191 - page = page.with(Node::text(description));
192 - }
193 -
194 - page = page.with(Node::text(format!("{} items", loaded.items.len())));
195 -
196 - page = if loaded.items.is_empty() {
197 - page.with(Node::empty("This collection is empty."))
198 - } else {
199 - page.with(items_table(&loaded.items))
200 - };
201 -
202 - page = page
203 - .with(Node::act(
204 - "View profile",
205 - Action::get(format!("/u/{}", loaded.owner_username)).navigating(),
206 - ))
207 - .with(Node::Act(Act::new("Copy link", Action::local()).copying(
208 - format!("/c/{}/{}", loaded.owner_username, loaded.slug),
209 - )));
210 -
211 - Described::single(format!(
212 - "{} - {} - Makenotwork",
213 - loaded.title, loaded.owner_username
214 - ))
215 - .measured(MEASURE)
216 - .documented(
217 - Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"])),
218 - )
219 - .with(page)
220 215 }
221 216
222 - /// The items, as a table.
223 - ///
224 - /// The template drew three stacked `<div>`s per item -- title, a middot-joined
225 - /// meta line, and a price pushed to the right -- which is a table written by
226 - /// hand. Saying it as one lets the design system decide what a narrow viewport
227 - /// drops, and the meta line's three facts become three columns that can be
228 - /// dropped independently rather than a string that wraps.
229 - fn items_table(items: &[Item]) -> Node {
230 - // The cells are positional, and stay so because the columns are three lines
231 - // above them in this one expression and every item fills all five. Naming
232 - // buys nothing a reader cannot already see; it earns its keep where a cell
233 - // is conditional or the headings live in another function.
234 - Table::new([
235 - Column::new("Item")
236 - .width(layout::Width::Fill)
237 - .priority(layout::Priority::Essential),
238 - Column::new("Type").width(layout::Width::Content),
239 - Column::new("Creator").width(layout::Width::Content),
240 - Column::new("Project").width(layout::Width::Content),
241 - Column::new("Price").width(layout::Width::Content),
242 - ])
243 - .rows(items.iter().map(|item| {
244 - Cells::new([
245 - Cell::new(item.title.clone()),
246 - Cell::new(item.item_type.clone()),
247 - Cell::new(item.creator.clone()),
248 - Cell::new(item.project.clone()),
249 - Cell::new(item.price.clone()),
250 - ])
251 - .activate(Action::get(format!("/i/{}", item.id)).navigating())
252 - }))
253 - .into()
217 + declare! {
218 + /// The items, as a table.
219 + ///
220 + /// The template drew three stacked `<div>`s per item -- title, a
221 + /// middot-joined meta line, and a price pushed to the right -- which is a
222 + /// table written by hand. Saying it as one lets the design system decide
223 + /// what a narrow viewport drops, and the meta line's three facts become
224 + /// three columns that can be dropped independently rather than a string that
225 + /// wraps.
226 + ///
227 + /// The cells are positional, and stay so because the columns are five lines
228 + /// above them in one declaration and every item fills all five. Naming buys
229 + /// nothing a reader cannot already see; it earns its keep where a cell is
230 + /// conditional or the headings live in another shape.
231 + shape items_table(items: &[Item]) -> Node;
232 +
233 + table {
234 + column "Item" {
235 + width Fill;
236 + priority Essential;
237 + }
238 + column "Type" {
239 + width Content;
240 + }
241 + column "Creator" {
242 + width Content;
243 + }
244 + column "Project" {
245 + width Content;
246 + }
247 + column "Price" {
248 + width Content;
249 + }
250 +
251 + for item in items.iter() {
252 + cells {
253 + cell item.title.clone();
254 + cell item.item_type.clone();
255 + cell item.creator.clone();
256 + cell item.project.clone();
257 + cell item.price.clone();
258 +
259 + activate to get "/i/{item.id}" navigating;
260 + }
261 + }
262 + }
254 263 }
255 264
256 265 /// The document this screen is drawn in.
@@ -82,10 +82,9 @@
82 82 //! [`Cell::part`]: quasi_router::screen::Cell::part
83 83
84 84 use makeover_layout as layout;
85 - use quasi_router::screen::{Act, Cell, Cells, Column, Rest, Table, Tag};
86 - use quasi_router::{
87 - Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot,
88 - };
85 + use quasi_declare::declare;
86 + use quasi_router::screen::{Rest, Tag};
87 + use quasi_router::{Action, Document, Node, RegionKind, Request, Response, RouteError, Slot};
89 88 use quasi_webview::Webview;
90 89
91 90 use crate::constants;
@@ -255,20 +254,29 @@
255 254 Ok(page_screen(&loaded.page()).into())
256 255 }
257 256
258 - /// The whole document: the title, the measure, the body.
259 - fn page_screen(page: &Page<'_>) -> Described {
260 - let mut pane = Slot::new(PAGE_REGION, RegionKind::Pane).with(Node::page("Your Feed"));
261 - for node in body(page, Surface::Page) {
262 - pane = pane.with(node);
263 - }
264 - Described::single("Feed - Makenotwork")
265 - .measured(MEASURE)
257 + declare! {
258 + /// The whole document: the title, the measure, the body.
259 + ///
260 + /// The body arrives as a list of members and is spread into the pane one at
261 + /// a time, which is the same loop both callers wrote by hand: a fill has no
262 + /// region of its own, so nothing can place it whole.
263 + shape page_screen(page: &Page<'_>) -> Screen;
264 +
265 + screen single "Feed - Makenotwork" {
266 + measured MEASURE;
266 267 // `padded-page feed-page`, which is what `pages/feed.html:4` rendered.
267 268 // Composed rather than written out: `Document::classed` replaces, so a
268 269 // screen naming only its own token would drop its measure (`2790e5c4`).
269 - .documented(Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"])))
270 - .summarised("Items from the users, projects and tags you follow.")
271 - .with(pane)
270 + documented Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"]));
271 + summarised "Items from the users, projects and tags you follow.";
272 +
273 + region PAGE_REGION as Pane {
274 + page "Your Feed";
275 + for node in body(page, Surface::Page) {
276 + include node;
277 + }
278 + }
279 + }
272 280 }
273 281
274 282 /// The document this screen is drawn in.
@@ -311,98 +319,111 @@
311 319 }
312 320 }
313 321
314 - /// The surface's contents, in order.
315 - fn body(page: &Page<'_>, surface: Surface) -> Vec<Node> {
316 - if page.items.is_empty() {
317 - return vec![empty()];
322 + declare! {
323 + /// The surface's contents, in order.
324 + shape body(page: &Page<'_>, surface: Surface) -> Vec<Node>;
325 +
326 + include empty() when page.items.is_empty();
327 +
328 + text "Showing {page.showing_start}-{page.showing_end} of {page.total_items} items"
329 + unless page.items.is_empty();
330 + include table(page, surface) unless page.items.is_empty();
331 + }
332 +
333 + declare! {
334 + /// Nothing followed yet.
335 + ///
336 + /// The two templates spelled this differently -- the panel wrote its own
337 + /// three paragraphs and a button, the page called
338 + /// `ui::empty_state_with_action` -- and said the same thing with the same
339 + /// way out. One spelling now, which is one of the things converting both
340 + /// surfaces together buys.
341 + shape empty() -> Node;
342 +
343 + empty "Nothing here yet. Follow users, projects, or tags to see their items here." {
344 + offering "Browse Discover" to get "/discover" navigating;
318 345 }
319 -
320 - vec![
321 - Node::text(format!(
322 - "Showing {}-{} of {} items",
323 - page.showing_start, page.showing_end, page.total_items
324 - )),
325 - table(page, surface),
326 - ]
327 346 }
328 347
329 - /// Nothing followed yet.
330 - ///
331 - /// The two templates spelled this differently -- the panel wrote its own three
332 - /// paragraphs and a button, the page called `ui::empty_state_with_action` -- and
333 - /// said the same thing with the same way out. One spelling now, which is one of
334 - /// the things converting both surfaces together buys.
335 - fn empty() -> Node {
336 - Node::empty("Nothing here yet. Follow users, projects, or tags to see their items here.")
337 - .offering(Act::new(
338 - "Browse Discover",
339 - Action::get("/discover").navigating(),
340 - ))
341 - }
348 + declare! {
349 + /// The five columns, and the rows under them.
350 + shape table(page: &Page<'_>, surface: Surface) -> Node;
342 351
343 - /// The five columns, and the rows under them.
344 - fn table(page: &Page<'_>, surface: Surface) -> Node {
345 - let mut table = Table::new([
346 - Column::new("Type").width(layout::Width::Content),
347 - Column::new("Name")
348 - .width(layout::Width::Fill)
349 - .priority(layout::Priority::Essential),
350 - Column::new("Tag").width(layout::Width::Content),
351 - Column::new("Price").width(layout::Width::Content),
352 - Column::new("Date").width(layout::Width::Content),
353 - ])
354 - .rows(page.items.iter().map(row));
352 + table {
353 + column "Type" {
354 + width Content;
355 + }
356 + column "Name" {
357 + width Fill;
358 + priority Essential;
359 + }
360 + column "Tag" {
361 + width Content;
362 + }
363 + column "Price" {
364 + width Content;
365 + }
366 + column "Date" {
367 + width Content;
368 + }
355 369
356 - if let Some(rest) = rest(page, surface) {
357 - table = table.more(rest);
370 + for item in page.items.iter() {
371 + include row(item);
372 + }
373 +
374 + more rest(page, surface) when page.total_pages over 1;
358 375 }
359 -
360 - table.into()
361 376 }
362 377
363 - /// One item.
378 + /// What the price cell reads, which is nothing when the item is free.
364 379 ///
365 - /// The cells name their columns rather than counting to them, because the
366 - /// headings are in [`table`] and this is a different function: position is only
367 - /// checkable when both halves are in front of you, and here they never are. The
368 - /// names must match [`table`]'s `Column::new` strings exactly, since a name no
369 - /// column carries is dropped rather than reported.
370 - fn row(item: &DiscoverItem) -> Cells {
371 - let price = if item.is_free {
372 - let mut free = Tag::badge("Free");
373 - free.tone = layout::Tone::Success;
374 - Cell::new(String::new()).token(free)
380 + /// A free item says so with a token instead, and the two are one cell rather
381 + /// than two guarded ones: a cell that is sometimes a word and sometimes a badge
382 + /// is still one cell.
383 + fn price(item: &DiscoverItem) -> String {
384 + if item.is_free {
385 + String::new()
375 386 } else {
376 - Cell::new(item.price.clone())
377 - };
387 + item.price.clone()
388 + }
389 + }
378 390
379 - Cells::default()
380 - .at(
381 - "Type",
382 - Cell::new(String::new()).token(Tag::badge(item.item_type.clone())),
383 - )
391 + declare! {
392 + /// One item.
393 + ///
394 + /// The cells name their columns rather than counting to them, because the
395 + /// headings are in [`table`] and this is a different shape: position is only
396 + /// checkable when both halves are in front of you, and here they never are.
397 + /// The names must match [`table`]'s `column` strings exactly, since a name
398 + /// no column carries is dropped rather than reported.
399 + shape row(item: &DiscoverItem) -> Cells;
400 +
401 + cells {
402 + cell at "Type" "" {
403 + token Tag::badge(item.item_type.clone());
404 + }
384 405 // The name is the link text and the creator rides under it in the same
385 406 // cell, which is what `Cell::activate` picks: the first text part.
386 - .at(
387 - "Name",
388 - Cell::new(item.name.clone()).part(Node::text(item.creator.clone())),
389 - )
390 - .at("Tag", Cell::new(item.primary_tag.clone()))
391 - .at("Price", price)
392 - .at("Date", Cell::new(item.date.clone()))
393 - .activate(Action::get(format!("/i/{}", item.id)).navigating())
407 + cell at "Name" item.name.clone() {
408 + text item.creator.clone();
409 + }
410 + cell at "Tag" item.primary_tag.clone();
411 + cell at "Price" price(item) {
412 + token Tag::badge("Free").tone(layout::Tone::Success) when item.is_free;
413 + }
414 + cell at "Date" item.date.clone();
415 +
416 + activate to get "/i/{item.id}" navigating;
417 + }
394 418 }
395 419
396 420 /// What the reader has not been shown, and every way to ask for it.
397 421 ///
398 - /// `None` on a single-page feed, which is what `{% if total_pages > 1 %}` said:
399 - /// a set that arrived whole has no rest, and a pager drawn over one would be
400 - /// two disabled buttons and the number 1.
401 - fn rest(page: &Page<'_>, surface: Surface) -> Option<Rest> {
402 - if page.total_pages <= 1 {
403 - return None;
404 - }
405 -
422 + /// The table asks for this only when there is more than one page, which is what
423 + /// `{% if total_pages > 1 %}` said: a set that arrived whole has no rest, and a
424 + /// pager drawn over one would be two disabled buttons and the number 1. R9 means
425 + /// this is still called on a single-page feed, and the answer is thrown away.
426 + fn rest(page: &Page<'_>, surface: Surface) -> Rest {
406 427 let per = constants::FEED_PAGE_SIZE as usize;
407 428 let from = (page.current_page as usize).saturating_sub(1) * per;
408 429 let mut rest = Rest::page(from, per).of(page.total_items as usize);
@@ -417,7 +438,7 @@
417 438 rest = rest.jumping(*jump as usize, surface.address(*jump));
418 439 }
419 440
420 - Some(rest)
441 + rest
421 442 }
422 443
423 444 #[cfg(test)]
@@ -31,8 +31,7 @@
31 31
32 32 use makeover_layout as layout;
33 33 use quasi_declare::declare;
34 - use quasi_router::screen::{Cell, Cells, Column, Lexeme, Table};
35 - use quasi_router::{Action, Document, Node, RegionKind, Screen, Slot};
34 + use quasi_router::Document;
36 35 use quasi_webview::Webview;
37 36
38 37 use crate::git::{BlameLine, Breadcrumb, RefInfo};
@@ -72,28 +71,28 @@
72 71 }
73 72 }
74 73
75 - /// The whole document: the title, the measure, the body.
76 - #[must_use]
77 - pub fn screen(view: &View<'_>) -> Screen {
78 - let page = Slot::new(PAGE_REGION, RegionKind::Pane)
79 - .with(super::widgets::git_nav::heading(view.owner, view.repo))
80 - .with(super::widgets::git_nav::region(&view.nav()))
81 - .with(super::widgets::git_nav::breadcrumb(
82 - view.owner,
83 - view.repo,
84 - view.current_ref,
85 - view.breadcrumbs,
86 - ))
87 - .with(header(view))
88 - .with(table(view));
74 + declare! {
75 + /// The whole document: the title, the measure, the body.
76 + #[must_use]
77 + pub shape screen(view: &View<'_>) -> Screen;
89 78
90 - Screen::single(format!(
91 - "Blame {} - {} - Git - Makenotwork",
92 - view.filename, view.repo
93 - ))
94 - .measured(MEASURE)
95 - .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
96 - .with(page)
79 + screen single "Blame {view.filename} - {view.repo} - Git - Makenotwork" {
80 + measured MEASURE;
81 + documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
82 +
83 + region PAGE_REGION as Pane {
84 + include super::widgets::git_nav::heading(view.owner, view.repo);
85 + include super::widgets::git_nav::region(&view.nav());
86 + include super::widgets::git_nav::breadcrumb(
87 + view.owner,
88 + view.repo,
89 + view.current_ref,
90 + view.breadcrumbs
91 + );
92 + include header(view);
93 + include table(view);
94 + }
95 + }
97 96 }
98 97
99 98 declare! {
@@ -119,73 +118,84 @@
119 118 }
120 119 }
121 120
122 - /// One row per line: who last touched it, when, and what it says.
123 - fn table(view: &View<'_>) -> Node {
124 - let base = format!("/git/{}/{}", view.owner, view.repo);
121 + declare! {
122 + /// One row per line: who last touched it, when, and what it says.
123 + shape table(view: &View<'_>) -> Node;
125 124
126 - Table::new(vec![
125 + let base = "/git/{view.owner}/{view.repo}";
126 +
127 + table {
127 128 // The commit is the identity of the row: everything else is a fact
128 - // about it, and a narrow viewport that dropped it would leave a
129 - // blame table that blames nobody.
130 - Column::new("Commit")
131 - .width(layout::Width::Content)
132 - .priority(layout::Priority::Essential),
133 - Column::new("Author")
134 - .width(layout::Width::Content)
135 - .priority(layout::Priority::Secondary),
136 - Column::new("Date")
137 - .width(layout::Width::Content)
138 - .priority(layout::Priority::Optional),
139 - Column::new("Line")
140 - .width(layout::Width::Content)
141 - .priority(layout::Priority::Secondary),
142 - Column::new("Code")
143 - .width(layout::Width::Fill)
144 - .priority(layout::Priority::Essential),
145 - ])
146 - .rows(view.lines.iter().map(|line| row(view, &base, line)))
147 - .into()
129 + // about it, and a narrow viewport that dropped it would leave a blame
130 + // table that blames nobody.
131 + column "Commit" {
132 + width Content;
133 + priority Essential;
134 + }
135 + column "Author" {
136 + width Content;
137 + priority Secondary;
138 + }
139 + column "Date" {
140 + width Content;
141 + priority Optional;
142 + }
143 + column "Line" {
144 + width Content;
145 + priority Secondary;
146 + }
147 + column "Code" {
148 + width Fill;
149 + priority Essential;
150 + }
151 +
152 + for line in view.lines.iter() {
153 + include row(view, &base, line);
154 + }
155 + }
148 156 }
149 157
150 - /// One blamed line.
158 + /// What the notes link on a blamed line reads.
151 159 ///
152 - /// The cells name their columns because the column list is a function away: a
153 - /// row written by position here would be lined up against headings nobody
154 - /// reading this function can see.
155 - fn row(view: &View<'_>, base: &str, line: &BlameLine) -> Cells {
156 - let commit = format!("{base}/commit/{}", line.commit_oid);
157 -
158 - // The short oid opens the commit; the badge beside it opens the same commit
159 - // at its notes. Two addresses on one cell, which is what a cell holding a
160 - // run of leaves is for.
161 - let mut identity = Cell::new(String::new()).part(Node::Link {
162 - text: line.commit_short_oid.clone(),
163 - action: Action::get(commit.clone()).navigating(),
164 - });
165 - if let Some(notes) = view.annotated.get(&line.commit_oid) {
166 - identity = identity.part(Node::Link {
167 - text: if *notes == 1 {
168 - "note".to_owned()
169 - } else {
170 - format!("{notes} notes")
171 - },
172 - action: Action::get(format!("{commit}#notes")).navigating(),
173 - });
160 + /// Empty when the commit carries no annotations, which is R9: the value is
161 + /// built whether or not the guard places it. A supplier because the count
162 + /// decides between a word and a number, inside an `Option` the form has no
163 + /// binding pattern to reach.
164 + fn notes_label(view: &View<'_>, line: &BlameLine) -> String {
165 + match view.annotated.get(&line.commit_oid) {
166 + Some(1) => "note".to_owned(),
167 + Some(notes) => format!("{notes} notes"),
168 + None => String::new(),
174 169 }
170 + }
175 171
176 - Cells::default()
177 - .at("Commit", identity)
178 - .at("Author", Cell::new(line.author_name.clone()))
179 - .at("Date", Cell::new(line.time_formatted.clone()))
180 - .at("Line", Cell::new(line.lineno.to_string()))
181 - .at(
182 - "Code",
183 - Cell::default().part(Node::Code {
184 - runs: vec![Lexeme::plain(&line.content)],
185 - language: None,
186 - inline: true,
187 - }),
188 - )
172 + declare! {
173 + /// One blamed line.
174 + ///
175 + /// The cells name their columns because the column list is a shape away: a
176 + /// row written by position here would be lined up against headings nobody
177 + /// reading this declaration can see.
178 + ///
179 + /// The short oid opens the commit; the link beside it opens the same commit
180 + /// at its notes. Two addresses on one cell, which is what a cell holding a
181 + /// run of leaves is for.
182 + shape row(view: &View<'_>, base: &str, line: &BlameLine) -> Cells;
183 +
184 + let commit = "{base}/commit/{line.commit_oid}";
185 +
186 + cells {
187 + cell at "Commit" "" {
188 + link line.commit_short_oid.clone() to get commit.clone() navigating;
189 + link notes_label(view, line) to get "{commit}#notes" navigating
190 + when view.annotated.contains_key(&line.commit_oid);
191 + }
192 + cell at "Author" line.author_name.clone();
193 + cell at "Date" line.time_formatted.clone();
194 + cell at "Line" line.lineno.to_string();
195 + cell at "Code" "" {
196 + literal &line.content;
197 + }
198 + }
189 199 }
190 200
191 201 /// The document this screen is drawn in.
@@ -42,7 +42,8 @@
42 42 //! for no reason a reader could otherwise guess.
43 43
44 44 use makeover_layout as layout;
45 - use quasi_router::screen::{Cell, Cells, Column, Figure, Table, Tag};
45 + use quasi_declare::declare;
46 + use quasi_router::screen::{Figure, Tag};
46 47 use quasi_router::{Action, Node, RegionKind, Slot};
47 48 use quasi_webview::Webview;
48 49
@@ -54,12 +55,33 @@
54 55 /// The bespoke region the chart is drawn into.
55 56 const CHART_SLOT: &str = "project-revenue-chart";
56 57
58 + /// One window the panel offers.
59 + ///
60 + /// Named members rather than a tuple, for `policy`'s reason: a description names
61 + /// what it draws, and `.1` is not a name.
62 + struct Range {
63 + value: &'static str,
64 + label: &'static str,
65 + }
66 +
57 67 /// The four windows the panel offers, and what each is called.
58 - const RANGES: &[(&str, &str)] = &[
59 - ("7d", "Last 7 days"),
60 - ("30d", "Last 30 days"),
61 - ("90d", "Last 90 days"),
62 - ("all", "All time"),
68 + const RANGES: &[Range] = &[
69 + Range {
70 + value: "7d",
71 + label: "Last 7 days",
72 + },
73 + Range {
74 + value: "30d",
75 + label: "Last 30 days",
76 + },
77 + Range {
78 + value: "90d",
79 + label: "Last 90 days",
80 + },
81 + Range {
82 + value: "all",
83 + label: "All time",
84 + },
63 85 ];
64 86
65 87 /// The panel as the route answers it: the region, carrying its own id.
@@ -92,129 +114,156 @@
92 114 webview
93 115 }
94 116
95 - /// The panel's contents, in order.
96 - fn body(
97 - slug: &str,
98 - range: &str,
99 - stats: &[StatCard],
100 - bars: &[ChartBar],
101 - items: &[ContentItem],
102 - ) -> Vec<Node> {
103 - let mut out = vec![
104 - Node::Link {
105 - text: "Docs: Analytics".into(),
106 - action: Action::get("/docs/analytics").navigating(),
107 - },
108 - Node::Link {
109 - text: "Export data".into(),
110 - action: Action::get("/dashboard/export").navigating(),
111 - },
112 - Node::section(heading(range)),
113 - ];
117 + declare! {
118 + /// The panel's contents, in order.
119 + ///
120 + /// The chart is a bespoke region: the description says only that there is
121 + /// one here and what it is called, and [`drawn`] puts the markup in it.
122 + shape body(
123 + slug: &str,
124 + range: &str,
125 + stats: &[StatCard],
126 + bars: &[ChartBar],
127 + items: &[ContentItem],
128 + ) -> Vec<Node>;
114 129
115 - out.extend(range_chips(slug, range));
116 - out.push(figures(stats));
117 - out.push(Node::section("Revenue Over Time"));
130 + link "Docs: Analytics" to get "/docs/analytics" navigating;
131 + link "Export data" to get "/dashboard/export" navigating;
132 + section heading(range);
118 133
119 - out.push(if bars.is_empty() {
120 - Node::empty(
121 - "No revenue data yet. Revenue will appear here after your first sale. \
122 - Publish an item and share it to get started.",
123 - )
124 - } else {
125 - // The description says only that there is a region here and what it is
126 - // called; `drawn` puts the markup in it.
127 - Node::Region(Slot::ceded(CHART_SLOT, "revenue-chart"))
128 - });
134 + for chip in range_chips(slug, range) {
135 + include chip;
136 + }
129 137
130 - out.push(Node::section("Top Performing Items"));
131 - out.push(if items.is_empty() {
132 - Node::empty("No sales data yet. Publish and promote your items to see analytics here.")
133 - } else {
134 - top_items(items)
135 - });
138 + include figures(stats);
136 139
137 - out
140 + section "Revenue Over Time";
141 + empty "No revenue data yet. Revenue will appear here after your first sale. \
142 + Publish an item and share it to get started."
143 + when bars.is_empty();
144 + region CHART_SLOT as RegionKind::ceded("revenue-chart") unless bars.is_empty() {}
145 +
146 + section "Top Performing Items";
147 + empty "No sales data yet. Publish and promote your items to see analytics here."
148 + when items.is_empty();
149 + include top_items(items) unless items.is_empty();
138 150 }
139 151
140 152 /// What the window is called, as the heading says it.
141 153 fn heading(range: &str) -> &'static str {
142 154 RANGES
143 155 .iter()
144 - .find(|(value, _)| *value == range)
145 - .map_or("All time", |(_, label)| *label)
156 + .find(|window| window.value == range)
157 + .map_or("All time", |window| window.label)
146 158 }
147 159
148 - /// The four range controls.
160 + /// Whether this window is the one being shown.
149 161 ///
150 - /// Chips rather than acts, and latched rather than carrying an `is-selected`
151 - /// class: which window is showing is a fact about the control, so the renderer
152 - /// draws the pressed state from the description instead of the template
153 - /// composing a class name.
154 - fn range_chips(slug: &str, range: &str) -> Vec<Node> {
155 - RANGES
156 - .iter()
157 - .map(|(value, _)| {
158 - Node::Token(
159 - Tag::chip(
160 - *value,
161 - Action::get(format!("/dashboard/project/{slug}/tabs/analytics"))
162 - .carrying("range", *value),
163 - )
164 - .latched(*value == range),
165 - )
166 - })
167 - .collect()
162 + /// A supplier because a comparison is an expression, and the form admits none
163 + /// in an argument. It hands back a `bool`, which is the smallest thing that
164 + /// works and keeps it out of the population.
165 + fn is_shown(window: &Range, range: &str) -> bool {
166 + window.value == range
168 167 }
169 168
170 - /// The figures across the top.
169 + /// One range control.
171 170 ///
172 - /// Toned the way `super::user_analytics::stats` is: the tone rides on the
173 - /// delta, so a card with nothing to report stays neutral rather than going
174 - /// green for having no news.
175 - fn figures(stats: &[StatCard]) -> Node {
176 - Node::Stats {
177 - figures: stats
178 - .iter()
179 - .map(|stat| {
180 - let mut figure = Figure::new(stat.value.clone(), stat.label.clone());
181 - if let Some(change) = &stat.change {
182 - figure = figure.change(change.clone()).tone(if stat.is_positive {
183 - layout::Tone::Success
184 - } else {
185 - layout::Tone::Danger
186 - });
187 - }
188 - (figure, None)
189 - })
190 - .collect(),
171 + /// A supplier and not a member: `token` is a **setting** on a row and on a cell,
172 + /// and a body's idents are told from settings by the member list alone, so the
173 + /// name cannot mean both. `quasi_declare`'s `NODE_MEMBERS` records the ruling.
174 + fn chip(window: &Range, slug: &str, range: &str) -> Node {
175 + Node::token(
176 + Tag::chip(
177 + window.value,
178 + Action::get(format!("/dashboard/project/{slug}/tabs/analytics"))
179 + .carrying("range", window.value),
180 + )
181 + .latched(is_shown(window, range)),
182 + )
183 + }
184 +
185 + declare! {
186 + /// The four range controls.
187 + ///
188 + /// Chips rather than acts, and latched rather than carrying an
189 + /// `is-selected` class: which window is showing is a fact about the control,
190 + /// so the renderer draws the pressed state from the description instead of
191 + /// the template composing a class name.
192 + shape range_chips(slug: &str, range: &str) -> Vec<Node>;
193 +
194 + for window in RANGES {
195 + include chip(window, slug, range);
191 196 }
192 197 }
193 198
194 - /// What sold.
195 - ///
196 - /// A table rather than the template's `<ul>` of two `<span>`s. The two columns
197 - /// were already a table pretending not to be, and a described one gets its
198 - /// header row back.
199 - ///
200 - /// The cells stay positional rather than naming their columns. Naming exists to
201 - /// stop a cell list drifting from a column list it cannot see, and here the two
202 - /// lists are the same expression: every row is the same two cells, nothing is
203 - /// conditional, and a reader checking the order reads eight lines to do it.
204 - fn top_items(items: &[ContentItem]) -> Node {
205 - Table::new([
206 - Column::new("Item")
207 - .width(layout::Width::Fill)
208 - .priority(layout::Priority::Essential),
209 - Column::new("Revenue").width(layout::Width::Content),
210 - ])
211 - .rows(items.iter().map(|item| {
212 - Cells::new([
213 - Cell::new(item.title.clone()),
214 - Cell::new(item.revenue.clone()),
215 - ])
216 - }))
217 - .into()
199 + /// What the delta reads, or nothing.
200 + fn change(stat: &StatCard) -> &str {
201 + stat.change.as_deref().unwrap_or_default()
202 + }
203 +
204 + /// The tone rides on the delta, so a card with nothing to report stays neutral
205 + /// rather than going green for having no news.
206 + fn delta_tone(stat: &StatCard) -> layout::Tone {
207 + if stat.is_positive {
208 + layout::Tone::Success
209 + } else {
210 + layout::Tone::Danger
211 + }
212 + }
213 +
214 + declare! {
215 + /// The figures across the top.
216 + ///
217 + /// Toned the way `super::user_analytics::stats` is: see [`delta_tone`].
218 + ///
219 + /// The empty list is what the figures accrete onto. `Node::stats` takes the
220 + /// whole list and this one is built a card at a time, so `Node::figure` is
221 + /// the accreting half, on the rule that closed `Table::column`,
222 + /// `Cells::cell` and `Field::options` before it.
223 + shape figures(stats: &[StatCard]) -> Node;
224 +
225 + stats [] {
226 + for stat in stats.iter() {
227 + figure Figure::new(stat.value.clone(), stat.label.clone())
228 + when stat.change.is_none();
229 + figure Figure::new(stat.value.clone(), stat.label.clone())
230 + .change(change(stat))
231 + .tone(delta_tone(stat))
232 + unless stat.change.is_none();
233 + }
234 + }
235 + }
236 +
237 + declare! {
238 + /// What sold.
239 + ///
240 + /// A table rather than the template's `<ul>` of two `<span>`s. The two
241 + /// columns were already a table pretending not to be, and a described one
242 + /// gets its header row back.
243 + ///
244 + /// The cells stay positional rather than naming their columns. Naming exists
245 + /// to stop a cell list drifting from a column list it cannot see, and here
246 + /// the two lists are one declaration: every row is the same two cells,
247 + /// nothing is conditional, and a reader checking the order reads eight lines
248 + /// to do it.
249 + shape top_items(items: &[ContentItem]) -> Node;
250 +
251 + table {
252 + column "Item" {
253 + width Fill;
254 + priority Essential;
255 + }
256 + column "Revenue" {
257 + width Content;
258 + }
259 +
260 + for item in items.iter() {
261 + cells {
262 + cell item.title.clone();
263 + cell item.revenue.clone();
264 + }
265 + }
266 + }
218 267 }
219 268
220 269 #[cfg(test)]
@@ -53,8 +53,9 @@
53 53 //! it sounds (see `super::mount`'s note on the two screens that did not).
54 54
55 55 use makeover_layout as layout;
56 - use quasi_router::screen::{Act, Row, Tag};
57 - use quasi_router::{Action, Node, RegionKind, Slot};
56 + use quasi_declare::declare;
57 + use quasi_router::screen::Tag;
58 + use quasi_router::{Node, RegionKind, Slot};
58 59 use quasi_webview::Webview;
59 60
60 61 use crate::types::ProjectCard;
@@ -101,44 +102,50 @@
101 102 out
102 103 }
103 104
104 - /// The panel's contents, in order.
105 - fn body(projects: &[ProjectCard], can_create_projects: bool) -> Vec<Node> {
106 - let mut out = vec![
107 - Node::Link {
108 - text: "Docs: Projects".into(),
109 - action: Action::get("/docs/projects").navigating(),
110 - },
111 - Node::section("Your Projects"),
112 - start_act(can_create_projects),
113 - ];
105 + declare! {
106 + /// The panel's contents, in order.
107 + ///
108 + /// A list of members and not a region: the strip draws the frame and its
109 + /// `id`, so [`fill`] must add no second one, and [`fragment`] wraps the same
110 + /// members itself.
111 + shape body(projects: &[ProjectCard], can_create_projects: bool) -> Vec<Node>;
114 112
115 - if projects.is_empty() {
116 - out.push(getting_started(can_create_projects));
117 - return out;
118 - }
113 + link "Docs: Projects" to get "/docs/projects" navigating;
114 + section "Your Projects";
115 + include start_act(can_create_projects);
119 116
120 - out.push(Node::list(projects.iter().map(card)));
121 - out
117 + include getting_started(can_create_projects) when projects.is_empty();
118 +
119 + list {
120 + for project in projects.iter() {
121 + include card(project);
122 + }
123 + } unless projects.is_empty();
122 124 }
123 125
124 - /// The one control at the top, which is a different offer per reader.
126 + declare! {
127 + /// The one control at the top, which is a different offer per reader.
128 + ///
129 + /// Two spellings in the template and one idea: a creator starts a project,
130 + /// and everyone else asks to become one.
131 + ///
132 + /// `external` because it is a whole page rather than a fragment, so it
133 + /// leaves. `super::project_content`'s New Item says it the same way and for
134 + /// the same reason: an internal get would fetch the wizard into this panel.
135 + shape start_act(can_create_projects: bool) -> Node;
136 +
137 + given can_create_projects {
138 + true -> act "New Project" to external NEW_PROJECT;
139 + false -> act "Apply for Creator Access" to external APPLY;
140 + }
141 + }
142 +
143 + /// The first meta line: what kind of project it is, when it was made, and when
144 + /// it was last touched.
125 145 ///
126 - /// Two spellings in the template and one idea: a creator starts a project, and
127 - /// everyone else asks to become one.
128 - fn start_act(can_create_projects: bool) -> Node {
129 - if can_create_projects {
130 - // `external` because it is a whole page rather than a fragment, so it
131 - // leaves. `super::project_content`'s New Item says it the same way and
132 - // for the same reason: an internal `Action::get` would fetch the wizard
133 - // into this panel.
134 - Node::act("New Project", Action::external(NEW_PROJECT))
135 - } else {
136 - Node::act("Apply for Creator Access", Action::external(APPLY))
137 - }
138 - }
139 -
140 - /// One project.
141 - fn card(project: &ProjectCard) -> Row {
146 + /// A supplier because the last clause is optional and the three read as one
147 + /// sentence, which is a string the form has no expression to build.
148 + fn made(project: &ProjectCard) -> String {
142 149 let mut meta = format!(
143 150 "{} · Created {}",
144 151 project.project_type, project.created_date
@@ -147,37 +154,30 @@
147 154 meta.push_str(" · Last updated ");
148 155 meta.push_str(updated);
149 156 }
157 + meta
158 + }
150 159
151 - let mut row = Row::new(project.title.clone()).meta(meta);
160 + declare! {
161 + /// One project.
162 + ///
163 + /// The template puts `stats` and the badge on one line separated by a
164 + /// middot, and hides the middot when stats is empty. Said as two parts, the
165 + /// renderer decides the separator and the empty case stops being a
166 + /// conditional in the markup.
167 + shape card(project: &ProjectCard) -> Row;
152 168
153 - // The template puts `stats` and the badge on one line separated by a
154 - // middot, and hides the middot when stats is empty. Said as two parts, the
155 - // renderer decides the separator and the empty case stops being a
156 - // conditional in the markup.
157 - if !project.stats.is_empty() {
158 - row = row.meta(project.stats.clone());
169 + row project.title.clone() {
170 + meta made(project);
171 + meta project.stats.clone() unless project.stats.is_empty();
172 + token Tag::badge(project.status.clone()).tone(tone(project.status_tone));
173 +
174 + act "View" to external "/p/{project.slug}";
175 + act "Edit" to external "/dashboard/project/{project.slug}";
176 + act "Delete" to delete "/api/projects/{project.id}" {
177 + tone Danger;
178 + confirm "Delete this project? This cannot be undone.";
179 + }
159 180 }
160 -
161 - let mut badge = Tag::badge(project.status.clone());
162 - badge.tone = tone(project.status_tone);
163 -
164 - row.token(badge)
165 - .act(Act::new(
166 - "View",
167 - Action::external(format!("/p/{}", project.slug)),
168 - ))
169 - .act(Act::new(
170 - "Edit",
171 - Action::external(format!("/dashboard/project/{}", project.slug)),
172 - ))
173 - .act(
174 - Act::new(
175 - "Delete",
176 - Action::delete(format!("/api/projects/{}", project.id)),
177 - )
178 - .tone(layout::Tone::Danger)
179 - .confirm("Delete this project? This cannot be undone."),
180 - )
181 181 }
182 182
183 183 /// The badge tone, from the string `ProjectCard::from_db` picked.
@@ -196,34 +196,32 @@
196 196 }
197 197 }
198 198
199 - /// What stands where the list would be, for an account with no projects.
200 - fn getting_started(can_create_projects: bool) -> Node {
201 - // The four steps are prose rather than a described list: they are one
202 - // explanation of what the product is, not a set of things with addresses.
203 - // `Node::Rich` carries the markdown source, which is what lets a terminal
204 - // render the same four steps without being handed markup.
205 - Node::Region(
206 - Slot::new("user-projects-getting-started", RegionKind::Pane)
207 - .with(Node::StandIn {
208 - state: layout::Readiness::Empty,
209 - message: "Welcome to Makenotwork. A project groups your work. Think of it as an \
210 - album, podcast feed, or product line. Each project contains items: \
211 - individual tracks, episodes, downloads, or posts."
212 - .into(),
213 - act: None,
214 - })
215 - .with(super::own_prose(
216 - "1. Create a project\n\
217 - 2. Add items: audio, video, text, or software\n\
218 - 3. Set prices (or keep them free) and publish\n\
219 - 4. Connect your payment account, 0% platform fee",
220 - ))
221 - .with(if can_create_projects {
222 - Node::act("Create Your First Project", Action::external(NEW_PROJECT))
223 - } else {
224 - Node::act("Apply for Creator Access", Action::external(APPLY))
225 - }),
226 - )
199 + declare! {
200 + /// What stands where the list would be, for an account with no projects.
201 + ///
202 + /// The four steps are prose rather than a described list: they are one
203 + /// explanation of what the product is, not a set of things with addresses.
204 + /// `own_prose` carries the markdown source, which is what lets a terminal
205 + /// render the same four steps without being handed markup.
206 + shape getting_started(can_create_projects: bool) -> Node;
207 +
208 + region "user-projects-getting-started" as Pane {
209 + empty "Welcome to Makenotwork. A project groups your work. Think of it as an \
210 + album, podcast feed, or product line. Each project contains items: \
211 + individual tracks, episodes, downloads, or posts.";
212 +
213 + include super::own_prose(
214 + "1. Create a project\n\
215 + 2. Add items: audio, video, text, or software\n\
216 + 3. Set prices (or keep them free) and publish\n\
217 + 4. Connect your payment account, 0% platform fee"
218 + );
219 +
220 + given can_create_projects {
221 + true -> act "Create Your First Project" to external NEW_PROJECT;
222 + false -> act "Apply for Creator Access" to external APPLY;
223 + }
224 + }
227 225 }
228 226
229 227 #[cfg(test)]