Skip to main content

max / makenotwork

Put /feed on the residual seam Sixteen of MNW's seventeen described screens serve from a template generated at build time. Only /dashboard/tabs/analytics is left, and it waits on a described chart rather than on anything staging can do. Two things had to change here beyond describing the pager. The two surfaces split. `/feed` and the library's Feed panel were one set of shapes with a `Surface` argument, and a staged `include` drops its arguments, so there is no `surface` inside a twin. The panel keeps its supplier-built `Rest`, which costs that surface nothing because it is not on the seam; the page describes its own. The column list is written twice as a result, and `the_two_surfaces_draw_the_same_columns` renders both and compares the heading rows so the pair is held together by a test rather than by hope. The empty state, every row and every cell stay shared. The price cell became a dispatch. Free is a badge and priced is text, and the cell carries `cell-value` in one case and not the other, so the two are different markup at one position. Said as two guarded cells it would be one question written twice, and a derivation varies guards one at a time, so it would render a Price column holding two cells. /feed was the last document mounted through the adapter, so `document_mount` and the handler it called are gone. Every described document answers no `Screen` now. Filled at every page of a four-page set, crossed against free and priced items and the single page that draws no pager. mnw-server 14d42beb.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-08 16:40 UTC
Signed with PGP, not checked
Commit: b1e1eef8fdbdc1c9a7c6b726390591bbec8f25f4
Parent: e6e734d
5 files changed, +463 insertions, -125 deletions
@@ -163,7 +163,7 @@
163 163 # through quasi beside its Askama equivalent, so the cost of describing a screen
164 164 # here is measured rather than argued. Not load-bearing for any shipped route.
165 165 # See wiki look-wave-2, tier G.
166 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.107" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
167 167 # The declared form. A screen written as `declare!` is compiled to the same
168 168 # `quasi_router` constructors the hand-written shape called, at build time, so
169 169 # nothing new reaches the binary and the macro is not in the request path.
@@ -183,13 +183,13 @@
183 183 # For the request head the per-viewer state factory reads. axum re-exports it,
184 184 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
185 185 http = "1.3.1"
186 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.107" }
187 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.107" }
186 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
187 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
188 188 # The first-party widget set. The carousel was proved here against three pages
189 189 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
190 190 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
191 191 # sits above quasi-router rather than inside the description suite.
192 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.107" }
192 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
193 193
194 194 # Git source browser
195 195 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -288,7 +288,7 @@
288 288 # Written from the crate rather than copied into the repo for the reason the
289 289 # constants exist: a stale copy in a static directory is a silently dead bulk
290 290 # bar, and the version has to be the one the emitter agrees with.
291 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.107" }
291 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
292 292
293 293 [dev-dependencies]
294 294 tower = { version = "0.5.3", features = ["util"] }
@@ -83,8 +83,8 @@
83 83
84 84 use makeover_layout as layout;
85 85 use quasi_declare::declare;
86 - use quasi_router::screen::{Rest, Tag};
87 - use quasi_router::{Action, Document, Node, RegionKind, Request, Response, RouteError, Slot};
86 + use quasi_router::screen::{Jump, Rest, Tag};
87 + use quasi_router::{Action, Document, Node, RegionKind, RouteError, Slot};
88 88 use quasi_webview::Webview;
89 89
90 90 use crate::constants;
@@ -139,13 +139,73 @@
139 139 pub showing_end: u32,
140 140 }
141 141
142 + /// One page the strip offers, and whether it is the one being read.
143 + ///
144 + /// `here` is carried per row rather than worked out by comparing each page
145 + /// against the current one. `quasi_router::Jump::here` has the reason: a strip
146 + /// is a loop, and a residual holds one compiled body per loop, so "exactly one
147 + /// row differs" is not a property of the body when the difference is a
148 + /// comparison the body does not make.
149 + pub struct Offered {
150 + /// Which page, counting from one.
151 + pub page: usize,
152 + /// Whether it is the one being read.
153 + pub here: bool,
154 + }
155 +
156 + impl Page<'_> {
157 + /// Where this page starts, which is what a `Rest` counts from.
158 + ///
159 + /// Suppliers rather than expressions because the declared form has no
160 + /// arithmetic, and these are the same sums [`load`] makes.
161 + fn offset(&self) -> usize {
162 + (self.current_page as usize - 1) * constants::FEED_PAGE_SIZE as usize
163 + }
164 +
165 + fn per(&self) -> usize {
166 + constants::FEED_PAGE_SIZE as usize
167 + }
168 +
169 + fn total(&self) -> usize {
170 + self.total_items as usize
171 + }
172 +
173 + fn previous(&self) -> u32 {
174 + self.current_page.saturating_sub(1)
175 + }
176 +
177 + fn next(&self) -> u32 {
178 + self.current_page + 1
179 + }
180 +
181 + fn has_previous(&self) -> bool {
182 + self.current_page > 1
183 + }
184 +
185 + fn has_next(&self) -> bool {
186 + self.current_page < self.total_pages
187 + }
188 +
189 + /// The pages the strip offers, marked. The window is the handler's, which
190 + /// is `build_pagination_range`; nothing here windows anything.
191 + fn offered(&self) -> Vec<Offered> {
192 + self.pagination_range
193 + .iter()
194 + .map(|at| Offered {
195 + page: *at as usize,
196 + here: *at == self.current_page,
197 + })
198 + .collect()
199 + }
200 + }
201 +
142 202 /// The library's Feed panel, in its region, for the tab route to answer with.
143 203 #[must_use]
144 204 pub fn library_fragment(page: &Page<'_>) -> String {
145 205 use quasi_axum::Serves as _;
146 206
147 207 let mut slot = Slot::new(LIBRARY_REGION, RegionKind::Pane);
148 - for node in body(page, Surface::Panel) {
208 + for node in panel_body(page) {
149 209 slot = slot.with(node);
150 210 }
151 211 Webview::new().fragment(&Node::Region(slot))
@@ -240,18 +300,21 @@
240 300 })
241 301 }
242 302
243 - /// The public feed page, described.
244 - pub fn screen(viewer: &super::Viewer, request: Request) -> Result<Response, RouteError> {
245 - // Moved out of the request rather than borrowed: the signature is quasi's,
246 - // so the request arrives owned and nothing else here reads it.
247 - let carried = request.carried;
303 + /// The one read this page makes, for the mount that serves it from a residual.
304 + ///
305 + /// Answers `Loaded` rather than `Page`, because a `Page` borrows it: the mount
306 + /// holds this and takes the borrow twice, once to state the document and once
307 + /// to fill the markup, from the same read.
308 + pub(crate) fn reading(
309 + viewer: &super::Viewer,
310 + carried: &super::Carried,
311 + ) -> Result<Loaded, RouteError> {
248 312 let asked = carried
249 - .get("page")
313 + .asked("page")
250 314 .and_then(|value| value.trim().parse::<u32>().ok());
251 - let loaded = viewer
315 + viewer
252 316 .block_on(load(&viewer.app.db, viewer.reader()?.id, asked))
253 - .map_err(|_| RouteError::internal("your feed could not be read"))?;
254 - Ok(page_screen(&loaded.page()).into())
317 + .map_err(|_| RouteError::internal("your feed could not be read"))
255 318 }
256 319
257 320 declare! {
@@ -260,7 +323,7 @@
260 323 /// The body arrives as a list of members and is spread into the pane one at
261 324 /// a time, which is the same loop both callers wrote by hand: a fill has no
262 325 /// region of its own, so nothing can place it whole.
263 - shape page_screen(page: &Page<'_>) -> Screen;
326 + pub(crate) shape page_screen(page: &Page<'_>) -> Screen;
264 327
265 328 screen single "Feed - Makenotwork" {
266 329 measured MEASURE;
@@ -270,10 +333,18 @@
270 333 documented Document::default().classed(crate::shell::body_class(MEASURE, &["feed-page"]));
271 334 summarised "Items from the users, projects and tags you follow.";
272 335
273 - region PAGE_REGION as Pane {
274 - page "Your Feed";
275 - include each body(page, Surface::Page);
276 - }
336 + include page_region(page);
337 + }
338 + }
339 +
340 + declare! {
341 + /// The page's one region, split out so it can be staged.
342 + #[staged]
343 + pub(crate) shape page_region(page: &Page<'_>) -> Slot;
344 +
345 + region PAGE_REGION as Pane {
346 + page "Your Feed";
347 + include each page_body(page);
277 348 }
278 349 }
279 350
@@ -297,35 +368,48 @@
297 368 ///
298 369 /// The only thing that differs between them, so it is one value rather than two
299 370 /// copies of the body. See the module header.
300 - #[derive(Clone, Copy)]
301 - enum Surface {
302 - /// The library tab panel: a page control swaps the panel.
303 - Panel,
304 - /// The public page: a page control replaces the document.
305 - Page,
306 - }
307 -
308 - impl Surface {
309 - /// What going to `page` calls, on this surface.
310 - fn address(self, page: u32) -> Action {
311 - match self {
312 - Self::Panel => Action::get(format!("{LIBRARY_ROUTE}?page={page}"))
313 - .awaiting()
314 - .replacing(LIBRARY_REGION),
315 - Self::Page => Action::get(format!("{PATH}?page={page}")).navigating(),
316 - }
317 - }
371 + /// What going to `page` calls on the library panel: a swap of the panel, in
372 + /// place, leaving the rest of the library where it is.
373 + ///
374 + /// The page surface's answer is not here. It is written in `page_table`'s own
375 + /// `more` body, because that surface is served from a residual and a supplier
376 + /// handing over a whole `Action` is markup the derivation cannot see into. See
377 + /// the note on `panel_table`.
378 + fn panel_address(page: u32) -> Action {
379 + Action::get(format!("{LIBRARY_ROUTE}?page={page}"))
380 + .awaiting()
381 + .replacing(LIBRARY_REGION)
318 382 }
319 383
320 384 declare! {
321 - /// The surface's contents, in order.
322 - shape body(page: &Page<'_>, surface: Surface) -> Vec<Node>;
385 + /// The library panel's contents, in order.
386 + ///
387 + /// One of a pair with [`page_body`], and the pair is what the surface split
388 + /// costs. Everything the two surfaces say the same way is said once --
389 + /// the empty state, the count, every row and every cell. What they cannot
390 + /// share is the pager: a page control on the panel swaps the panel in place
391 + /// and one on the page navigates, which is different markup rather than a
392 + /// different address, so it cannot be a value either surface hands over.
393 + /// See `panel_table`.
394 + shape panel_body(page: &Page<'_>) -> Vec<Node>;
323 395
324 396 include empty() when page.items.is_empty();
325 397
326 398 text "Showing {page.showing_start}-{page.showing_end} of {page.total_items} items"
327 399 unless page.items.is_empty();
328 - include table(page, surface) unless page.items.is_empty();
400 + include panel_table(page) unless page.items.is_empty();
401 + }
402 +
403 + declare! {
404 + /// The public page's contents, in order. See [`panel_body`].
405 + #[staged]
406 + pub(crate) shape page_body(page: &Page<'_>) -> Vec<Node>;
407 +
408 + include empty() when page.items.is_empty();
409 +
410 + text "Showing {page.showing_start}-{page.showing_end} of {page.total_items} items"
411 + unless page.items.is_empty();
412 + include page_table(page) unless page.items.is_empty();
329 413 }
330 414
331 415 declare! {
@@ -336,6 +420,7 @@
336 420 /// `ui::empty_state_with_action` -- and said the same thing with the same
337 421 /// way out. One spelling now, which is one of the things converting both
338 422 /// surfaces together buys.
423 + #[constant]
339 424 shape empty() -> Node;
340 425
341 426 empty "Nothing here yet. Follow users, projects, or tags to see their items here." {
@@ -344,8 +429,29 @@
344 429 }
345 430
346 431 declare! {
347 - /// The five columns, and the rows under them.
348 - shape table(page: &Page<'_>, surface: Surface) -> Node;
432 + /// The five columns, and the rows under them, for the library panel.
433 + ///
434 + /// # Why there are two of these
435 + ///
436 + /// The column list is written twice, here and in [`page_table`], and that
437 + /// is the price of the surface split rather than an oversight. What forced
438 + /// it: a page control on the panel is
439 + /// `Action::get(..).awaiting().replacing(LIBRARY_REGION)` and one on the
440 + /// page is `Action::get(..).navigating()`, which is different MARKUP, not a
441 + /// different address. A residual holds one markup per position, and the
442 + /// page surface is served from one.
443 + ///
444 + /// Saying the difference as guards on the action's modifiers was refused on
445 + /// reading: three guards that have to agree is what wiki
446 + /// `quasi-declare-form` section 24 calls one question written three times,
447 + /// and a derivation varies them one at a time.
448 + ///
449 + /// **The two lists are held together by a test rather than by hope.**
450 + /// `the_two_surfaces_draw_the_same_columns` renders both and compares the
451 + /// heading rows, so a column added to one and forgotten in the other fails
452 + /// there. Everything below the headings -- every row, every cell -- is
453 + /// [`row`], said once.
454 + shape panel_table(page: &Page<'_>) -> Node;
349 455
350 456 table {
351 457 column "Type" {
@@ -369,7 +475,61 @@
369 475 include row(item);
370 476 }
371 477
372 - more rest(page, surface) when page.total_pages over 1;
478 + more panel_rest(page) when page.total_pages over 1;
479 + }
480 + }
481 +
482 + declare! {
483 + /// The same five columns and rows, for the public page. See [`panel_table`].
484 + ///
485 + /// The pager is described rather than supplied, which is what puts this
486 + /// surface on the residual seam: a `Rest` handed over whole has no
487 + /// sentinel, and everything a described one carries is a number or an
488 + /// address. The directions are written back, then the strip, then forward,
489 + /// because that is the order they draw in and `quasi-declare` holds this to
490 + /// it.
491 + #[staged]
492 + pub(crate) shape page_table(page: &Page<'_>) -> Node;
493 +
494 + table {
495 + column "Type" {
496 + width Content;
497 + }
498 + column "Name" {
499 + width Fill;
500 + priority Essential;
501 + }
502 + column "Tag" {
503 + width Content;
504 + }
505 + column "Price" {
506 + width Content;
507 + }
508 + column "Date" {
509 + width Content;
510 + }
511 +
512 + for item in page.items.iter() {
513 + include row(item);
514 + }
515 +
516 + more Rest::page(page.offset(), page.per()).of(page.total()) {
517 + back Action::get("{PATH}?page={page.previous()}").navigating()
518 + when page.has_previous();
519 + // The strip, one control per page the handler windowed to. Which
520 + // one the reader is on is a readout rather than a control, which is
521 + // two markups at one position and is why `Op::Arms` had to exist.
522 + for offered in page.offered().iter() {
523 + jumping Jump::new(
524 + offered.page,
525 + Action::get("{PATH}?page={offered.page}").navigating()
526 + ) {
527 + here when offered.here;
528 + }
529 + }
530 + forward Action::get("{PATH}?page={page.next()}").navigating()
531 + when page.has_next();
532 + } when page.total_pages over 1;
373 533 }
374 534 }
375 535
@@ -394,6 +554,7 @@
394 554 /// checkable when both halves are in front of you, and here they never are.
395 555 /// The names must match [`table`]'s `column` strings exactly, since a name
396 556 /// no column carries is dropped rather than reported.
557 + #[staged]
397 558 shape row(item: &DiscoverItem) -> Row;
398 559
399 560 cells {
@@ -406,8 +567,20 @@
406 567 text item.creator.clone();
407 568 }
408 569 cell at "Tag" item.primary_tag.clone();
409 - cell at "Price" price(item) {
410 - token Tag::badge("Free").tone(layout::Tone::Success) when item.is_free;
570 + // One question with two shapes rather than two questions. A free item
571 + // says so with a badge and a priced one reads its price, and the cell
572 + // carries `cell-value` in the second case and not in the first -- so
573 + // the two are different markup at one position, which is a dispatch.
574 + //
575 + // Said as two guarded cells it would be one question written twice
576 + // (wiki `quasi-declare-form` section 24, rule one) and a derivation
577 + // varies guards one at a time, so it would render a Price column
578 + // holding two cells. quasicoherent `cbb63155`.
579 + given item.is_free {
580 + true -> cell at "Price" "" {
581 + token Tag::badge("Free").tone(layout::Tone::Success);
582 + }
583 + otherwise -> cell at "Price" price(item);
411 584 }
412 585 cell at "Date" item.date.clone();
413 586
@@ -417,29 +590,32 @@
417 590
418 591 /// What the reader has not been shown, and every way to ask for it.
419 592 ///
593 + /// The panel's, and the panel's alone. A supplier is the right shape here
594 + /// because this surface is not on the residual seam: it builds nodes per
595 + /// request, so handing the renderer a whole `Rest` costs nothing. The page
596 + /// surface describes its pager instead, in `page_table`.
597 + ///
420 598 /// The table asks for this only when there is more than one page, which is what
421 599 /// `{% if total_pages > 1 %}` said: a set that arrived whole has no rest, and a
422 - /// pager drawn over one would be two disabled buttons and the number 1. R9 means
423 - /// this is still called on a single-page feed, and the answer is thrown away.
424 - fn rest(page: &Page<'_>, surface: Surface) -> Rest {
425 - let per = constants::FEED_PAGE_SIZE as usize;
426 - let from = (page.current_page as usize).saturating_sub(1) * per;
427 - let mut rest = Rest::page(from, per).of(page.total_items as usize);
600 + /// pager drawn over one would be one control and the number 1. R9 means this is
601 + /// still called on a single-page feed, and the answer is thrown away.
602 + fn panel_rest(page: &Page<'_>) -> Rest {
603 + let mut rest = Rest::page(page.offset(), page.per()).of(page.total());
428 604
429 - if page.current_page > 1 {
430 - rest = rest.back(surface.address(page.current_page - 1));
605 + if page.has_previous() {
606 + rest = rest.back(panel_address(page.previous()));
431 607 }
432 - if page.current_page < page.total_pages {
433 - rest = rest.forward(surface.address(page.current_page + 1));
608 + if page.has_next() {
609 + rest = rest.forward(panel_address(page.next()));
434 610 }
435 611 // Which page the reader is on is carried per jump rather than compared
436 612 // against the paging inside each renderer. `Jump::here`'s reason is
437 613 // `Choice::chosen`'s: a strip is a loop, and a residual holds one compiled
438 614 // body per loop.
439 - for jump in page.pagination_range {
440 - let at = *jump as usize;
441 - let jumping = quasi_router::screen::Jump::new(at, surface.address(*jump));
442 - rest = rest.jumping(if at == page.current_page as usize {
615 + for offered in page.offered() {
616 + let jumping =
617 + quasi_router::screen::Jump::new(offered.page, panel_address(offered.page as u32));
618 + rest = rest.jumping(if offered.here {
443 619 jumping.here()
444 620 } else {
445 621 jumping
@@ -449,11 +625,21 @@
449 625 rest
450 626 }
451 627
628 + /// Two items, one priced and one free, as the tests draw them.
629 + ///
630 + /// Module-level rather than inside `mod tests` because `quasi::residuals` needs
631 + /// them too, and the price cell's two shapes are exactly what its filling test
632 + /// is crossing. Test-only.
633 + #[cfg(test)]
634 + pub(crate) fn sample_items() -> Vec<DiscoverItem> {
635 + vec![tests::item("itm_1", false), tests::item("itm_2", true)]
636 + }
637 +
452 638 #[cfg(test)]
453 639 mod tests {
454 640 use super::*;
455 641
456 - fn item(id: &str, free: bool) -> DiscoverItem {
642 + pub(super) fn item(id: &str, free: bool) -> DiscoverItem {
457 643 DiscoverItem {
458 644 id: id.to_string(),
459 645 name: format!("Item {id}"),
@@ -694,23 +880,60 @@
694 880 assert!(!html.contains("page-title"), "{html}");
695 881 }
696 882
697 - /// No `?page=` grammar survives in a template: both addresses are built
698 - /// here, from one function, on the surface that reads them.
883 + /// No `?page=` grammar survives in a template: each surface builds its own
884 + /// addresses in one place, and neither is a template's.
699 885 #[test]
700 - fn the_page_grammar_lives_in_one_place() {
886 + fn the_page_grammar_lives_in_one_place_per_surface() {
701 887 assert!(
702 - Surface::Panel
703 - .address(3)
888 + panel_address(3)
704 889 .destination
705 890 .route()
706 891 .is_some_and(|route| route == "/library/tabs/feed?page=3")
707 892 );
708 - assert!(
709 - Surface::Page
710 - .address(3)
711 - .destination
712 - .route()
713 - .is_some_and(|route| route == "/feed?page=3")
714 - );
893 +
894 + // The page surface's is in `page_table`'s own `more` body, so it is read
895 + // out of the markup rather than out of a function.
896 + let html = public(3, 8);
897 + assert!(html.contains("href=\"/feed?page=2\""), "{html}");
898 + assert!(html.contains("href=\"/feed?page=4\""), "{html}");
899 + }
900 +
901 + /// The two surfaces draw the same columns, which is what holds the split
902 + /// tables together.
903 + ///
904 + /// `panel_table` and `page_table` repeat the column list, because their
905 + /// pagers are different markup and a residual holds one. Nothing in the
906 + /// compiler pairs the two lists, so this does: a column added to one and
907 + /// forgotten in the other changes one heading row and not the other.
908 + #[test]
909 + fn the_two_surfaces_draw_the_same_columns() {
910 + use quasi_axum::Serves as _;
911 +
912 + fn headings(html: &str) -> Vec<&str> {
913 + html.match_indices("columnheader")
914 + .map(|(at, _)| {
915 + let rest = &html[at..];
916 + let from = rest.find('>').map_or(0, |at| at + 1);
917 + let to = rest[from..].find('<').map_or(0, |at| at + from);
918 + &rest[from..to]
919 + })
920 + .collect()
921 + }
922 +
923 + let (items, range) = page(4);
924 + let held = Page {
925 + items: &items,
926 + total_items: 40,
927 + current_page: 1,
928 + total_pages: 4,
929 + pagination_range: &range,
930 + showing_start: 1,
931 + showing_end: 20,
932 + };
933 + let panel = Webview::new().fragment(&panel_table(&held));
934 + let public = Webview::new().fragment(&page_table(&held));
935 +
936 + assert_eq!(headings(&panel), headings(&public));
937 + assert_eq!(headings(&panel).len(), 5, "{panel}");
715 938 }
716 939 }
@@ -119,7 +119,7 @@
119 119 /// public documents: a screen a reader can reach with no session, whose
120 120 /// header and controls differ by whether one is held. Every other mount
121 121 /// refuses before the handler runs, so a screen behind [`mount`],
122 - /// [`writes_only`] or [`document_mount`] can read it through
122 + /// [`writes_only`] or a served document mount can read it through
123 123 /// [`reader`](Self::reader) and never see the refusal that method can
124 124 /// return.
125 125 ///
@@ -646,7 +646,15 @@
646 646 vec![
647 647 (
648 648 feeds::PATH,
649 - document_mount(app, feeds::PATH, feeds::screen, feeds::renderer),
649 + served_gated_mount(app, feeds::PATH, feeds::renderer, |viewer, carried| {
650 + // One read, borrowed twice: the document says which page it is
651 + // and the markup fills from the same rows.
652 + let loaded = feeds::reading(viewer, carried)?;
653 + Ok(Served {
654 + screen: feeds::page_screen(&loaded.page()),
655 + markup: feeds::page_region_serve(&residuals::FEED, &loaded.page()).into(),
656 + })
657 + }),
650 658 ),
651 659 (
652 660 export_portal::PATH,
@@ -667,44 +675,6 @@
667 675 ]
668 676 }
669 677
670 - /// A described screen a reader NAVIGATES to, rather than a panel htmx fetches.
671 - ///
672 - /// The difference is what a signed-out reader gets. [`viewer_factory`] refuses
673 - /// with `denied` and quasi-axum answers that as a bare 403 with no body: right
674 - /// for a panel fetched by a page that already checked, wrong for an address a
675 - /// person can type. `tests/workflows/pages.rs::unauthorized_page_offers_login_and_signup`
676 - /// is the shipped rule, so the gate runs [`crate::auth::authenticate`] in front
677 - /// and answers whatever that refuses with, which renders the branded 401 with
678 - /// its way back in. It is the same call the `AuthUser` extractor makes, so the
679 - /// two paths cannot disagree about who is signed in.
680 - ///
681 - /// It costs one extra session read on this nest: the gate resolves the session
682 - /// and the factory resolves it again. Stated rather than optimised, because the
683 - /// alternative is a viewer whose `user` is optional, which is the signed-out
684 - /// question the feed conversion deliberately did not answer.
685 - ///
686 - /// # The address is registered whole, not as `/`
687 - ///
688 - /// [`mount`]'s nests are mounted with `nest_service`, which strips the prefix
689 - /// before the adapter sees the request. A document screen is mounted with
690 - /// `CsrfRouter::route_service` instead (see there for why), which strips
691 - /// nothing, so the router inside answers the address the reader typed.
692 - fn document_mount(
693 - app: &AppState,
694 - path: &'static str,
695 - screen: Screen,
696 - renderer: fn(&Viewer) -> quasi_webview::Webview,
697 - ) -> axum::Router {
698 - let router = quasi_router::Router::<Viewer>::new().get(path, screen);
699 - quasi_axum::Adapter::per_viewer(
700 - router,
701 - viewer_factory(app.clone(), Audience::Reader),
702 - move |viewer, _, _| renderer(viewer),
703 - )
704 - .into_router()
705 - .layer(axum::middleware::from_fn_with_state(app.clone(), signed_in))
706 - }
707 -
708 678 /// The gate in front of every document nest: a reader or a branded refusal.
709 679 async fn signed_in(
710 680 axum::extract::State(app): axum::extract::State<AppState>,
@@ -105,6 +105,9 @@
105 105 ("GIT_EXPLORE", |plan| {
106 106 Node::Region(super::git_explore::page_region_staged(plan))
107 107 }),
108 + ("FEED", |plan| {
109 + Node::Region(super::feeds::page_region_staged(plan))
110 + }),
108 111 ]
109 112 }
110 113
@@ -806,6 +809,62 @@
806 809 }
807 810 }
808 811
812 + /// The feed fills to what the renderer builds, at every page shape.
813 + ///
814 + /// The first residual holding **arms**, and both kinds of them
815 + /// (quasicoherent `cbb63155`). The numbered strip marks the page a reader
816 + /// is on by drawing a readout where every other page is a control, and the
817 + /// price cell is a badge for a free item and text for a priced one. Neither
818 + /// is markup that is there or is not, so neither is a branch: they are two
819 + /// markups at one position, which is what `Op::Arms` holds.
820 + ///
821 + /// Crossed over every page of a four-page set, both mixes of free and
822 + /// priced, and the empty feed that draws no table at all.
823 + #[test]
824 + fn the_feed_residual_fills_to_what_the_renderer_builds() {
825 + use crate::quasi::feeds::{Page, page_region, page_region_serve};
826 + use quasi_axum::Serves as _;
827 +
828 + let items = crate::quasi::feeds::sample_items();
829 + let range: Vec<u32> = (1..=4).collect();
830 +
831 + for current in 1..=4 {
832 + for held in [items.as_slice(), &[]] {
833 + let page = Page {
834 + items: held,
835 + total_items: 80,
836 + current_page: current,
837 + total_pages: 4,
838 + pagination_range: &range,
839 + showing_start: 1,
840 + showing_end: 20,
841 + };
842 + assert_eq!(
843 + page_region_serve(&FEED, &page),
844 + Webview::new().fragment(&Node::Region(page_region(&page))),
845 + "page {current} of 4, {} items",
846 + held.len(),
847 + );
848 + }
849 + }
850 +
851 + // And a single page, which draws no pager at all.
852 + let page = Page {
853 + items: &items,
854 + total_items: 2,
855 + current_page: 1,
856 + total_pages: 1,
857 + pagination_range: &[],
858 + showing_start: 1,
859 + showing_end: 2,
860 + };
861 + assert_eq!(
862 + page_region_serve(&FEED, &page),
863 + Webview::new().fragment(&Node::Region(page_region(&page))),
864 + "one page",
865 + );
866 + }
867 +
809 868 /// The payout card fills to what the renderer builds.
810 869 ///
811 870 /// The first residual holding a **figure strip**, which is the other half
@@ -907,14 +966,7 @@
907 966 /// is to describe the chart rather than to compile around it, so this
908 967 /// screen stays here until a chart is in the vocabulary.
909 968 /// quasicoherent `7d6ad166`.
910 - /// - `/feed`: pages with a numbered strip, which prints each page
911 - /// number and marks the one being read by comparing it against the
912 - /// paging -- "exactly one row differs" inside a loop, which takes the
913 - /// Select's answer. quasicoherent `cbb63155`.
914 - const OFF_THE_SEAM: &[&str] = &[
915 - crate::quasi::user_analytics::PATH,
916 - crate::quasi::feeds::PATH,
917 - ];
969 + const OFF_THE_SEAM: &[&str] = &[crate::quasi::user_analytics::PATH];
918 970
919 971 let described = PATHS.len() + DOCUMENT_PATHS.len() + PUBLIC_DOCUMENT_PATHS.len();
920 972
@@ -955,8 +1007,8 @@
955 1007 /// Screens with their own filling test: `/use-cases`, `/fan-plus`,
956 1008 /// `/c/{username}/{slug}`, `/dashboard/export`, `/git/{owner}`, and the
957 1009 /// two forum panes, the two contact panes, the payout card,
958 - /// `/creators`, `/dashboard/tabs/ssh-keys` and `/git`.
959 - const HOLED: usize = 13;
1010 + /// `/creators`, `/dashboard/tabs/ssh-keys`, `/git` and `/feed`.
1011 + const HOLED: usize = 14;
960 1012
961 1013 assert_eq!(
962 1014 roster().len(),
@@ -593,4 +593,97 @@
593 593 ])),
594 594 ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"git-explore-anchored\" data-menu=\"anchored\" hidden></div></div>")),
595 595 ]);
596 +
597 + pub static FEED: ::quasi_router::stage::Residual =
598 + ::quasi_router::stage::Residual::compiled(&[
599 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"feed\" class=\"region pane\"><h1 class=\"heading\">Your Feed</h1>")),
600 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
601 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"placeholder\" data-state=\"empty\" role=\"status\" aria-live=\"polite\"><p class=\"placeholder-text\">Nothing here yet. Follow users, projects, or tags to see their items here.</p><div class=\"placeholder-action\"><a class=\"button\" data-act href=\"/discover\">Browse Discover</a></div></div>")),
602 +
603 + ])),
604 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
605 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">Showing ")),
606 + ::quasi_router::stage::Op::Hole { scope: 1, id: 0 },
607 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("-")),
608 + ::quasi_router::stage::Op::Hole { scope: 1, id: 1 },
609 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(" of ")),
610 + ::quasi_router::stage::Op::Hole { scope: 1, id: 2 },
611 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(" items</p>")),
612 +
613 + ])),
614 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
615 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"table\" class=\"table\"><div role=\"row\" class=\"table-head\"><span role=\"columnheader\" class=\"table-heading col-Type cell-content cell-drops-next\">Type</span><span role=\"columnheader\" class=\"table-heading col-Name cell-fill cell-keeps\">Name</span><span role=\"columnheader\" class=\"table-heading col-Tag cell-content cell-drops-next\">Tag</span><span role=\"columnheader\" class=\"table-heading col-Price cell-content cell-drops-next\">Price</span><span role=\"columnheader\" class=\"table-heading col-Date cell-content cell-drops-next\">Date</span></div>")),
616 + ::quasi_router::stage::Op::Loop(::std::borrow::Cow::Borrowed(&[
617 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"row\" class=\"table-row\" data-row href=\"/i/")),
618 + ::quasi_router::stage::Op::Hole { scope: 993, id: 6 },
619 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\"><div class=\"cell col-Type cell-content cell-drops-next\"><span class=\"cell-tokens\"><span class=\"badge\">")),
620 + ::quasi_router::stage::Op::Hole { scope: 993, id: 0 },
621 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</span></span></div><div class=\"cell col-Name cell-fill cell-keeps\"><span class=\"cell-value\">")),
622 + ::quasi_router::stage::Op::Hole { scope: 993, id: 1 },
623 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</span><span class=\"cell-value\">")),
624 + ::quasi_router::stage::Op::Hole { scope: 993, id: 2 },
625 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</span></div><div class=\"cell col-Tag cell-content cell-drops-next cell-value\">")),
626 + ::quasi_router::stage::Op::Hole { scope: 993, id: 3 },
627 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Price cell-content cell-drops-next")),
628 + ::quasi_router::stage::Op::Arms(::std::borrow::Cow::Borrowed(&[
629 + ::std::borrow::Cow::Borrowed(&[
630 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\"><span class=\"cell-tokens\"><span class=\"badge\" data-tone=\"success\">Free</span></span>")),
631 +
632 + ]),
633 + ::std::borrow::Cow::Borrowed(&[
634 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(" cell-value\">")),
635 + ::quasi_router::stage::Op::Hole { scope: 993, id: 4 },
636 +
637 + ]),
638 + ])),
639 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Date cell-content cell-drops-next cell-value\">")),
640 + ::quasi_router::stage::Op::Hole { scope: 993, id: 5 },
641 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div></div>")),
642 +
643 + ])),
644 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div>")),
645 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
646 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"rest\">")),
647 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
648 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<a class=\"button rest-previous\" href=\"/feed?page=")),
649 + ::quasi_router::stage::Op::Hole { scope: 32, id: 3 },
650 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\">Prev</a>")),
651 +
652 + ])),
653 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"rest-pages\">")),
654 + ::quasi_router::stage::Op::Loop(::std::borrow::Cow::Borrowed(&[
655 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<")),
656 + ::quasi_router::stage::Op::Arms(::std::borrow::Cow::Borrowed(&[
657 + ::std::borrow::Cow::Borrowed(&[
658 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("span class=\"rest-page rest-page-here\" aria-current=\"page\">")),
659 + ::quasi_router::stage::Op::Hole { scope: 32, id: 4 },
660 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</span")),
661 +
662 + ]),
663 + ::std::borrow::Cow::Borrowed(&[
664 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("a class=\"button rest-page\" href=\"/feed?page=")),
665 + ::quasi_router::stage::Op::Hole { scope: 32, id: 5 },
666 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\">")),
667 + ::quasi_router::stage::Op::Hole { scope: 32, id: 4 },
668 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</a")),
669 +
670 + ]),
671 + ])),
672 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(">")),
673 +
674 + ])),
675 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div>")),
676 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
677 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<a class=\"button rest-next\" href=\"/feed?page=")),
678 + ::quasi_router::stage::Op::Hole { scope: 32, id: 6 },
679 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\">Next</a>")),
680 +
681 + ])),
682 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div>")),
683 +
684 + ])),
685 +
686 + ])),
687 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"feed-anchored\" data-menu=\"anchored\" hidden></div></div>")),
688 + ]);
596 689