Skip to main content

max / goingson

Describe the projects screen through quasi, behind an off-by-default feature
Author: Max Johnson <me@maxj.phd> · 2026-08-08 23:53 UTC
Signed with PGP, not checked
Commit: 6867fd73a88f564db331a35ea791c463e2a1dafc
Parent: 07905b6
5 files changed, +584 insertions, -0 deletions
M Cargo.lock +40
@@ -2181,6 +2181,10 @@
2181 2181 "open",
2182 2182 "painhours",
2183 2183 "pter",
2184 + "quasi-http",
2185 + "quasi-router",
2186 + "quasi-tauri",
2187 + "quasi-webview",
2184 2188 "rand 0.10.2",
2185 2189 "reqwest",
2186 2190 "rfd",
@@ -4514,6 +4518,42 @@
4514 4518 source = "registry+https://github.com/rust-lang/crates.io-index"
4515 4519 checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
4516 4520
4521 + [[package]]
4522 + name = "quasi-http"
4523 + version = "0.1.0"
4524 + dependencies = [
4525 + "form_urlencoded",
4526 + "http",
4527 + "quasi-router",
4528 + ]
4529 +
4530 + [[package]]
4531 + name = "quasi-router"
4532 + version = "0.1.0"
4533 + dependencies = [
4534 + "makeover-layout",
4535 + ]
4536 +
4537 + [[package]]
4538 + name = "quasi-tauri"
4539 + version = "0.1.0"
4540 + dependencies = [
4541 + "http",
4542 + "quasi-http",
4543 + "quasi-router",
4544 + "tauri",
4545 + ]
4546 +
4547 + [[package]]
4548 + name = "quasi-webview"
4549 + version = "0.1.0"
4550 + dependencies = [
4551 + "makeover-layout",
4552 + "makeover-webview",
4553 + "quasi-http",
4554 + "quasi-router",
4555 + ]
4556 +
4517 4557 [[package]]
4518 4558 name = "quick-xml"
4519 4559 version = "0.41.0"
@@ -34,6 +34,15 @@
34 34 synckit-client = { git = "https://makenot.work/git/max/synckit.git" }
35 35 synckit-config = { git = "https://makenot.work/git/max/synckit.git" }
36 36
37 + # The quasi stack, behind the `quasi` feature. Git URLs redirected to the
38 + # working copy by ~/Code/.cargo/config.toml, the way every cross-repo
39 + # dependency here is. astra is quasi's origin rather than a mirror, since it
40 + # has never been pushed to makenot.work.
41 + quasi-router = { git = "ssh://astra/home/max/git-mirrors/quasi.git", optional = true }
42 + quasi-http = { git = "ssh://astra/home/max/git-mirrors/quasi.git", optional = true }
43 + quasi-webview = { git = "ssh://astra/home/max/git-mirrors/quasi.git", optional = true }
44 + quasi-tauri = { git = "ssh://astra/home/max/git-mirrors/quasi.git", optional = true }
45 +
37 46 # Tauri
38 47 tauri = { workspace = true, features = ["image-png", "devtools"] }
39 48 tauri-plugin-dialog = { workspace = true }
@@ -149,6 +158,10 @@
149 158 [features]
150 159 default = ["custom-protocol"]
151 160 custom-protocol = ["tauri/custom-protocol"]
161 + # The quasi projects screen, off by default. Nothing in a default build reaches
162 + # `src/quasi/`; the shipped screen is `frontend/js/projects.js` unchanged. This
163 + # is the proving ground for the description layer, not a migration.
164 + quasi = ["dep:quasi-router", "dep:quasi-http", "dep:quasi-webview", "dep:quasi-tauri"]
152 165
153 166 [lints]
154 167 workspace = true
@@ -15,6 +15,10 @@
15 15 pub mod jmap;
16 16 pub mod oauth;
17 17 pub mod problems;
18 + /// The projects screen described rather than built. Off by default; see the
19 + /// module header and the `quasi` feature.
20 + #[cfg(feature = "quasi")]
21 + pub mod quasi;
18 22 pub mod state;
19 23 pub mod syncstore;
20 24
@@ -1,0 +1,312 @@
1 + //! The projects screen, described rather than built.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! The proving ground for [`quasi_webview`], chosen 2026-08-08 over testing the
6 + //! renderer against only an app written to make it pass. Behind the `quasi`
7 + //! feature, which is off: nothing in a default build reaches this module, and
8 + //! the shipped app is `frontend/js/projects.js` exactly as before.
9 + //!
10 + //! # What it is for
11 + //!
12 + //! Not to replace the screen. To find out what a real screen needs that the
13 + //! description layer cannot say, while that is still cheap to fix. Two things
14 + //! turned up immediately and are recorded in [`row_for`], because a finding
15 + //! that lives only in a commit message is a finding nobody acts on.
16 + //!
17 + //! # The shape
18 + //!
19 + //! Three routes, which is the whole screen:
20 + //!
21 + //! - `GET /projects` — the document.
22 + //! - `GET /projects/list` — the grid alone, which is what the two filters swap.
23 + //! - `GET /projects/{id}` — the detail pane.
24 + //!
25 + //! The filters are routes rather than local state, per decision 2. `projects.js`
26 + //! holds `showSharedOnly` and `showRetired` in module scope and re-renders from a
27 + //! cached list; here they are query params, so the same screen is reachable by
28 + //! address and no state has to survive between two clicks.
29 +
30 + // Handlers take their params by value because `quasi_router::Handler` is a
31 + // plain `fn(&S, Params)` pointer, so the signature is the router's and not a
32 + // choice made here. Same allow, for the same reason, as quasi-axum's tests.
33 + #![allow(clippy::needless_pass_by_value)]
34 +
35 + use std::sync::Arc;
36 +
37 + use goingson_core::{Project, ProjectStatus, ProjectType};
38 + use quasi_router::screen::{Act, Row};
39 + use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot};
40 +
41 + use crate::state::{AppState, DESKTOP_USER_ID};
42 +
43 + #[cfg(test)]
44 + mod tests;
45 +
46 + /// Whether a project has stopped being worked on.
47 + ///
48 + /// `projects.js:isRetired` reads the same two statuses. Duplicated rather than
49 + /// shared because the JS is what ships; when this module replaces it, this is
50 + /// the copy that survives.
51 + fn retired(project: &Project) -> bool {
52 + matches!(
53 + project.status,
54 + ProjectStatus::Completed | ProjectStatus::Archived
55 + )
56 + }
57 +
58 + /// The display name of a project type.
59 + fn type_label(project_type: &ProjectType) -> &'static str {
60 + match project_type {
61 + ProjectType::SideProject => "Side Project",
62 + ProjectType::Job => "Job",
63 + ProjectType::Company => "Company",
64 + ProjectType::Essay => "Essay",
65 + ProjectType::Article => "Article",
66 + ProjectType::Painting => "Painting",
67 + ProjectType::Other => "Other",
68 + }
69 + }
70 +
71 + /// The display name of a project status.
72 + fn status_label(status: &ProjectStatus) -> &'static str {
73 + match status {
74 + ProjectStatus::Active => "Active",
75 + ProjectStatus::OnHold => "On Hold",
76 + ProjectStatus::Completed => "Completed",
77 + ProjectStatus::Archived => "Archived",
78 + }
79 + }
80 +
81 + /// One project as a row.
82 + ///
83 + /// # The two things the description cannot say
84 + ///
85 + /// Both found here, on the first real screen, which is what the proving ground
86 + /// was for.
87 + ///
88 + /// **A row carries one trailing fact and this card has two.**
89 + /// `makeover_layout::RowPart` is `Primary | Secondary | Meta | Actions`, taken
90 + /// from Balanced Breakfast as the consumer that had all four. A project card
91 + /// carries a type badge *and* a status badge, and the status badge is toned:
92 + /// `projects.js` runs `statusTone(status)` and colours it. Joined into `meta`
93 + /// here, which keeps both facts and loses the tone — a status reads as text
94 + /// rather than as green or amber. `Node::Token` exists and says exactly the
95 + /// right thing, but only as a node in its own right, never inside a row.
96 + /// Naming it in `makeover-layout` first is what the admission test requires,
97 + /// so this is a finding rather than a patch.
98 + ///
99 + /// **A description carries text and this card carries markdown.**
100 + /// `ProjectResponse::description_html` is `docengine::render_standard`, and the
101 + /// card renders it as HTML. Nothing in the vocabulary names rich text, and it
102 + /// should not be smuggled in as a string the renderer trusts — that is the one
103 + /// door through which a description becomes a templating language. The raw
104 + /// description goes into `secondary` as text. A `Region::Bespoke` is the
105 + /// vocabulary's own answer for a place the app fills itself, and it is the
106 + /// shape this wants if it turns out to matter.
107 + fn row_for(project: &Project, selected: bool) -> Row {
108 + let mut row = Row::new(&project.name).meta(format!(
109 + "{} · {}",
110 + type_label(&project.project_type),
111 + status_label(&project.status)
112 + ));
113 +
114 + if !project.description.is_empty() {
115 + // Text, not the rendered HTML. See the note above.
116 + row = row.secondary(&project.description);
117 + }
118 +
119 + row.selected = selected;
120 + row.activate = Some(Action::get(format!("/projects/{}", project.id)));
121 + row
122 + }
123 +
124 + /// The grid, filtered the way the screen's two toggles filter it.
125 + fn grid(state: &AppState, shared_only: bool, show_retired: bool) -> Result<Node, RouteError> {
126 + let all = state
127 + .projects
128 + .list_all(DESKTOP_USER_ID)
129 + .map_err(|error| RouteError::internal(error.to_string()))?;
130 +
131 + if all.is_empty() {
132 + return Ok(Node::text("No projects yet."));
133 + }
134 +
135 + let scoped: Vec<&Project> = all
136 + .iter()
137 + .filter(|project| !shared_only || project.group_id.is_some())
138 + .collect();
139 +
140 + if scoped.is_empty() {
141 + return Ok(Node::text(
142 + "No shared projects yet. Share a project from its menu to see it here.",
143 + ));
144 + }
145 +
146 + let (live, dormant): (Vec<&Project>, Vec<&Project>) =
147 + scoped.into_iter().partition(|project| !retired(project));
148 +
149 + if live.is_empty() && !show_retired {
150 + return Ok(Node::text("Every project is completed or archived."));
151 + }
152 +
153 + let shown = if show_retired {
154 + live.into_iter().chain(dormant).collect::<Vec<_>>()
155 + } else {
156 + live
157 + };
158 +
159 + Ok(Node::list(
160 + shown.into_iter().map(|project| row_for(project, false)),
161 + ))
162 + }
163 +
164 + /// How many projects are shared into a group, and how many are retired.
165 + ///
166 + /// Both counts drive whether a control appears at all, so they are read once
167 + /// per screen rather than per control.
168 + fn counts(state: &AppState) -> Result<(usize, usize), RouteError> {
169 + let all = state
170 + .projects
171 + .list_all(DESKTOP_USER_ID)
172 + .map_err(|error| RouteError::internal(error.to_string()))?;
173 + Ok((
174 + all.iter().filter(|p| p.group_id.is_some()).count(),
175 + all.iter().filter(|p| retired(p)).count(),
176 + ))
177 + }
178 +
179 + /// Whether a param is on. Absent is off, which is what a URL without it means.
180 + fn flag(params: &quasi_router::Params, name: &str) -> bool {
181 + matches!(params.get(name), Some("1" | "true"))
182 + }
183 +
184 + /// The address of the grid under a given pair of filters.
185 + fn list_action(shared_only: bool, show_retired: bool) -> Action {
186 + let mut action = Action::get("/projects/list");
187 + if shared_only {
188 + action = action.with("shared", "1");
189 + }
190 + if show_retired {
191 + action = action.with("retired", "1");
192 + }
193 + action
194 + }
195 +
196 + /// The whole screen.
197 + fn index(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
198 + let shared_only = flag(&params, "shared");
199 + let show_retired = flag(&params, "retired");
200 + let (shared, dormant) = counts(state)?;
201 +
202 + let mut band = Slot::new("projects-band", RegionKind::Band)
203 + .with(Node::page("Projects"))
204 + .with(Node::act("New project", Action::get("/projects/new")));
205 +
206 + // The filter surfaces only when sharing is in play, which is the rule
207 + // `projects.js` already applies to the same control.
208 + if shared > 0 || shared_only {
209 + band = band.with(Node::Token {
210 + kind: makeover_layout::Token::Chip { removable: false },
211 + label: "Shared only".into(),
212 + tone: makeover_layout::Tone::Neutral,
213 + latched: shared_only,
214 + action: Some(list_action(!shared_only, show_retired)),
215 + });
216 + }
217 +
218 + if dormant > 0 {
219 + band = band.with(Node::Act(Act::new(
220 + if show_retired {
221 + "Hide completed and archived".to_owned()
222 + } else {
223 + format!("Show {dormant} completed or archived")
224 + },
225 + list_action(shared_only, !show_retired),
226 + )));
227 + }
228 +
229 + Ok(Screen::list_detail("Projects", false)
230 + .with(band)
231 + .with(Slot::new("projects-grid", RegionKind::Pane).with(grid(
232 + state,
233 + shared_only,
234 + show_retired,
235 + )?))
236 + .with(Slot::new("projects-detail", RegionKind::Pane).with(Node::text("Nothing selected")))
237 + .into())
238 + }
239 +
240 + /// The grid alone, which is what a filter toggle replaces.
241 + fn list(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
242 + let node = grid(state, flag(&params, "shared"), flag(&params, "retired"))?;
243 + Ok(Response::fragment("projects-grid", node))
244 + }
245 +
246 + /// One project's detail pane.
247 + fn detail(state: &AppState, params: quasi_router::Params) -> Result<Response, RouteError> {
248 + let id = params
249 + .get("id")
250 + .ok_or_else(|| RouteError::not_found("no project id"))?;
251 + // `ProjectId` has no `FromStr`, only `From<Uuid>`, so the parse is the
252 + // uuid crate's. Not worth adding one upstream for a single call site.
253 + let id = goingson_core::ProjectId::from(
254 + uuid::Uuid::parse_str(id).map_err(|_| RouteError::not_found("not a project id"))?,
255 + );
256 +
257 + let project = state
258 + .projects
259 + .get_by_id(id, DESKTOP_USER_ID)
260 + .map_err(|error| RouteError::internal(error.to_string()))?
261 + .ok_or_else(|| RouteError::not_found("no such project"))?;
262 +
263 + let mut slot = Slot::new("projects-detail", RegionKind::Pane)
264 + .with(Node::section(&project.name))
265 + .with(Node::text(format!(
266 + "{} · {}",
267 + type_label(&project.project_type),
268 + status_label(&project.status)
269 + )));
270 +
271 + if !project.description.is_empty() {
272 + slot = slot.with(Node::text(&project.description));
273 + }
274 +
275 + slot = slot.with(Node::Act(
276 + Act::new(
277 + "Delete project",
278 + Action::post(format!("/projects/{}/delete", project.id)),
279 + )
280 + .tone(makeover_layout::Tone::Danger),
281 + ));
282 +
283 + Ok(Response::fragment("projects-detail", Node::Region(slot)))
284 + }
285 +
286 + /// The projects screen's routes.
287 + #[must_use]
288 + pub fn router() -> Router<AppState> {
289 + Router::<AppState>::new()
290 + .get("/projects", index)
291 + .get("/projects/list", list)
292 + .get("/projects/:id", detail)
293 + }
294 +
295 + /// The custom protocol serving the screen inside the app.
296 + ///
297 + /// `quasi://localhost/projects`. The assets come from the same scheme, which is
298 + /// the one thing that differs from the same description served over HTTP.
299 + #[must_use]
300 + pub fn protocol(state: Arc<AppState>) -> quasi_tauri::Protocol<AppState, quasi_webview::Webview> {
301 + quasi_tauri::Protocol::new(
302 + "quasi",
303 + router(),
304 + state,
305 + Arc::new(
306 + quasi_webview::Webview::under("quasi://localhost/static").with_shell(
307 + quasi_webview::Shell::under("quasi://localhost/static")
308 + .styled("/static/styles.css"),
309 + ),
310 + ),
311 + )
312 + }
@@ -1,0 +1,215 @@
1 + //! The projects screen, driven through the router against a real database.
2 + //!
3 + //! No Tauri runtime and no window: a route is a function from state and params
4 + //! to a description, which is the property that makes the router testable at
5 + //! all. What is asserted here is the description, and the markup only where the
6 + //! markup is the point.
7 +
8 + use std::sync::Arc;
9 +
10 + use goingson_core::{NewProject, ProjectStatus, ProjectType};
11 + use quasi_http::Render as _;
12 + use quasi_router::{Method, Params, Response};
13 +
14 + use super::{protocol, router};
15 + use crate::state::{AppState, DESKTOP_USER_ID};
16 +
17 + /// State with the desktop user in place, which is who the handlers read as.
18 + async fn state() -> Arc<AppState> {
19 + let (state, _) = crate::test_utils::setup_test_state().await;
20 + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
21 + state
22 + .db
23 + .conn()
24 + .unwrap()
25 + .execute(
26 + "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
27 + VALUES (?, ?, ?, ?, ?)",
28 + rusqlite::params![
29 + DESKTOP_USER_ID.to_string(),
30 + "desktop@localhost",
31 + "x",
32 + "Desktop User",
33 + &now,
34 + ],
35 + )
36 + .unwrap();
37 + state
38 + }
39 +
40 + fn add(state: &AppState, name: &str, status: ProjectStatus) -> goingson_core::Project {
41 + state
42 + .projects
43 + .create(
44 + DESKTOP_USER_ID,
45 + NewProject {
46 + name: name.to_owned(),
47 + description: String::new(),
48 + project_type: ProjectType::SideProject,
49 + status,
50 + },
51 + )
52 + .unwrap()
53 + }
54 +
55 + fn answer(state: &AppState, path: &str, params: Params) -> Response {
56 + router()
57 + .handle(state, Method::Get, path, params)
58 + .expect("the route answers")
59 + }
60 +
61 + #[tokio::test]
62 + async fn an_empty_database_says_so_rather_than_rendering_nothing() {
63 + let state = state().await;
64 + let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
65 + panic!("the index answers with a screen");
66 + };
67 + let html = quasi_webview::Webview::new().screen(&screen);
68 + assert!(html.contains("No projects yet."));
69 + }
70 +
71 + #[tokio::test]
72 + async fn the_grid_lists_live_projects_and_holds_the_retired_ones_back() {
73 + let state = state().await;
74 + add(&state, "Live one", ProjectStatus::Active);
75 + add(&state, "Paused", ProjectStatus::OnHold);
76 + add(&state, "Finished", ProjectStatus::Completed);
77 +
78 + let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
79 + panic!("a screen");
80 + };
81 + let html = quasi_webview::Webview::new().screen(&screen);
82 +
83 + assert!(html.contains("Live one"));
84 + assert!(html.contains("Paused"));
85 + assert!(!html.contains("Finished"));
86 + // The control that reveals them names how many there are, which is what
87 + // `projects.js` puts on its retired toggle.
88 + assert!(html.contains("Show 1 completed or archived"));
89 + }
90 +
91 + #[tokio::test]
92 + async fn the_retired_toggle_is_an_address_not_a_piece_of_module_state() {
93 + let state = state().await;
94 + add(&state, "Finished", ProjectStatus::Completed);
95 +
96 + let shown = answer(&state, "/projects", Params::new().with("retired", "1"));
97 + let Response::Screen(screen) = shown else {
98 + panic!("a screen");
99 + };
100 + let html = quasi_webview::Webview::new().screen(&screen);
101 + assert!(html.contains("Finished"));
102 + assert!(html.contains("Hide completed and archived"));
103 + }
104 +
105 + #[tokio::test]
106 + async fn a_filter_toggle_swaps_the_grid_alone() {
107 + let state = state().await;
108 + add(&state, "Live one", ProjectStatus::Active);
109 +
110 + let response = answer(&state, "/projects/list", Params::new());
111 + // Decision 7: the response names the region, so the whole document is not
112 + // reflowed to change one pane.
113 + assert_eq!(response.target(), Some("projects-grid"));
114 +
115 + let Response::Fragment { node, .. } = response else {
116 + panic!("a fragment");
117 + };
118 + let html = quasi_webview::Webview::new().fragment(&node);
119 + assert!(html.starts_with("<ul"));
120 + assert!(!html.contains("<html"));
121 + assert!(html.contains("Live one"));
122 + }
123 +
124 + #[tokio::test]
125 + async fn the_shared_filter_appears_only_when_sharing_is_in_play() {
126 + let state = state().await;
127 + add(&state, "Mine", ProjectStatus::Active);
128 +
129 + let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
130 + panic!("a screen");
131 + };
132 + let html = quasi_webview::Webview::new().screen(&screen);
133 + assert!(!html.contains("Shared only"));
134 +
135 + // Asking for the filtered view surfaces the control even with nothing
136 + // shared, so the way back is always on screen. Same rule as `projects.js`.
137 + let Response::Screen(screen) = answer(&state, "/projects", Params::new().with("shared", "1"))
138 + else {
139 + panic!("a screen");
140 + };
141 + let html = quasi_webview::Webview::new().screen(&screen);
142 + assert!(html.contains("Shared only"));
143 + assert!(html.contains("No shared projects yet."));
144 + }
145 +
146 + #[tokio::test]
147 + async fn a_row_carries_both_badges_as_one_trailing_fact() {
148 + // The vocabulary gap, asserted so the workaround is visible rather than
149 + // silently correct: two badges, one `meta`, and the status tone gone. See
150 + // `row_for`'s note.
151 + let state = state().await;
152 + add(&state, "Mine", ProjectStatus::OnHold);
153 +
154 + let Response::Fragment { node, .. } = answer(&state, "/projects/list", Params::new()) else {
155 + panic!("a fragment");
156 + };
157 + let html = quasi_webview::Webview::new().fragment(&node);
158 +
159 + assert!(html.contains("Side Project · On Hold"));
160 + assert!(!html.contains("tone-warning"));
161 + }
162 +
163 + #[tokio::test]
164 + async fn selecting_a_row_addresses_the_detail_pane() {
165 + let state = state().await;
166 + let project = add(&state, "Mine", ProjectStatus::Active);
167 +
168 + let Response::Fragment { node, .. } = answer(&state, "/projects/list", Params::new()) else {
169 + panic!("a fragment");
170 + };
171 + let html = quasi_webview::Webview::new().fragment(&node);
172 + assert!(html.contains(&format!("hx-get=\"/projects/{}\"", project.id)));
173 +
174 + let response = answer(&state, &format!("/projects/{}", project.id), Params::new());
175 + assert_eq!(response.target(), Some("projects-detail"));
176 + }
177 +
178 + #[tokio::test]
179 + async fn a_missing_project_is_a_not_found_rather_than_a_panic() {
180 + let state = state().await;
181 + let error = router()
182 + .handle(
183 + &state,
184 + Method::Get,
185 + &format!("/projects/{}", uuid::Uuid::nil()),
186 + Params::new(),
187 + )
188 + .expect_err("no such project");
189 + assert_eq!(error.class.http_status(), 404);
190 + }
191 +
192 + #[tokio::test]
193 + async fn a_project_name_cannot_become_markup() {
194 + // The reason the description carries text and the renderer owns escaping.
195 + // A project name is user input and goes through the same door as everything
196 + // else.
197 + let state = state().await;
198 + add(&state, "<script>alert(1)</script>", ProjectStatus::Active);
199 +
200 + let Response::Screen(screen) = answer(&state, "/projects", Params::new()) else {
201 + panic!("a screen");
202 + };
203 + let html = quasi_webview::Webview::new().screen(&screen);
204 + assert!(!html.contains("<script>alert"));
205 + assert!(html.contains("&lt;script&gt;"));
206 + }
207 +
208 + #[tokio::test]
209 + async fn the_protocol_serves_the_screen_from_its_own_scheme() {
210 + let state = state().await;
211 + add(&state, "Mine", ProjectStatus::Active);
212 +
213 + let served = protocol(state);
214 + assert_eq!(served.url().scheme(), "quasi");
215 + }