Skip to main content

max / makenotwork

Declare the forum memberships, the item sales, the media picker and own_prose Wave 3, twelve shapes across four files. `progress.py` moves from declared 21 to declared 33. 2687 lib tests pass, clippy clean. item_sales' `body` is gone. It existed to build the panel's contents as a `Vec<Node>` so the empty case could return early, and a guard says that inline. Nothing it drew has moved. media_picker gains two suppliers where the form's own deferred table names one: `folder_choices` for a list built from an iterator, and `badge` in item_sales for a tone that is a mapping from a string the row carries. own_prose was refused in wave 2 for want of a node member with a settings body. forum_memberships demanded that production, so the refusal is withdrawn and the shape converts with no new grammar. carousel.rs's `region` stays refused: a closure holding a `let mut` and an `if let` is the hard limit, not a gap. Five assertions added for loss-list items the files did not already carry: the column headings in both new tables, the sale status tone that moved into a supplier, the folder chooser's options now that they come from one, and the trust axis that is the whole reason own_prose exists. Needs quasi-declare from quasi 3fe4f1d and quasi-router 0.101.6.
Author: Max Johnson <me@maxj.phd> · 2026-09-03 19:15 UTC
Signed with PGP, not checked
Commit: 5920f4ce86d6c054c76d1cd1a046a75b460acaea
Parent: c04a4f0
4 files changed, +374 insertions, -243 deletions
@@ -31,9 +31,9 @@
31 31 //! what the Askama handlers do and is the right call for a tab: a non-success
32 32 //! status renders an empty list.
33 33
34 - use makeover_layout as layout;
35 - use quasi_router::screen::{Act, Cell, Cells, Column, Table, Tag};
36 - use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
34 + use quasi_declare::declare;
35 + use quasi_router::screen::Tag;
36 + use quasi_router::{Request, Response, RouteError};
37 37 use quasi_webview::Webview;
38 38
39 39 use super::Viewer;
@@ -166,83 +166,99 @@
166 166 .unwrap_or_default()
167 167 }
168 168
169 - /// The sentence naming where these memberships are, with the link on the name.
170 - ///
171 - /// `Node::Rich` rather than two nodes and a control: it is one sentence with one
172 - /// word in it that goes somewhere, and splitting it into text, an act and more
173 - /// text is how a sentence stops reading as a sentence in every host.
174 - fn upstream_line(base: &str) -> Node {
175 - super::own_prose(format!(
169 + declare! {
170 + /// The sentence naming where these memberships are, with the link on the name.
171 + ///
172 + /// `Node::Rich` rather than two nodes and a control: it is one sentence with
173 + /// one word in it that goes somewhere, and splitting it into text, an act
174 + /// and more text is how a sentence stops reading as a sentence in every
175 + /// host.
176 + shape upstream_line(base: &str) -> Node;
177 +
178 + include super::own_prose(
176 179 "Your memberships across [Multithreaded]({base}) forum communities."
177 - ))
180 + );
178 181 }
179 182
180 - /// Everything inside the library's tab pane.
181 - fn library_pane(memberships: &[MembershipView], base: &str) -> Node {
182 - let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane);
183 + declare! {
184 + /// Everything inside the library's tab pane.
185 + shape library_pane(memberships: &[MembershipView], base: &str) -> Node;
183 186
184 - if memberships.is_empty() {
185 - let mut nothing = Node::empty("You haven't joined any forum communities yet.");
187 + region LIBRARY_REGION as Pane {
186 188 // The way out is offered only when there is somewhere to send them,
187 189 // which is the `{% if !mt_base_url.is_empty() %}` the template wrapped
188 190 // its button in.
189 - if !base.is_empty() {
190 - nothing = nothing.offering(Act::new("Browse Communities", Action::external(base)));
191 + empty "You haven't joined any forum communities yet."
192 + when memberships.is_empty()
193 + {
194 + offering "Browse Communities" to external base unless base.is_empty();
191 195 }
192 - return Node::Region(slot.with(nothing));
196 +
197 + include upstream_line(base) unless memberships.is_empty();
198 + include table(memberships) unless memberships.is_empty();
193 199 }
194 -
195 - slot = slot.with(upstream_line(base)).with(table(memberships));
196 - Node::Region(slot)
197 200 }
198 201
199 - /// Everything inside the settings pane.
200 - fn settings_pane(memberships: &[MembershipView], base: &str) -> Node {
201 - let mut slot = Slot::new(SETTINGS_REGION, RegionKind::Pane)
202 - .with(Node::section("Forum Communities"))
203 - .with(upstream_line(base));
202 + declare! {
203 + /// Everything inside the settings pane.
204 + ///
205 + /// The heading and the line are drawn either way here, unlike the library's,
206 + /// which is the one real difference between the two screens.
207 + shape settings_pane(memberships: &[MembershipView], base: &str) -> Node;
204 208
205 - // The heading and the line are drawn either way here, unlike the library's,
206 - // which is the one real difference between the two screens.
207 - slot = if memberships.is_empty() {
208 - slot.with(Node::empty("You haven't joined any forum communities yet."))
209 - } else {
210 - slot.with(table(memberships))
211 - };
209 + region SETTINGS_REGION as Pane {
210 + section "Forum Communities";
211 + include upstream_line(base);
212 212
213 - Node::Region(slot)
213 + given memberships.is_empty() {
214 + true -> empty "You haven't joined any forum communities yet.";
215 + otherwise -> include table(memberships);
216 + }
217 + }
214 218 }
215 219
216 - /// The memberships, written once for both screens.
217 - fn table(memberships: &[MembershipView]) -> Node {
218 - // Positional cells. Both screens take this table whole rather than picking
219 - // columns out of it, so the headings and the row stay the one expression
220 - // below and every membership contributes the same four cells. Nothing is
221 - // paged, so no `more`: this is a whole set the handler already counted.
222 - Table::new(vec![
223 - Column::new("Community")
224 - .width(layout::Width::Fill)
225 - .priority(layout::Priority::Essential),
226 - Column::new("Role").width(layout::Width::Content),
227 - Column::new("Posts").width(layout::Width::Content),
228 - Column::new("Joined")
229 - .width(layout::Width::Content)
230 - .priority(layout::Priority::Optional),
231 - ])
232 - .rows(memberships.iter().map(|membership| {
233 - Cells::new([
234 - // The destination is Multithreaded, so it leaves. That is
235 - // the description saying it rather than the reader finding
236 - // out: a host with no browser can decide what to do with a
237 - // link off its own service.
238 - Cell::new(membership.community.clone())
239 - .activate(Action::external(membership.profile_url.clone())),
240 - Cell::tag(Tag::badge(membership.role.clone())),
241 - Cell::new(membership.posts.clone()),
242 - Cell::new(membership.joined.clone()),
243 - ])
244 - }))
245 - .into()
220 + declare! {
221 + /// The memberships, written once for both screens.
222 + ///
223 + /// Positional cells. Both screens take this table whole rather than picking
224 + /// columns out of it, so every membership contributes the same four cells.
225 + /// Nothing is paged, so no `more`: this is a whole set the handler already
226 + /// counted.
227 + shape table(memberships: &[MembershipView]) -> Node;
228 +
229 + table {
230 + column "Community" {
231 + width Fill;
232 + priority Essential;
233 + }
234 + column "Role" {
235 + width Content;
236 + }
237 + column "Posts" {
238 + width Content;
239 + }
240 + column "Joined" {
241 + width Content;
242 + priority Optional;
243 + }
244 +
245 + for membership in memberships.iter() {
246 + cells {
247 + // The destination is Multithreaded, so it leaves. That is the
248 + // description saying it rather than the reader finding out: a
249 + // host with no browser can decide what to do with a link off
250 + // its own service.
251 + cell membership.community.clone() {
252 + activate to external membership.profile_url.clone();
253 + }
254 + cell "" {
255 + token Tag::badge(membership.role.clone());
256 + }
257 + cell membership.posts.clone();
258 + cell membership.joined.clone();
259 + }
260 + }
261 + }
246 262 }
247 263
248 264 /// The renderer both screens are drawn with.
@@ -252,8 +268,10 @@
252 268
253 269 #[cfg(test)]
254 270 mod tests {
255 - use super::*;
256 271 use quasi_axum::Serves;
272 + use quasi_router::Node;
273 +
274 + use super::*;
257 275
258 276 fn membership(community: &str, role: &str) -> MembershipView {
259 277 MembershipView {
@@ -308,6 +326,18 @@
308 326 );
309 327 }
310 328
329 + /// The four headings, which moved from a hand-written `Table::new` list
330 + /// into the declaration. A column dropped on the way is silent: the cells
331 + /// still render and land under the wrong name.
332 + #[test]
333 + fn every_column_the_table_had_is_still_named() {
334 + let html = render(&table(&[membership("rust", "Moderator")]));
335 +
336 + for heading in ["Community", "Role", "Posts", "Joined"] {
337 + assert!(html.contains(heading), "{heading} is gone from {html}");
338 + }
339 + }
340 +
311 341 #[test]
312 342 fn one_table_serves_both_screens() {
313 343 // The point of the batch. Two screens rendering the same rows differ in
@@ -36,8 +36,9 @@
36 36 //! [`super::project_members`] left `/api/projects/{id}/members` alone.
37 37
38 38 use makeover_layout as layout;
39 - use quasi_router::screen::{Act, Cell, Cells, Column, Table, Tag};
40 - use quasi_router::{Method, Node, RegionKind, Request, Response, RouteError, Slot};
39 + use quasi_declare::declare;
40 + use quasi_router::screen::Tag;
41 + use quasi_router::{Method, Request, Response, RouteError};
41 42 use quasi_webview::Webview;
42 43
43 44 use super::Viewer;
@@ -65,52 +66,60 @@
65 66 Webview::new().fragment(&pane(item, sales, export_route))
66 67 }
67 68
68 - /// The panel in its region.
69 - fn pane(item: ItemId, sales: &[SaleRow], export_route: &str) -> Node {
70 - let mut slot = Slot::new(REGION, RegionKind::Pane);
71 - for node in body(item, sales, export_route) {
72 - slot = slot.with(node);
69 + declare! {
70 + /// The panel in its region.
71 + ///
72 + /// `body` is gone: it existed to build the contents as a `Vec<Node>` so the
73 + /// empty case could return early, and a guard says that inline. Nothing it
74 + /// drew has moved.
75 + shape pane(item: ItemId, sales: &[SaleRow], export_route: &str) -> Node;
76 +
77 + region REGION as Pane {
78 + section "Sales";
79 +
80 + empty "No sales yet. Sales will appear here once your first purchase is completed."
81 + when sales.is_empty();
82 +
83 + // The export is offered only when there is something to export, which
84 + // is what the template said with `{% if !sales.is_empty() %}`.
85 + include super::export_act::act(export_route, "item-sales.csv")
86 + unless sales.is_empty();
87 + include table(item, sales) unless sales.is_empty();
73 88 }
74 - Node::Region(slot)
75 89 }
76 90
77 - /// The panel's contents, in order.
78 - fn body(item: ItemId, sales: &[SaleRow], export_route: &str) -> Vec<Node> {
79 - let mut out = vec![Node::section("Sales")];
91 + declare! {
92 + /// The transaction history.
93 + ///
94 + /// The columns are declared here and the cells are built in [`row`], so the
95 + /// cells name their columns rather than counting to them. Position would ask
96 + /// a reader of either function to hold the other one in their head, and the
97 + /// refund column at the end is the one that would move if this list ever
98 + /// grew a heading in the middle.
99 + shape table(item: ItemId, sales: &[SaleRow]) -> Node;
80 100
81 - if sales.is_empty() {
82 - out.push(Node::empty(
83 - "No sales yet. Sales will appear here once your first purchase is completed.",
84 - ));
85 - return out;
101 + table {
102 + column COL_DATE {
103 + width Content;
104 + priority Essential;
105 + }
106 + column COL_BUYER {
107 + width Fill;
108 + }
109 + column COL_AMOUNT {
110 + width Content;
111 + }
112 + column COL_STATUS {
113 + width Content;
114 + }
115 + column COL_ACTS {
116 + width Content;
117 + }
118 +
119 + for sale in sales.iter() {
120 + include row(item, sale);
121 + }
86 122 }
87 -
88 - // The export is offered only when there is something to export, which is
89 - // what the template said with `{% if !sales.is_empty() %}`.
90 - out.push(super::export_act::act(export_route, "item-sales.csv"));
91 - out.push(table(item, sales));
92 - out
93 - }
94 -
95 - /// The transaction history.
96 - ///
97 - /// The columns are declared here and the cells are built in [`row`], so the
98 - /// cells name their columns rather than counting to them. Position would ask a
99 - /// reader of either function to hold the other one in their head, and the
100 - /// refund column at the end is the one that would move if this list ever grew a
101 - /// heading in the middle.
102 - fn table(item: ItemId, sales: &[SaleRow]) -> Node {
103 - Table::new([
104 - Column::new(COL_DATE)
105 - .width(layout::Width::Content)
106 - .priority(layout::Priority::Essential),
107 - Column::new(COL_BUYER).width(layout::Width::Fill),
108 - Column::new(COL_AMOUNT).width(layout::Width::Content),
109 - Column::new(COL_STATUS).width(layout::Width::Content),
110 - Column::new(COL_ACTS).width(layout::Width::Content),
111 - ])
112 - .rows(sales.iter().map(|sale| row(item, sale)))
113 - .into()
114 123 }
115 124
116 125 /// The headings, written once so the two halves cannot drift apart.
@@ -125,33 +134,35 @@
125 134 const COL_STATUS: &str = "Status";
126 135 const COL_ACTS: &str = "";
127 136
128 - /// One sale.
129 - fn row(item: ItemId, sale: &SaleRow) -> Cells {
130 - let mut status = Tag::badge(sale.status.clone());
131 - status.tone = tone(sale.status_tone);
137 + declare! {
138 + /// One sale.
139 + shape row(item: ItemId, sale: &SaleRow) -> Cells;
132 140
133 - let mut action = Cell::new(String::new());
134 - if sale.refundable {
135 - action = action.act(
136 - Act::new(
137 - "Refund",
138 - quasi_router::Action::post(format!("{NEST}/{item}/{}", sale.transaction_id))
139 - .awaiting(),
140 - )
141 - .tone(layout::Tone::Danger)
142 - .confirm(format!(
143 - "Issue a full refund for {}? This cannot be undone.",
144 - sale.amount_display
145 - )),
146 - );
141 + cells {
142 + cell at COL_DATE sale.date.clone();
143 + cell at COL_BUYER sale.buyer.clone();
144 + cell at COL_AMOUNT sale.amount_display.clone();
145 + cell at COL_STATUS "" {
146 + token badge(sale);
147 + }
148 + cell at COL_ACTS "" {
149 + act "Refund"
150 + to post "{NEST}/{item}/{sale.transaction_id}" awaiting
151 + when sale.refundable
152 + {
153 + tone Danger;
154 + confirm "Issue a full refund for {sale.amount_display}? This cannot be undone.";
155 + }
156 + }
147 157 }
158 + }
148 159
149 - Cells::default()
150 - .at(COL_DATE, Cell::new(sale.date.clone()))
151 - .at(COL_BUYER, Cell::new(sale.buyer.clone()))
152 - .at(COL_AMOUNT, Cell::new(sale.amount_display.clone()))
153 - .at(COL_STATUS, Cell::new(String::new()).token(status))
154 - .at(COL_ACTS, action)
160 + /// The status badge, toned.
161 + ///
162 + /// A supplier because the tone is a mapping from a string the row carries, and
163 + /// a mapping is what [`tone`] is.
164 + fn badge(sale: &SaleRow) -> Tag {
165 + Tag::badge(sale.status.clone()).tone(tone(sale.status_tone))
155 166 }
156 167
157 168 /// The badge tone the template set with `data-tone`.
@@ -369,6 +380,32 @@
369 380 );
370 381 }
371 382
383 + /// The five headings. `COL_ACTS` is deliberately empty, so the four that
384 + /// read are what a dropped column would take with it.
385 + #[test]
386 + fn every_column_the_table_had_is_still_named() {
387 + let html = render(&[sale(true)]);
388 +
389 + for heading in [COL_DATE, COL_BUYER, COL_AMOUNT, COL_STATUS] {
390 + assert!(html.contains(heading), "{heading} is gone from {html}");
391 + }
392 + }
393 +
394 + /// The status badge's tone, which the template set with `data-tone` and a
395 + /// supplier now maps. A badge that lost its tone reads as neutral and says
396 + /// nothing about whether the sale went through.
397 + #[test]
398 + fn a_sale_status_keeps_the_tone_the_template_set() {
399 + let mut refunded = sale(false);
400 + refunded.status = "refunded".to_owned();
401 + refunded.status_tone = "warning";
402 +
403 + let html = render(&[refunded]);
404 +
405 + assert!(html.contains("refunded"), "{html}");
406 + assert!(html.contains(r#"data-tone="warning""#), "{html}");
407 + }
408 +
372 409 #[test]
373 410 fn a_buyer_cannot_smuggle_markup() {
374 411 let mut hostile = sale(true);
@@ -65,9 +65,10 @@
65 65 //! name at all, which no description can address, so they are described here
66 66 //! too and carry names of their own. See `rich_field::named`.
67 67
68 - use makeover_layout::{FieldKind, Fit};
68 + use makeover_layout::Fit;
69 + use quasi_declare::declare;
69 70 use quasi_router::screen::Act;
70 - use quasi_router::{Action, Choice, Consult, Field, Image, Node, RegionKind, Slot};
71 + use quasi_router::{Action, Choice, Consult, Image, Node, RegionKind, Slot};
71 72
72 73 /// The stem both region ids are built from.
73 74 const REGION: &str = "media-picker";
@@ -295,116 +296,130 @@
295 296 .fragment(&Node::Region(Slot::new(region_id(into), RegionKind::Group)))
296 297 }
297 298
298 - /// The tiles, or the sentence that says why there are none.
299 - fn grid_slot(entries: &[Entry], into: &str) -> Slot {
300 - let mut slot = Slot::new(grid_id(into), widget(GRID_WIDGET));
301 - if entries.is_empty() {
302 - // Two different emptinesses read the same here, and deliberately: a
303 - // library with nothing in it and a filter that matched nothing both
304 - // leave the reader with the filters they can see and change.
305 - return slot.with(Node::text(
306 - "No media files match. Upload images in your Media Library tab first.",
307 - ));
299 + declare! {
300 + /// The tiles, or the sentence that says why there are none.
301 + ///
302 + /// Two different emptinesses read the same here, and deliberately: a library
303 + /// with nothing in it and a filter that matched nothing both leave the
304 + /// reader with the filters they can see and change.
305 + shape grid_slot(entries: &[Entry], into: &str) -> Slot;
306 +
307 + region grid_id(into) as widget(GRID_WIDGET) {
308 + text "No media files match. Upload images in your Media Library tab first."
309 + when entries.is_empty();
310 +
311 + for entry in entries.iter() {
312 + include card(entry, into);
313 + }
308 314 }
309 - for entry in entries {
310 - slot = slot.with(card(entry, into));
311 - }
312 - slot
313 315 }
314 316
315 - /// One tile: one control, showing the thumbnail and saying the file name.
316 - ///
317 - /// The label is the file name, which is the fact the shipped script lost to a
318 - /// misspelled JSON key. The deposit is `markdown_ref` verbatim, which is the
319 - /// fact that script wrapped twice.
320 - ///
321 - /// `Action::local()`, so the press makes no request. The old picker made none
322 - /// either; what it did instead was 155 lines of DOM building.
323 - ///
324 - /// # This was two members and half a tile until quasi 0.69.0
325 - ///
326 - /// The conversion found that an [`Act`] was a label and nothing said a control
327 - /// reads as a picture, so the tile was a region holding the picture and the
328 - /// control as siblings and only the file name answered a press -- a reader
329 - /// aiming at the thumbnail, which is the whole affordance of a picture picker,
330 - /// hit nothing. `Act::shows` (quasicoherent `db998898`) is that member, and the
331 - /// tile is one control again.
332 - ///
333 - /// The region went with it, and so did the id it needed. [`Entry::id`] is kept
334 - /// for the caller's convenience and no longer addresses anything.
335 - fn card(entry: &Entry, into: &str) -> Node {
336 - let mut act = Act::new(&entry.filename, Action::local()).filling(into, &entry.reference);
337 - if entry.image {
317 + declare! {
318 + /// One tile: one control, showing the thumbnail and saying the file name.
319 + ///
320 + /// The label is the file name, which is the fact the shipped script lost to
321 + /// a misspelled JSON key. The deposit is `markdown_ref` verbatim, which is
322 + /// the fact that script wrapped twice.
323 + ///
324 + /// `local`, so the press makes no request. The old picker made none either;
325 + /// what it did instead was 155 lines of DOM building.
326 + ///
327 + /// # This was two members and half a tile until quasi 0.69.0
328 + ///
329 + /// The conversion found that an [`Act`] was a label and nothing said a
330 + /// control reads as a picture, so the tile was a region holding the picture
331 + /// and the control as siblings and only the file name answered a press -- a
332 + /// reader aiming at the thumbnail, which is the whole affordance of a
333 + /// picture picker, hit nothing. `Act::shows` (quasicoherent `db998898`) is
334 + /// that member, and the tile is one control again.
335 + ///
336 + /// The region went with it, and so did the id it needed. [`Entry::id`] is
337 + /// kept for the caller's convenience and no longer addresses anything.
338 + shape card(entry: &Entry, into: &str) -> Node;
339 +
340 + act &entry.filename to local {
341 + filling into &entry.reference;
338 342 // The alt is the file name, because that is what the tile is for: a
339 343 // reader who cannot see the thumbnail is choosing between file names,
340 - // which is exactly what the control says.
341 - let mut picture = Image::new(&entry.url, &entry.filename).lazy();
342 - // The letterbox, which is what the shipped tile did with
343 - // `object-fit: contain`: a thumbnail grid whose files are not all one
344 - // shape, and the whole picture matters more than filling the square.
345 - picture.fit = Fit::Contain;
346 - act = act.showing(picture);
344 + // which is exactly what the control says. The fit is the letterbox the
345 + // shipped tile did with `object-fit: contain`: a thumbnail grid whose
346 + // files are not all one shape, and the whole picture matters more than
347 + // filling the square.
348 + showing Image::new(&entry.url, &entry.filename).lazy().fit(Fit::Contain)
349 + when entry.image;
347 350 }
348 - Node::Act(act)
349 351 }
350 352
351 - /// The typed filter.
352 - ///
353 - /// It asks rather than writes: see this module's header on why a consult and
354 - /// not `changes`. The folder rides along under [`Consult::sending`], so
355 - /// narrowing by name inside a folder stays inside it.
356 - ///
357 - /// The folder is also carried on the action, which looks redundant and is the
358 - /// fallback: htmx drops an address parameter whose name the payload repeats, so
359 - /// the live select wins whenever it is found, and the carried value is what a
360 - /// document with no select in reach sends instead.
361 - ///
362 - /// `sending` names a field document-wide, which is the vocabulary's own
363 - /// reading. A document holding two open pickers would therefore match two
364 - /// selects and send both values. Not guarded, because a picker is a modal
365 - /// covering the viewport and a reader cannot reach the second trigger while the
366 - /// first is open; recorded so the next reader knows it was seen rather than
367 - /// missed.
368 - fn name_filter(into: &str, name: &str, folder: &str) -> Field {
369 - let mut field = Field::new(FieldKind::Text, NAME, "Filter by name").consulting(
370 - Consult::new(
353 + declare! {
354 + /// The typed filter.
355 + ///
356 + /// It asks rather than writes: see this module's header on why a consult and
357 + /// not `changes`. The folder rides along under [`Consult::sending`], so
358 + /// narrowing by name inside a folder stays inside it.
359 + ///
360 + /// The folder is also carried on the action, which looks redundant and is
361 + /// the fallback: htmx drops an address parameter whose name the payload
362 + /// repeats, so the live select wins whenever it is found, and the carried
363 + /// value is what a document with no select in reach sends instead.
364 + ///
365 + /// `sending` names a field document-wide, which is the vocabulary's own
366 + /// reading. A document holding two open pickers would therefore match two
367 + /// selects and send both values. Not guarded, because a picker is a modal
368 + /// covering the viewport and a reader cannot reach the second trigger while
369 + /// the first is open; recorded so the next reader knows it was seen rather
370 + /// than missed.
371 + shape name_filter(into: &str, name: &str, folder: &str) -> Field;
372 +
373 + field Text NAME "Filter by name" {
374 + consulting Consult::new(
371 375 Action::get(GRID_PATH)
372 376 .carrying(INTO, into)
373 377 .carrying(FOLDER, folder)
374 378 .replacing(grid_id(into)),
375 379 )
376 380 .after(WAIT)
377 - .sending([FOLDER]),
378 - );
379 - field.placeholder = Some("Filter by name...".to_owned());
380 - field.value = Some(name.to_owned());
381 - field
381 + .sending([FOLDER]);
382 + placeholder "Filter by name...";
383 + value name;
384 + }
382 385 }
383 386
384 - /// The folder chooser.
387 + declare! {
388 + /// The folder chooser.
389 + ///
390 + /// A consult and not a write: it narrows what the grid shows and puts
391 + /// nothing anywhere. `Consult::at_once` because a select is at its next
392 + /// value or its last one and there is nothing to wait out -- which is the
393 + /// half `aeb44860` left with the description after moving the event to the
394 + /// renderer. It carries the typed filter forward on the action, because a
395 + /// field's question sends its own value and nothing else.
396 + shape folder_filter(folders: &[String], into: &str, name: &str, folder: &str) -> Field;
397 +
398 + field Select FOLDER "Folder" {
399 + options folder_choices(folders);
400 + consulting Consult::at_once(
401 + Action::get(GRID_PATH)
402 + .carrying(INTO, into)
403 + .carrying(NAME, name)
404 + .replacing(grid_id(into)),
405 + );
406 + value folder;
407 + }
408 + }
409 +
410 + /// Every folder the reader has, under an "All folders" that clears the filter.
385 411 ///
386 - /// A consult and not a write: it narrows what the grid shows and puts nothing
387 - /// anywhere. `Consult::at_once` because a select is at its next value or its
388 - /// last one and there is nothing to wait out -- which is the half `aeb44860`
389 - /// left with the description after moving the event to the renderer. It
390 - /// carries the typed filter forward on the action, because a field's question
391 - /// sends its own value and nothing else.
392 - fn folder_filter(folders: &[String], into: &str, name: &str, folder: &str) -> Field {
412 + /// A supplier because a list built from an iterator is what the deferred table
413 + /// names one for. The root folder is the empty string and reads as `(root)`,
414 + /// which is the one place a folder's value and its label differ.
415 + fn folder_choices(folders: &[String]) -> Vec<Choice> {
393 416 let mut options = vec![Choice::new("", "All folders")];
394 417 options.extend(
395 418 folders
396 419 .iter()
397 420 .map(|folder| Choice::new(folder, if folder.is_empty() { "(root)" } else { folder })),
398 421 );
399 -
400 - let mut field = Field::select(FOLDER, "Folder", options).consulting(Consult::at_once(
401 - Action::get(GRID_PATH)
402 - .carrying(INTO, into)
403 - .carrying(NAME, name)
404 - .replacing(grid_id(into)),
405 - ));
406 - field.value = Some(folder.to_owned());
407 - field
422 + options
408 423 }
409 424
410 425 #[cfg(test)]
@@ -543,6 +558,24 @@
543 558 assert!(html.contains("delay:150ms"), "{html}");
544 559 }
545 560
561 + /// The folder chooser's options, which are a supplier's list now rather
562 + /// than an argument to `Field::select`. The root folder reads as `(root)`
563 + /// and is the one place a folder's value and its label differ.
564 + #[test]
565 + fn the_folder_chooser_offers_every_folder_and_a_way_to_clear_it() {
566 + let html = picker(
567 + &[],
568 + &["drums".to_owned(), String::new()],
569 + "body",
570 + "",
571 + "drums",
572 + );
573 +
574 + assert!(html.contains("All folders"), "{html}");
575 + assert!(html.contains("drums"), "{html}");
576 + assert!(html.contains("(root)"), "{html}");
577 + }
578 +
546 579 /// The picker is opened for a box, so every action it emits carries which
547 580 /// one. The shipped script held this in a module-level variable.
548 581 #[test]
@@ -31,6 +31,7 @@
31 31 //! in the tree with many concurrent readers. It is not arguable, only
32 32 //! measurable: see `tests/load` and the S3 numbers in the wiki note.
33 33
34 + use quasi_declare::declare;
34 35 use tokio::runtime::Handle;
35 36
36 37 use axum::extract::FromRef;
@@ -764,27 +765,32 @@
764 765 quasi_axum::Adapter::new(router, state, std::sync::Arc::new(pricing::renderer())).into_router()
765 766 }
766 767
767 - /// This server's own copy, as markdown.
768 - ///
769 - /// `Node::rich` means "markdown somebody else wrote": quasi hardens it, so its
770 - /// links carry `nofollow`, its raw markup is dropped and fetchable schemes are
771 - /// filtered. That is right for a forum post and wrong for a page's own
772 - /// sentence, and until quasi grew the trust axis every described page here was
773 - /// telling crawlers not to follow its own links (quasicoherent `24a3b1df`).
774 - ///
775 - /// The two axes stay separate in quasi -- `Richness` is what the format may
776 - /// express, `Trust` is who wrote it -- and this is the one combination this
777 - /// server reaches for often enough to name: a sentence, ours. A screen wanting
778 - /// tables says so with `Node::richness`, and a screen carrying a reader's
779 - /// markdown keeps `Node::rich`.
780 - ///
781 - /// **The test for using it is authorship, not tidiness.** The string has to be
782 - /// a literal in this repository, or interpolated from a value that cannot carry
783 - /// markup. A creator's description reaching a screen through the database is
784 - /// `Node::rich` however well-behaved it has been.
785 - #[must_use]
786 - pub fn own_prose(source: impl Into<String>) -> quasi_router::Node {
787 - quasi_router::Node::rich(source).trust(quasi_router::Trust::Trusted)
768 + declare! {
769 + /// This server's own copy, as markdown.
770 + ///
771 + /// `Node::rich` means "markdown somebody else wrote": quasi hardens it, so
772 + /// its links carry `nofollow`, its raw markup is dropped and fetchable
773 + /// schemes are filtered. That is right for a forum post and wrong for a
774 + /// page's own sentence, and until quasi grew the trust axis every described
775 + /// page here was telling crawlers not to follow its own links
776 + /// (quasicoherent `24a3b1df`).
777 + ///
778 + /// The two axes stay separate in quasi -- `Richness` is what the format may
779 + /// express, `Trust` is who wrote it -- and this is the one combination this
780 + /// server reaches for often enough to name: a sentence, ours. A screen
781 + /// wanting tables says so with `Node::richness`, and a screen carrying a
782 + /// reader's markdown keeps `Node::rich`.
783 + ///
784 + /// **The test for using it is authorship, not tidiness.** The string has to
785 + /// be a literal in this repository, or interpolated from a value that
786 + /// cannot carry markup. A creator's description reaching a screen through
787 + /// the database is `Node::rich` however well-behaved it has been.
788 + #[must_use]
789 + pub shape own_prose(source: impl Into<String>) -> Node;
790 +
791 + rich source {
792 + trust quasi_router::Trust::Trusted;
793 + }
788 794 }
789 795
790 796 /// A handler, spelled once so the screens and the mount agree about it.
@@ -896,6 +902,31 @@
896 902 mod tests {
897 903 use super::*;
898 904
905 + /// The whole of what `own_prose` is for: a sentence this server wrote is
906 + /// trusted, so quasi stops telling crawlers not to follow its own links.
907 + /// `Node::rich` alone is untrusted and is the right default for a forum
908 + /// post; the two are one call apart and read the same at a glance.
909 + #[test]
910 + fn our_own_sentence_is_trusted_and_a_readers_is_not() {
911 + let ours = own_prose("Read the [docs](/docs).");
912 + let theirs = quasi_router::Node::rich("Read the [docs](/docs).");
913 +
914 + assert!(matches!(
915 + ours,
916 + quasi_router::Node::Rich {
917 + trust: quasi_router::Trust::Trusted,
918 + ..
919 + }
920 + ));
921 + assert!(matches!(
922 + theirs,
923 + quasi_router::Node::Rich {
924 + trust: quasi_router::Trust::Untrusted,
925 + ..
926 + }
927 + ));
928 + }
929 +
899 930 #[test]
900 931 fn every_screen_is_listed_in_paths() {
901 932 // The two lists are written by hand and read by two different things,