Skip to main content

max / makenotwork

Spike quasi-axum against the server: one screen through the router Tier G1. /spike/docs answers what /docs answers, described rather than templated, so the cost of converting a screen here is a number. The docs index was chosen because its data is an in-memory DocLoader and it is public, which holds the async layer and the session still and leaves the part that is genuinely about describing a screen. Five findings, the first two structural and the third the one that decides the tier. A handler is sync and this server is async Postgres end to end, so every described route holds a blocking-pool thread for a round trip. Nothing carries identity into a handler: decode drops the header map and the per-request factory feeds the renderer after dispatch, so an authenticated screen cannot be described at all today. And an internal navigation emits a button with hx-get, never an href, which lands hardest on exactly the public tier that is the only one able to convert. The other two are smaller and still real: the site header has no home once base.html is gone, and this screen, rated furniture by phase 0, needs a bespoke fill for its search box and loses the Guide section's disclosure groups to a vocabulary gap. Full write-up in the module docs, which is where the numbers are. The module is meant to be deleted when G2 is answered.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 14:31 UTC
Signed with PGP, not checked
Commit: 76a2e57b1442d8d93e3de8d29296a9a30a4295ff
Parent: 26fbb65
4 files changed, +240 insertions, -20 deletions
M server/Cargo.lock +45 -20
@@ -5247,6 +5247,9 @@
5247 5247 "object 0.39.1",
5248 5248 "pom-contract",
5249 5249 "proptest",
5250 + "quasi-axum",
5251 + "quasi-router",
5252 + "quasi-webview",
5250 5253 "rand 0.10.2",
5251 5254 "regex",
5252 5255 "reqwest 0.13.4",
@@ -6537,6 +6540,44 @@
6537 6540 "winapi",
6538 6541 ]
6539 6542
6543 + [[package]]
6544 + name = "quasi-axum"
6545 + version = "0.1.0"
6546 + dependencies = [
6547 + "axum",
6548 + "http 1.4.2",
6549 + "quasi-http",
6550 + "quasi-router",
6551 + "tokio",
6552 + ]
6553 +
6554 + [[package]]
6555 + name = "quasi-http"
6556 + version = "0.1.0"
6557 + dependencies = [
6558 + "form_urlencoded",
6559 + "http 1.4.2",
6560 + "quasi-router",
6561 + ]
6562 +
6563 + [[package]]
6564 + name = "quasi-router"
6565 + version = "0.1.0"
6566 + dependencies = [
6567 + "makeover-layout",
6568 + ]
6569 +
6570 + [[package]]
6571 + name = "quasi-webview"
6572 + version = "0.1.0"
6573 + dependencies = [
6574 + "docengine",
6575 + "makeover-layout",
6576 + "makeover-webview",
6577 + "quasi-http",
6578 + "quasi-router",
6579 + ]
6580 +
6540 6581 [[package]]
6541 6582 name = "quick-error"
6542 6583 version = "1.2.3"
@@ -10643,27 +10684,11 @@
10643 10684 ]
10644 10685
10645 10686 [[patch.unused]]
10646 - name = "quasi-axum"
10687 + name = "kberg"
10647 10688 version = "0.1.0"
10648 10689
10649 10690 [[patch.unused]]
10650 - name = "quasi-http"
10651 - version = "0.1.0"
10652 -
10653 - [[patch.unused]]
10654 - name = "quasi-router"
10655 - version = "0.1.0"
10656 -
10657 - [[patch.unused]]
10658 - name = "quasi-store"
10659 - version = "0.1.0"
10660 -
10661 - [[patch.unused]]
10662 - name = "quasi-tauri"
10663 - version = "0.1.0"
10664 -
10665 - [[patch.unused]]
10666 - name = "quasi-webview"
10691 + name = "painhours"
10667 10692 version = "0.1.0"
10668 10693
10669 10694 [[patch.unused]]
@@ -10675,9 +10700,9 @@
10675 10700 version = "0.2.0"
10676 10701
10677 10702 [[patch.unused]]
10678 - name = "kberg"
10703 + name = "quasi-store"
10679 10704 version = "0.1.0"
10680 10705
10681 10706 [[patch.unused]]
10682 - name = "painhours"
10707 + name = "quasi-tauri"
10683 10708 version = "0.1.0"
@@ -150,6 +150,14 @@
150 150 # Shared theme palette + the bundled theme set (Tier 0 creator theming).
151 151 makeover = "2.4.1"
152 152
153 + # The description layer, for the tier G1 spike only: one read-only screen served
154 + # through quasi beside its Askama equivalent, so the cost of describing a screen
155 + # here is measured rather than argued. Not load-bearing for any shipped route.
156 + # See wiki look-wave-2, tier G.
157 + quasi-router = { git = "https://makenot.work/git/max/quasi.git" }
158 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git" }
159 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git" }
160 +
153 161 # Git source browser
154 162 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
155 163 syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] }
@@ -48,6 +48,7 @@
48 48 pub mod openapi;
49 49 pub mod payments;
50 50 pub mod pricing;
51 + pub mod quasi_spike;
51 52 pub mod rate_limit;
52 53 pub mod routes;
53 54 pub mod rss;
@@ -557,6 +558,10 @@
557 558 .merge(csrf_routes)
558 559 .merge(git_routes())
559 560 .merge(routes::embed::embed_routes())
561 + // Tier G1. Serves /spike/docs beside /docs so the two can be diffed.
562 + // Mounted as a service, not merged: the adapter mounts as a fallback and
563 + // this server has one. Delete with the module when G2 is answered.
564 + .nest_service("/spike", quasi_spike::router(Arc::clone(&state.docs)))
560 565 .route(
561 566 "/api/openapi.json",
562 567 axum::routing::get(openapi::openapi_json),
@@ -1,0 +1,182 @@
1 + //! Tier G1: one screen through the description layer, beside its Askama one.
2 + //!
3 + //! Wiki note `look-wave-2`, tier G. This exists to be measured and deleted, not
4 + //! to be built on. It serves `/spike/docs`, which answers the same thing
5 + //! `/docs` does, described rather than templated, so the two can be diffed and
6 + //! the cost of converting a screen here is a number instead of an argument.
7 + //!
8 + //! The docs index was chosen because it isolates the question. Its data is an
9 + //! in-memory `Arc<DocLoader>`, so nothing about it is entangled with the async
10 + //! Postgres layer, and it is public, so nothing about it is entangled with the
11 + //! session. What is left over when both of those are held still is the part
12 + //! that is genuinely about describing a screen. What the choice *hides* is the
13 + //! two findings in the module docs below, which are the ones that decide G2.
14 + //!
15 + //! # Finding 1: a handler cannot await, and this server's data layer is async
16 + //!
17 + //! [`quasi_router::Handler`] is `fn(&S, Params) -> Result<Response, RouteError>`,
18 + //! sync by quasi's decision 6, which was taken for egui-in-a-frame and
19 + //! terminal-in-an-event-loop and matches the desktop apps' rusqlite store. This
20 + //! server is async sqlx over Postgres end to end. `quasi-axum` runs the router
21 + //! on `spawn_blocking`, so a handler *can* reach a pool through
22 + //! `Handle::block_on`, but every described route then holds a blocking-pool
23 + //! thread for the length of a database round trip. That is a real cost on the
24 + //! one host in the tree that has many concurrent readers.
25 + //!
26 + //! # Finding 2: nothing carries identity into a handler
27 + //!
28 + //! `quasi_http::decode` builds `Incoming { method, path, params }` and drops the
29 + //! header map. Params are the query string and the form body, which is to say
30 + //! things the client chose. A handler receives `&S`, which is shared, and those
31 + //! params. There is no session, no cookie, no `AuthUser`, and no side channel:
32 + //! the doc comment on `Adapter::per_request` says so explicitly, and that
33 + //! factory feeds the *renderer*, after dispatch, not the handler.
34 + //!
35 + //! So an authenticated screen cannot be described today. That is most of this
36 + //! server: of the 105 conversion units phase 0 counted, the dashboard, project,
37 + //! item and library families are all behind auth. The public tier is what would
38 + //! convert as-is, which is roughly the batch-1 set.
39 + //!
40 + //! # Finding 3: an internal navigation is never a link
41 + //!
42 + //! Read off the emitted document, which the test below prints. A row that
43 + //! activates emits
44 + //!
45 + //! ```html
46 + //! <button type="button" class="row-activate" hx-get="/docs/api" hx-swap="morph">API</button>
47 + //! ```
48 + //!
49 + //! `quasi_webview::node::action_attrs` writes an `href` only for
50 + //! [`Destination::External`](quasi_router::Destination); an internal route is
51 + //! always a control carrying `hx-get`. So there is no href for a crawler to
52 + //! follow, no middle-click, no copy-link, and with JS off the page is inert.
53 + //!
54 + //! That collides head-on with finding 2. The only tier that can be described
55 + //! today is the public one, and the public one is the tier where this costs
56 + //! most: `/docs`, `/discover`, and every creator, project and item page is an
57 + //! SEO surface. The docs index today is `<li><a href="/docs/{slug}">`.
58 + //!
59 + //! # Finding 4: the site chrome has no home
60 + //!
61 + //! The Askama page opens with `{% include "partials/site_header.html" %}`, 35
62 + //! lines of nav, sign-in state and the wordmark. [`Shell`] emits the `<head>`
63 + //! and the `<body>` wrapper and nothing inside it, so the header is either a
64 + //! `Region::Band` every screen redescribes, or a bespoke fill every screen
65 + //! carries. Either way it is per-screen work times 105, for markup that is the
66 + //! same on all of them, and `base.html` does it once today.
67 + //!
68 + //! # Finding 5: two vocabulary gaps this one screen already hits
69 + //!
70 + //! - **Disclosure.** The Guide section groups into `<details open><summary>`
71 + //! subsections. No node says that, so the described version flattens them.
72 + //! - **The search box.** The input, its results container and `docs-search.js`
73 + //! are bespoke, so this screen needs a `Region::Bespoke` fill even though
74 + //! phase 0 rated it furniture. Worth noting for the sizing: "furniture" was
75 + //! read off templates, and this one turned out to carry a fill.
76 + //!
77 + //! # What it deletes, measured on this screen
78 + //!
79 + //! `templates/pages/doc_index.html` is 45 lines and would go. The 45-line
80 + //! grouping pass in the Askama handler stays, near enough verbatim: the
81 + //! description replaces the markup, not the domain logic. The described screen
82 + //! is 14 lines of handler plus 7 of assembly.
83 + //!
84 + //! It does **not** delete the stylesheet. `style.css` carries 25 `.docs-*`
85 + //! rules; of those the `.docs-search-*` set survives with the fill, and the
86 + //! `.docs-index` / `.docs-section` set has nothing to attach to any more,
87 + //! because the emitted classes are the generated `.list` / `.row` / `.heading`.
88 + //! Note what that does to charter rule 13: both the page title and the section
89 + //! titles emit `class="heading"`, so the 13 named heading classes have no
90 + //! purchase on a described screen and any level distinction has to come from
91 + //! the element selector.
92 +
93 + use std::sync::Arc;
94 +
95 + use docengine::DocLoader;
96 + use quasi_axum::Adapter;
97 + use quasi_router::{Node, Params, RegionKind, Response, RouteError, Row, Screen, Slot};
98 + use quasi_webview::{Shell, Webview};
99 +
100 + /// The docs index, described.
101 + ///
102 + /// Compare against `routes::pages::public::docs::docs_index`, which is the same
103 + /// grouping over the same loader ending in `DocIndexTemplate`.
104 + fn docs_index(docs: &DocLoader, _params: Params) -> Result<Response, RouteError> {
105 + // The grouping is the handler's either way. Describing a screen does not
106 + // remove the domain pass over the index, and this is a fair copy of it.
107 + let mut sections: Vec<(String, Vec<Row>)> = Vec::new();
108 + for entry in docs.index() {
109 + let row = Row::new(entry.title.clone())
110 + .activate(quasi_router::Action::get(format!("/docs/{}", entry.slug)));
111 + match sections.iter_mut().find(|(name, _)| name == &entry.section) {
112 + Some((_, rows)) => rows.push(row),
113 + None => sections.push((entry.section.clone(), vec![row])),
114 + }
115 + }
116 +
117 + Ok(screen_from(sections).into())
118 + }
119 +
120 + /// Build the screen from an already-grouped index.
121 + ///
122 + /// Split out of the handler so a test can render it without standing up a
123 + /// [`DocLoader`]. The grouping above is the only part that touches the loader.
124 + fn screen_from(sections: Vec<(String, Vec<Row>)>) -> Screen {
125 + let mut pane = Slot::new("docs", RegionKind::Pane).with(Node::page("Documentation"));
126 + for (name, rows) in sections {
127 + pane = pane.with(Node::section(name)).with(Node::list(rows));
128 + }
129 + Screen::sidebar_content("Documentation - Makenotwork").with(pane)
130 + }
131 +
132 + /// The renderer this spike serves with.
133 + fn renderer() -> Webview {
134 + // `Shell::under` points the asset paths at `/static`, which is what this
135 + // server already serves. `layered` states the cascade order the hand-written
136 + // head states today; without it the generated sheets would establish the
137 + // `makeover` layer by link order.
138 + let shell = Shell::under("/static").layered(["base", "components", "responsive"]);
139 + Webview::new().with_shell(shell)
140 + }
141 +
142 + /// The spike's route table and renderer, mounted under `/spike`.
143 + ///
144 + /// Mounted with `nest_service` rather than merged, because
145 + /// [`Adapter::into_router`] mounts as a fallback and this server already has
146 + /// one. The prefix is stripped, so the router sees `/docs` and the pattern
147 + /// below is the real one.
148 + pub fn router(docs: Arc<DocLoader>) -> axum::Router {
149 + let quasi = quasi_router::Router::<DocLoader>::new().get("/docs", docs_index);
150 + Adapter::new(quasi, docs, Arc::new(renderer())).into_router()
151 + }
152 +
153 + #[cfg(test)]
154 + mod tests {
155 + use super::*;
156 + use quasi_axum::Render;
157 +
158 + /// Print what the description layer emits for this screen, so the G1
159 + /// measurement is read off a real document rather than reasoned about.
160 + #[test]
161 + fn emits_the_docs_index() {
162 + let sections = vec![
163 + (
164 + "Guide".to_string(),
165 + vec![
166 + Row::new("Getting started")
167 + .activate(quasi_router::Action::get("/docs/getting-started")),
168 + Row::new("Uploading files")
169 + .activate(quasi_router::Action::get("/docs/uploading")),
170 + ],
171 + ),
172 + (
173 + "Reference".to_string(),
174 + vec![Row::new("API").activate(quasi_router::Action::get("/docs/api"))],
175 + ),
176 + ];
177 +
178 + let html = renderer().screen(&screen_from(sections));
179 + println!("{html}");
180 + assert!(html.contains("Documentation"));
181 + }
182 + }