Skip to main content

max / makenotwork

Declare the repository bar, the profile, and four settings screens Thirty declarations across six files: widgets/git_nav, user, project_members, ssh_keys, auth_pages and git_browse. mnw goes from declared 99 to 129 and its remaining count from 74 to 42. The population falls 173 to 171: three shapes deleted against one supplier that counts. Three shapes deleted rather than declared. project_members' hint and bounded assigned public Field fields that Field::placeholder and Field::within have set the whole time, which is the mistake user_support made in wave 4, in the same struct. auth_pages' page took a title and a Slot and handed back a Screen, which is what custom_page::strip was refused for; its three settings are written out at the four screens and the module header carries the one reason worth reading once. git_browse's Frame::opening was the same refusal and got the other answer: it is a -> Vec<Node> shape now and both screens spread it, so the shared three members are said once and nothing moves in the document. An -> Option<_> shape is placed with `for x in shape(..).into_iter()`, which four sites wanted here. The .into_iter() is load-bearing: rustc's for_loops_over_fallibles fires on a bare Option and points at the declare! block, so every consumer would carry a warning nobody can act on. The data gave up two more tuples, which is the rule that a description names what it draws: git_nav's tab strip is a Tab struct and its ranges a Range one. Step, Profile and ItemView gained predicate-and-reader pairs for the Options the form cannot reach into. auth_pages gains a reset_action supplier, forced because Action::with is refused as a production. Second instance after tip::checkout. 2688 lib tests pass, clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-04 14:39 UTC
Signed with PGP, not checked
Commit: 6edfa38a41b93c07616dd65b68fbebee3ef8b84c
Parent: d7cad87
6 files changed, +1174 insertions, -1063 deletions
@@ -32,6 +32,17 @@
32 32 //! described route. Their POSTs are answered by the handlers that always
33 33 //! answered them, into the region the description names.
34 34 //!
35 + //! # Why the four screens each write the same three settings
36 + //!
37 + //! `measured`, `documented` and `indexed false` are the same on all four, and
38 + //! they used to be a `page(title, body)` helper. R2 makes a `-> Screen` shape
39 + //! its single `screen`, so a helper that takes a document and hands it back is
40 + //! the shape `custom_page::strip` was refused for, and the settings are written
41 + //! out. The one worth reading once is the third: none of the four wants to be
42 + //! indexed, because three of them answer only to a reader holding a link or a
43 + //! pending session, and a login form in a search result is a phishing lure with
44 + //! our name on it.
45 + //!
35 46 //! # What each form's answer lands in
36 47 //!
37 48 //! A named region, through [`Action::replacing`], which is documented as the
@@ -59,7 +70,7 @@
59 70 //! reader submits rather than after.
60 71
61 72 use makeover_layout as layout;
62 - use quasi_router::screen::Field;
73 + use quasi_declare::declare;
63 74 use quasi_router::{Action, Document, Node, RegionKind, Screen as Described, Slot};
64 75 use quasi_webview::Webview;
65 76
@@ -178,233 +189,220 @@
178 189 Webview::new().fragment(&Node::Region(slot))
179 190 }
180 191
181 - /// The page body every one of these four shares: one region, at the measure
182 - /// they all run at, carrying whatever the page put in it.
183 - fn page(title: &str, body: Slot) -> Described {
184 - Described::single(title)
185 - .measured(MEASURE)
186 - .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
187 - // None of the four wants to be indexed: three of them answer only to a
188 - // reader holding a link or a pending session, and a login form in a
189 - // search result is a phishing lure with our name on it.
190 - .indexed(false)
191 - .with(body)
192 - }
192 + declare! {
193 + /// An empty region for a route's answer to land in.
194 + ///
195 + /// Empty, and that is the point: it is an address rather than content. The
196 + /// error a full-page POST re-renders goes in through `error` instead,
197 + /// because on that path there is no swap to land anything.
198 + ///
199 + /// An `Option` is an iterator of at most one, and `.into_iter()` is the
200 + /// method step that says so.
201 + shape feedback(id: &str, error: Option<&str>) -> Node;
193 202
194 - /// An empty region for a route's answer to land in.
195 - ///
196 - /// Empty, and that is the point: it is an address rather than content. The
197 - /// error a full-page POST re-renders goes in through `error` instead, because
198 - /// on that path there is no swap to land anything.
199 - fn feedback(id: &str, error: Option<&str>) -> Node {
200 - let mut slot = Slot::new(id, RegionKind::Pane);
201 - if let Some(message) = error {
202 - slot = slot.with(Node::banner(layout::Tone::Danger, message));
203 - }
204 - Node::Region(slot)
205 - }
206 -
207 - /// The link back to the login form, which three of these four carry.
208 - fn back_to_login() -> Node {
209 - Node::Link {
210 - text: "Back to login".to_owned(),
211 - action: Action::get("/login").navigating(),
203 + region id as Pane {
204 + for message in error.into_iter() {
205 + banner layout::Tone::Danger message;
206 + }
212 207 }
213 208 }
214 209
215 - /// `/login`.
216 - ///
217 - /// `sso_enabled` is the testnot.work preview, where there is no local password
218 - /// at all and the whole form is replaced by one link. Two shapes of the same
219 - /// screen rather than two screens, because everything around them -- the
220 - /// wordmark, the measure, the notice -- is the same.
221 - #[must_use]
222 - pub fn login(
223 - prefill: &str,
224 - error: Option<&str>,
225 - notice: Option<&str>,
226 - sso_enabled: bool,
227 - ) -> Described {
228 - let mut body = Slot::new(PAGE_REGION, RegionKind::Pane);
210 + declare! {
211 + /// The link back to the login form, which three of these four carry.
212 + shape back_to_login() -> Node;
229 213
230 - if let Some(note) = notice {
231 - body = body.with(Node::banner(layout::Tone::Info, note));
232 - }
214 + link "Back to login" to get "/login" navigating;
215 + }
233 216
234 - if sso_enabled {
235 - return page(
236 - "Log In - Makenotwork",
237 - body.with(feedback(LOGIN_FEEDBACK, error))
238 - .with(Node::section("Log in"))
239 - .with(Node::text(
240 - "testnot.work is a preview of makenot.work. Sign in with your \
241 - makenot.work account to continue. Your password is only ever \
242 - entered on makenot.work.",
243 - ))
244 - .with(Node::act(
245 - "Sign in with Makenot.work",
246 - Action::get("/sso/login").navigating(),
247 - )),
248 - );
249 - }
217 + declare! {
218 + /// `/login`.
219 + ///
220 + /// `sso_enabled` is the testnot.work preview, where there is no local
221 + /// password at all and the whole form is replaced by one link. Two shapes
222 + /// of the same screen rather than two screens, because everything around
223 + /// them -- the wordmark, the measure, the notice -- is the same. Said as
224 + /// guards rather than as a dispatch, because the two branches offer a
225 + /// different *number* of things and a dispatch arm is one emission.
226 + #[must_use]
227 + pub shape login(
228 + prefill: &str,
229 + error: Option<&str>,
230 + notice: Option<&str>,
231 + sso_enabled: bool,
232 + ) -> Screen;
250 233
251 - let mut login_field = Field::new(layout::FieldKind::Text, "login", "Username or Email")
252 - .required()
253 - // What was typed, so a wrong password does not cost the address as
254 - // well. `login_handler`'s full-page error path has always done this.
255 - .value(prefill);
256 - login_field.placeholder = Some("username or you@example.com".to_owned());
234 + screen single "Log In - Makenotwork" {
235 + measured MEASURE;
236 + documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
237 + indexed false;
238 + // The caret in the box the reader came here to fill. The password box
239 + // is the wrong answer even for somebody whose browser fills the first
240 + // one: a filled box is still where a correction is made. The preview
241 + // has no box at all, so it opens at nothing.
242 + opening_at "login" unless sso_enabled;
257 243
258 - let mut password = Field::new(layout::FieldKind::Secret, "password", "Password").required();
259 - password.placeholder = Some("--------".to_owned());
244 + region PAGE_REGION as Pane {
245 + for note in notice.into_iter() {
246 + banner layout::Tone::Info note;
247 + }
248 + include feedback(LOGIN_FEEDBACK, error);
249 + section "Log in";
260 250
261 - page(
262 - "Log In - Makenotwork",
263 - body.with(feedback(LOGIN_FEEDBACK, error))
264 - .with(Node::section("Log in"))
265 - .with(Node::Form {
266 - action: Action::post("/login").replacing(LOGIN_FEEDBACK),
267 - submit: "Log In".to_owned(),
268 - fields: vec![
269 - login_field,
270 - password,
271 - Field::new(layout::FieldKind::Checkbox, "remember_me", "Remember me"),
272 - ],
273 - })
274 - .with(Node::Link {
275 - text: "Reset Password".to_owned(),
276 - action: Action::get("/forgot-password").navigating(),
277 - })
278 - .with(Node::Link {
279 - text: "Join now".to_owned(),
280 - action: Action::get("/join").navigating(),
281 - })
251 + text "testnot.work is a preview of makenot.work. Sign in with your \
252 + makenot.work account to continue. Your password is only ever \
253 + entered on makenot.work."
254 + when sso_enabled;
255 + act "Sign in with Makenot.work" to get "/sso/login" navigating when sso_enabled;
256 +
257 + form post "/login" replacing LOGIN_FEEDBACK unless sso_enabled {
258 + submit "Log In";
259 + field Text "login" "Username or Email" {
260 + required;
261 + // What was typed, so a wrong password does not cost the
262 + // address as well. `login_handler`'s full-page error path
263 + // has always done this.
264 + value prefill;
265 + placeholder "username or you@example.com";
266 + }
267 + field Secret "password" "Password" {
268 + required;
269 + placeholder "--------";
270 + }
271 + field Checkbox "remember_me" "Remember me";
272 + }
273 + link "Reset Password" to get "/forgot-password" navigating unless sso_enabled;
274 + link "Join now" to get "/join" navigating unless sso_enabled;
282 275 // Hidden until `page-login-2.js` has asked the browser whether it
283 276 // can do WebAuthn. See `PASSKEY`.
284 - .with(Node::Region(Slot::handover(
285 - PASSKEY_REGION,
286 - "the passkey offer",
287 - ))),
288 - )
289 - // The caret in the box the reader came here to fill. The password box is
290 - // the wrong answer even for somebody whose browser fills the first one:
291 - // a filled box is still where a correction is made.
292 - .opening_at("login")
293 - }
294 -
295 - /// `/forgot-password`.
296 - #[must_use]
297 - pub fn forgot_password() -> Described {
298 - let mut email = Field::new(layout::FieldKind::Email, "email", "Email").required();
299 - email.placeholder = Some("you@example.com".to_owned());
300 -
301 - page(
302 - "Reset Password - Makenotwork",
303 - Slot::new(PAGE_REGION, RegionKind::Pane)
304 - .with(feedback(FEEDBACK, None))
305 - .with(Node::section("Reset Password"))
306 - .with(Node::text(
307 - "Enter your email address and we'll send you a link to reset your password.",
308 - ))
309 - .with(Node::Form {
310 - action: Action::post("/forgot-password").replacing(FEEDBACK),
311 - submit: "Send Reset Link".to_owned(),
312 - fields: vec![email],
313 - })
314 - .with(back_to_login()),
315 - )
316 - .opening_at("email")
317 - }
318 -
319 - /// `/reset-password`.
320 - ///
321 - /// `valid` is whether the signed link still resolves. An expired one is a
322 - /// different screen rather than a disabled form: there is nothing to type, and
323 - /// the only useful control is the one that asks for a fresh link.
324 - ///
325 - /// The token rides on the action as a parameter rather than as a hidden field.
326 - /// A hidden input is markup standing in for a value the call already carries,
327 - /// and [`Action::with`] is what the vocabulary has for "send this along".
328 - #[must_use]
329 - pub fn reset_password(valid: bool, token: &str, error: Option<&str>) -> Described {
330 - let mut body = Slot::new(PAGE_REGION, RegionKind::Pane).with(feedback(FEEDBACK, error));
331 -
332 - if valid {
333 - let mut password = Field::new(layout::FieldKind::Secret, "password", "New Password")
334 - .required()
335 - // The rule the server enforces, said before the reader submits
336 - // rather than after. See the module header for why it is not
337 - // `minlength`.
338 - .hint("At least 8 characters");
339 - password.placeholder = Some("--------".to_owned());
340 -
341 - let mut confirm = Field::new(
342 - layout::FieldKind::Secret,
343 - "password_confirm",
344 - "Confirm Password",
345 - )
346 - .required();
347 - confirm.placeholder = Some("--------".to_owned());
348 -
349 - body = body
350 - .with(Node::section("Set New Password"))
351 - .with(Node::text("Enter your new password below."))
352 - .with(Node::Form {
353 - action: Action::post("/reset-password")
354 - .with("token", token)
355 - .replacing(FEEDBACK),
356 - submit: "Set Password".to_owned(),
357 - fields: vec![password, confirm],
358 - });
359 - } else {
360 - body = body
361 - .with(Node::section("Link Expired"))
362 - .with(Node::text(
363 - "This password reset link has expired or is invalid. Please request a new one.",
364 - ))
365 - .with(Node::act(
366 - "Request New Link",
367 - Action::get("/forgot-password").navigating(),
368 - ));
369 - }
370 -
371 - let screen = page("Set New Password - Makenotwork", body.with(back_to_login()));
372 - if valid {
373 - screen.opening_at("password")
374 - } else {
375 - // Nothing to type into, so nothing to open at. A name no field carries
376 - // would be honoured as nothing anyway; saying nothing is the honest
377 - // spelling of it.
378 - screen
277 + region PASSKEY_REGION as RegionKind::handover("the passkey offer")
278 + unless sso_enabled {}
279 + }
379 280 }
380 281 }
381 282
382 - /// `/auth/2fa`.
383 - #[must_use]
384 - pub fn two_factor(error: Option<&str>) -> Described {
385 - let mut code = Field::new(layout::FieldKind::Text, "code", "Verification Code").required();
386 - code.placeholder = Some("000000".to_owned());
387 - // Six digits or an eight-character backup code, which is what the template
388 - // capped it at. A cap the box enforces, unlike the password minimum below:
389 - // `Field` carries a maximum and no minimum.
390 - code.max_length = Some(8);
283 + declare! {
284 + /// `/forgot-password`.
285 + #[must_use]
286 + pub shape forgot_password() -> Screen;
391 287
392 - page(
393 - "Two-Factor Authentication - Makenotwork",
394 - Slot::new(PAGE_REGION, RegionKind::Pane)
395 - .with(feedback(FEEDBACK, error))
396 - .with(Node::section("Two-Factor Authentication"))
397 - .with(Node::text(
398 - "Enter the 6-digit code from your authenticator app, or use a backup code.",
399 - ))
400 - .with(Node::Form {
401 - action: Action::post("/auth/verify-2fa").replacing(FEEDBACK),
402 - submit: "Verify".to_owned(),
403 - fields: vec![code],
404 - })
405 - .with(back_to_login()),
406 - )
407 - .opening_at("code")
288 + screen single "Reset Password - Makenotwork" {
289 + measured MEASURE;
290 + documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
291 + indexed false;
292 + opening_at "email";
293 +
294 + region PAGE_REGION as Pane {
295 + include feedback(FEEDBACK, None);
296 + section "Reset Password";
297 + text "Enter your email address and we'll send you a link to reset your password.";
298 + form post "/forgot-password" replacing FEEDBACK {
299 + submit "Send Reset Link";
300 + field Email "email" "Email" {
301 + required;
302 + placeholder "you@example.com";
303 + }
304 + }
305 + include back_to_login();
306 + }
307 + }
308 + }
309 +
310 + /// Where a new password is sent, with the signed token along for the ride.
311 + ///
312 + /// A supplier because `Action::with(name, value)` is refused as a production: a
313 + /// form has no way to say "and this value too, sometimes", and `doing` is the
314 + /// remedy the form already names for an action it cannot spell. Counted in the
315 + /// population for the reason `tip::checkout` is.
316 + fn reset_action(token: &str) -> Action {
317 + Action::post("/reset-password")
318 + .with("token", token)
319 + .replacing(FEEDBACK)
320 + }
321 +
322 + declare! {
323 + /// `/reset-password`.
324 + ///
325 + /// `valid` is whether the signed link still resolves. An expired one is a
326 + /// different screen rather than a disabled form: there is nothing to type,
327 + /// and the only useful control is the one that asks for a fresh link.
328 + ///
329 + /// The token rides on the action as a parameter rather than as a hidden
330 + /// field. A hidden input is markup standing in for a value the call already
331 + /// carries. See [`reset_action`].
332 + #[must_use]
333 + pub shape reset_password(valid: bool, token: &str, error: Option<&str>) -> Screen;
334 +
335 + screen single "Set New Password - Makenotwork" {
336 + measured MEASURE;
337 + documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
338 + indexed false;
339 + // Nothing to type into on the expired screen, so nothing to open at. A
340 + // name no field carries would be honoured as nothing anyway; saying
341 + // nothing is the honest spelling of it.
342 + opening_at "password" when valid;
343 +
344 + region PAGE_REGION as Pane {
345 + include feedback(FEEDBACK, error);
346 +
347 + section "Set New Password" when valid;
348 + text "Enter your new password below." when valid;
349 + form doing reset_action(token) when valid {
350 + submit "Set Password";
351 + field Secret "password" "New Password" {
352 + required;
353 + // The rule the server enforces, said before the reader
354 + // submits rather than after. See the module header for why
355 + // it is not `minlength`.
356 + hint "At least 8 characters";
357 + placeholder "--------";
358 + }
359 + field Secret "password_confirm" "Confirm Password" {
360 + required;
361 + placeholder "--------";
362 + }
363 + }
364 +
365 + section "Link Expired" unless valid;
366 + text "This password reset link has expired or is invalid. Please request a \
367 + new one."
368 + unless valid;
369 + act "Request New Link" to get "/forgot-password" navigating unless valid;
370 +
371 + include back_to_login();
372 + }
373 + }
374 + }
375 +
376 + declare! {
377 + /// `/auth/2fa`.
378 + #[must_use]
379 + pub shape two_factor(error: Option<&str>) -> Screen;
380 +
381 + screen single "Two-Factor Authentication - Makenotwork" {
382 + measured MEASURE;
383 + documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
384 + indexed false;
385 + opening_at "code";
386 +
387 + region PAGE_REGION as Pane {
388 + include feedback(FEEDBACK, error);
389 + section "Two-Factor Authentication";
390 + text "Enter the 6-digit code from your authenticator app, or use a backup code.";
391 + form post "/auth/verify-2fa" replacing FEEDBACK {
392 + submit "Verify";
393 + field Text "code" "Verification Code" {
394 + required;
395 + placeholder "000000";
396 + // Six digits or an eight-character backup code, which is
397 + // what the template capped it at. A cap the box enforces,
398 + // unlike the password minimum above: `Field` carries a
399 + // maximum and no minimum.
400 + limited_to 8;
401 + }
402 + }
403 + include back_to_login();
404 + }
405 + }
408 406 }
409 407
410 408 #[cfg(test)]
@@ -19,8 +19,9 @@
19 19 //! gap open while the page was still a template; it is gone with the template.
20 20
21 21 use makeover_layout as layout;
22 - use quasi_router::screen::{Cell, Cells, Column, Lexeme, Table};
23 - use quasi_router::{Action, Document, Node, RegionKind, Run, Screen, Slot};
22 + use quasi_declare::declare;
23 + use quasi_router::Document;
24 + use quasi_router::screen::Lexeme;
24 25 use quasi_webview::Webview;
25 26
26 27 use crate::git::{Breadcrumb, RefInfo, TreeItem, TreeItemKind};
@@ -65,19 +66,6 @@
65 66 refs: self.refs,
66 67 }
67 68 }
68 -
69 - /// The identity line, the bar and the trail, which both pages open with.
70 - fn opening(&self) -> Slot {
71 - Slot::new(PAGE_REGION, RegionKind::Pane)
72 - .with(super::widgets::git_nav::heading(self.owner, self.repo))
73 - .with(super::widgets::git_nav::region(&self.nav()))
74 - .with(super::widgets::git_nav::breadcrumb(
75 - self.owner,
76 - self.repo,
77 - self.current_ref,
78 - self.breadcrumbs,
79 - ))
80 - }
81 69 }
82 70
83 71 /// A directory listing.
@@ -105,214 +93,255 @@
105 93 pub notes: &'a [CommitNote],
106 94 }
107 95
108 - /// The directory listing, as a screen.
109 - #[must_use]
110 - pub fn tree_screen(frame: &Frame<'_>, tree: &Tree<'_>) -> Screen {
111 - let page = frame.opening().with(listing(frame, tree));
96 + declare! {
97 + /// The identity line, the bar and the trail, which both pages open with.
98 + ///
99 + /// A panel's members rather than the region itself, because both screens
100 + /// then add their own content to the same region and a shape that takes a
101 + /// container and hands it back is what `custom_page::strip` was refused
102 + /// for. `-> Vec<Node>` is the shape for exactly this: members in order,
103 + /// with nothing wrapping them.
104 + shape opening(frame: &Frame<'_>) -> Vec<Node>;
112 105
113 - Screen::single(format!(
114 - "{} - {} - Git - Makenotwork",
115 - frame.path, frame.repo
116 - ))
117 - .measured(MEASURE)
118 - .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
119 - .with(page)
106 + include super::widgets::git_nav::heading(frame.owner, frame.repo);
107 + include super::widgets::git_nav::region(&frame.nav());
108 + include super::widgets::git_nav::breadcrumb(
109 + frame.owner,
110 + frame.repo,
111 + frame.current_ref,
112 + frame.breadcrumbs
113 + );
120 114 }
121 115
122 - /// The file view, as a screen.
123 - #[must_use]
124 - pub fn file_screen(frame: &Frame<'_>, file: &File<'_>) -> Screen {
125 - let mut page = frame.opening().with(file_header(frame, file));
116 + declare! {
117 + /// The directory listing, as a screen.
118 + #[must_use]
119 + pub shape tree_screen(frame: &Frame<'_>, tree: &Tree<'_>) -> Screen;
126 120
127 - if let Some(notes) = super::widgets::git_notes::region(file.notes) {
128 - page = page.with(notes);
121 + screen single "{frame.path} - {frame.repo} - Git - Makenotwork" {
122 + measured MEASURE;
123 + documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
124 +
125 + region PAGE_REGION as Pane {
126 + for node in opening(frame) {
127 + include node;
128 + }
129 + include listing(frame, tree);
130 + }
129 131 }
130 -
131 - page = page.with(if file.is_binary {
132 - binary(frame, file)
133 - } else {
134 - source(file)
135 - });
136 -
137 - Screen::single(format!(
138 - "{} - {} - Git - Makenotwork",
139 - file.filename, frame.repo
140 - ))
141 - .measured(MEASURE)
142 - .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[])))
143 - .with(page)
144 132 }
145 133
146 - /// What is in this directory.
134 + declare! {
135 + /// The file view, as a screen.
136 + ///
137 + /// The notes region is an `-> Option<_>` shape, and an `Option` is an
138 + /// iterator of at most one: `.into_iter()` is the method step that says so
139 + /// rather than a production for placing an absence.
140 + #[must_use]
141 + pub shape file_screen(frame: &Frame<'_>, file: &File<'_>) -> Screen;
142 +
143 + screen single "{file.filename} - {frame.repo} - Git - Makenotwork" {
144 + measured MEASURE;
145 + documented Document::default().classed(crate::shell::body_class(MEASURE, &[]));
146 +
147 + region PAGE_REGION as Pane {
148 + for node in opening(frame) {
149 + include node;
150 + }
151 + include file_header(frame, file);
152 + for notes in super::widgets::git_notes::region(file.notes).into_iter() {
153 + include notes;
154 + }
155 + given file.is_binary {
156 + true -> include binary(frame, file);
157 + otherwise -> include source(file);
158 + }
159 + }
160 + }
161 + }
162 +
163 + /// What an entry is called in the listing.
147 164 ///
148 - /// `..` is a row like any other, which is what the shipped table made it: it is
149 - /// a place in the tree, and giving it its own control would say it was a
150 - /// different kind of thing.
151 - fn listing(frame: &Frame<'_>, tree: &Tree<'_>) -> Node {
152 - let mut rows = Vec::with_capacity(tree.items.len() + 1);
153 -
154 - // Rows are built here and the columns are declared at the bottom of the
155 - // function, so the cells name their columns: `..` and an entry are two
156 - // separate constructions that would otherwise have to agree with a heading
157 - // list neither of them can see.
158 - if let Some(parent) = tree.parent {
159 - rows.push(
160 - Cells::default()
161 - .at("Name", Cell::new(".."))
162 - .at("Size", Cell::new(String::new()))
163 - .activate(Action::get(frame.tree(parent)).navigating()),
164 - );
165 + /// A trailing slash is how a directory says it is one. The shipped table said
166 + /// it twice -- an icon column holding `/` and the slash on the name -- and one
167 + /// of the two was a column of one character that carried no fact the name did
168 + /// not.
169 + fn entry_name(item: &TreeItem) -> String {
170 + match item.kind {
171 + TreeItemKind::Dir => format!("{}/", item.name),
172 + TreeItemKind::File => item.name.clone(),
165 173 }
166 -
167 - for item in tree.items {
168 - // A trailing slash is how a directory says it is one. The shipped table
169 - // said it twice -- an icon column holding `/` and the slash on the name
170 - // -- and one of the two was a column of one character that carried no
171 - // fact the name did not.
172 - let name = match item.kind {
173 - TreeItemKind::Dir => format!("{}/", item.name),
174 - TreeItemKind::File => item.name.clone(),
175 - };
176 - let path = if frame.path.is_empty() {
177 - item.name.clone()
178 - } else {
179 - format!("{}/{}", frame.path, item.name)
180 - };
181 -
182 - rows.push(
183 - Cells::default()
184 - .at("Name", Cell::new(name))
185 - .at(
186 - "Size",
187 - Cell::new(
188 - item.size
189 - .as_ref()
190 - .map(crate::routes::git::format_size)
191 - .unwrap_or_default(),
192 - ),
193 - )
194 - .activate(Action::get(frame.tree(&path)).navigating()),
195 - );
196 - }
197 -
198 - Table::new(vec![
199 - Column::new("Name")
200 - .width(layout::Width::Fill)
201 - .priority(layout::Priority::Essential),
202 - Column::new("Size")
203 - .width(layout::Width::Content)
204 - .priority(layout::Priority::Secondary),
205 - ])
206 - .rows(rows)
207 - .into()
208 174 }
209 175
210 - /// The strip over a file: how big it is, and the other three ways to read it.
211 - fn file_header(frame: &Frame<'_>, file: &File<'_>) -> Node {
212 - let base = frame.base();
176 + /// Where it is, under the path being browsed.
177 + fn entry_path(frame: &Frame<'_>, item: &TreeItem) -> String {
178 + if frame.path.is_empty() {
179 + item.name.clone()
180 + } else {
181 + format!("{}/{}", frame.path, item.name)
182 + }
183 + }
184 +
185 + /// How big it is, or nothing for a directory.
186 + fn entry_size(item: &TreeItem) -> String {
187 + item.size
188 + .as_ref()
189 + .map(crate::routes::git::format_size)
190 + .unwrap_or_default()
191 + }
192 +
193 + declare! {
194 + /// What is in this directory.
195 + ///
196 + /// `..` is a row like any other, which is what the shipped table made it:
197 + /// it is a place in the tree, and giving it its own control would say it
198 + /// was a different kind of thing. It is drawn only below the root, which is
199 + /// where there is an up, and an `Option` is an iterator of at most one.
200 + ///
201 + /// The rows are two separate constructions and the columns are declared
202 + /// above them, so the cells name their columns rather than counting against
203 + /// a heading list neither construction can see.
204 + shape listing(frame: &Frame<'_>, tree: &Tree<'_>) -> Node;
205 +
206 + table {
207 + column "Name" {
208 + width Fill;
209 + priority Essential;
210 + }
211 + column "Size" {
212 + width Content;
213 + priority Secondary;
214 + }
215 +
216 + for parent in tree.parent.into_iter() {
217 + cells {
218 + cell at "Name" "..";
219 + cell at "Size" "";
220 + activate to get frame.tree(parent) navigating;
221 + }
222 + }
223 +
224 + for item in tree.items.iter() {
225 + cells {
226 + cell at "Name" entry_name(item);
227 + cell at "Size" entry_size(item);
228 + activate to get frame.tree(&entry_path(frame, item)) navigating;
229 + }
230 + }
231 + }
232 + }
233 +
234 + /// How big the file is, and how long, in the strip's one sentence.
235 + fn size_line(file: &File<'_>) -> String {
213 236 let count = file.lines.len();
214 - let meta = if file.is_binary {
237 + if file.is_binary {
215 238 file.file_size.to_owned()
216 239 } else if count == 1 {
217 240 format!("{} - 1 line", file.file_size)
218 241 } else {
219 242 format!("{} - {count} lines", file.file_size)
220 - };
221 -
222 - let mut strip =
223 - Run::new(layout::Fallback::Wrap).beside(Node::text(meta), layout::Priority::Secondary);
224 -
225 - for (label, route) in [
226 - (
227 - "History",
228 - format!("{base}/log/{}/{}", frame.current_ref, file.file_path),
229 - ),
230 - (
231 - "Blame",
232 - format!("{base}/blame/{}/{}", frame.current_ref, file.file_path),
233 - ),
234 - (
235 - "Raw",
236 - format!("{base}/raw/{}/{}", frame.current_ref, file.file_path),
237 - ),
238 - ] {
239 - strip = strip.beside(
240 - Node::Link {
241 - text: label.to_owned(),
242 - action: Action::get(route).navigating(),
243 - },
244 - layout::Priority::Essential,
245 - );
246 243 }
247 -
248 - Node::Region(Slot::new("git-file-header", RegionKind::Group).across(strip))
249 244 }
250 245
251 - /// A file nothing can usefully draw, and the way to get it anyway.
252 - fn binary(frame: &Frame<'_>, file: &File<'_>) -> Node {
253 - Node::Region(
254 - Slot::new("git-binary-notice", RegionKind::Group).across(
255 - Run::new(layout::Fallback::Wrap)
256 - .beside(
257 - Node::text(format!("Binary file ({}).", file.file_size)),
258 - layout::Priority::Essential,
259 - )
260 - .beside(
261 - Node::Link {
262 - text: "Download".to_owned(),
263 - action: Action::get(format!(
264 - "{}/raw/{}/{}",
265 - frame.base(),
266 - frame.current_ref,
267 - file.file_path
268 - ))
269 - .navigating(),
270 - },
271 - layout::Priority::Essential,
272 - ),
273 - ),
274 - )
246 + declare! {
247 + /// The strip over a file: how big it is, and the other three ways to read
248 + /// it.
249 + shape file_header(frame: &Frame<'_>, file: &File<'_>) -> Node;
250 +
251 + let base = frame.base();
252 +
253 + region "git-file-header" as Group {
254 + across Wrap {
255 + beside Secondary text size_line(file);
256 + beside Essential link "History"
257 + to get "{base}/log/{frame.current_ref}/{file.file_path}" navigating;
258 + beside Essential link "Blame"
259 + to get "{base}/blame/{frame.current_ref}/{file.file_path}" navigating;
260 + beside Essential link "Raw"
261 + to get "{base}/raw/{frame.current_ref}/{file.file_path}" navigating;
262 + }
263 + }
275 264 }
276 265
277 - /// The file, one row per line.
266 + declare! {
267 + /// A file nothing can usefully draw, and the way to get it anyway.
268 + shape binary(frame: &Frame<'_>, file: &File<'_>) -> Node;
269 +
270 + let base = frame.base();
271 +
272 + region "git-binary-notice" as Group {
273 + across Wrap {
274 + beside Essential text "Binary file ({file.file_size}).";
275 + beside Essential link "Download"
276 + to get "{base}/raw/{frame.current_ref}/{file.file_path}" navigating;
277 + }
278 + }
279 + }
280 +
281 + /// One line of the file, numbered.
278 282 ///
279 - /// A table rather than a block [`Node::Code`], because the line numbers are
280 - /// what a reader links to: `#L42` is the address of a line, and a block that
281 - /// owned its own lines would have nothing to hang one on.
282 - fn source(file: &File<'_>) -> Node {
283 - // Two columns, two cells, both written here: the row stays positional
284 - // because the headings it answers to are three lines above it.
285 - Table::new(vec![
286 - Column::new("Line")
287 - .width(layout::Width::Content)
288 - .priority(layout::Priority::Secondary),
289 - Column::new("Code")
290 - .width(layout::Width::Fill)
291 - .priority(layout::Priority::Essential),
292 - ])
293 - .rows(file.lines.iter().enumerate().map(|(at, runs)| {
294 - let number = at + 1;
295 - Cells::new([
296 - // The number is a link to itself, which is how a reader
297 - // gets the address of a line into their clipboard.
298 - Cell::default().part(Node::Link {
299 - text: number.to_string(),
300 - action: Action::get(format!("#L{number}")),
301 - }),
302 - Cell::default().part(Node::Code {
303 - runs: runs.clone(),
304 - language: file.language.map(str::to_owned),
305 - inline: true,
306 - }),
307 - ])
308 - // `#L42`, which is what the link above points at and what
309 - // `page-git-file.js` scrolls to. `addressed` and not `identified`:
310 - // the first is a document address and the second is the app's own
311 - // name for the row, and this wrote the second for as long as the
312 - // vocabulary had no way to say the first.
313 - .addressed(format!("L{number}"))
314 - }))
315 - .into()
283 + /// A supplier because the number comes off `enumerate` and one more than an
284 + /// index is arithmetic, which the form admits none of. `Line` is not a
285 + /// vocabulary type, so this stays out of the population.
286 + struct Line {
287 + /// Its number, from one.
288 + number: usize,
289 + /// What the lexer made of it.
290 + runs: Vec<Lexeme>,
291 + }
292 +
293 + /// The file's lines, each knowing which one it is.
294 + fn numbered(file: &File<'_>) -> Vec<Line> {
295 + file.lines
296 + .iter()
297 + .enumerate()
298 + .map(|(at, runs)| Line {
299 + number: at + 1,
300 + runs: runs.clone(),
301 + })
302 + .collect()
303 + }
304 +
305 + declare! {
306 + /// The file, one row per line.
307 + ///
308 + /// A table rather than a block `Node::Code`, because the line numbers are
309 + /// what a reader links to: `#L42` is the address of a line, and a block
310 + /// that owned its own lines would have nothing to hang one on.
311 + ///
312 + /// Two columns, two cells, both written here: the row stays positional
313 + /// because the headings it answers to are five lines above it.
314 + shape source(file: &File<'_>) -> Node;
315 +
316 + table {
317 + column "Line" {
318 + width Content;
319 + priority Secondary;
320 + }
321 + column "Code" {
322 + width Fill;
323 + priority Essential;
324 + }
325 +
326 + for line in numbered(file) {
327 + cells {
328 + // `#L42`, which is what the link below points at and what
329 + // `page-git-file.js` scrolls to. `addressed` and not
330 + // `identified`: the first is a document address and the second
331 + // is the app's own name for the row, and this wrote the second
332 + // for as long as the vocabulary had no way to say the first.
333 + addressed "L{line.number}";
334 + // The number is a link to itself, which is how a reader gets
335 + // the address of a line into their clipboard.
336 + cell "" {
337 + link "{line.number}" to get "#L{line.number}";
338 + }
339 + cell "" {
340 + code line.runs file.language.map(str::to_owned);
341 + }
342 + }
343 + }
344 + }
316 345 }
317 346
318 347 /// The document a browse page is drawn in.
@@ -50,8 +50,9 @@
50 50 //! appears inside a conditional inside a table cell.
51 51
52 52 use makeover_layout as layout;
53 - use quasi_router::screen::{Act, Cell, Cells, Column, Field, Table, Tag};
54 - use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot};
53 + use quasi_declare::declare;
54 + use quasi_router::screen::{Figure, Tag};
55 + use quasi_router::{Method, Request, Response, RouteError};
55 56 use quasi_webview::Webview;
56 57
57 58 use super::Viewer;
@@ -89,194 +90,198 @@
89 90 Webview::new().fragment(&pane(members, owner_split, project, None))
90 91 }
91 92
92 - /// The panel wrapped in its region, optionally carrying something to say.
93 - fn pane(members: &[ProjectMemberRow], owner_split: i64, project: &str, said: Option<&str>) -> Node {
94 - let mut slot = Slot::new(REGION, RegionKind::Pane);
95 - for node in body(members, owner_split, project, said) {
96 - slot = slot.with(node);
93 + declare! {
94 + /// The panel wrapped in its region, optionally carrying something to say.
95 + shape pane(
96 + members: &[ProjectMemberRow],
97 + owner_split: i64,
98 + project: &str,
99 + said: Option<&str>,
100 + ) -> Node;
101 +
102 + region REGION as Pane {
103 + for node in body(members, owner_split, project, said) {
104 + include node;
105 + }
97 106 }
98 - Node::Region(slot)
99 107 }
100 108
101 - /// The panel's contents, in order.
102 - fn body(
103 - members: &[ProjectMemberRow],
104 - owner_split: i64,
105 - project: &str,
106 - said: Option<&str>,
107 - ) -> Vec<Node> {
108 - let mut out = vec![
109 - Node::Link {
110 - text: "Docs: Collaborators".into(),
111 - action: Action::get("/docs/splits").navigating(),
112 - },
113 - Node::section("Members & Payouts"),
114 - Node::text(
115 - "Add collaborators and set their share of revenue. The project owner receives the \
116 - remainder.",
117 - ),
118 - Node::stats([
119 - quasi_router::screen::Figure::new(format!("{owner_split}%"), "Owner's share"),
120 - quasi_router::screen::Figure::new(members.len().to_string(), "Members"),
121 - ]),
109 + /// Whether anybody on the project has yet to accept.
110 + ///
111 + /// A supplier because `any` takes a closure, and it hands back a `bool`, which
112 + /// is the smallest type that works and keeps it out of the population.
113 + fn any_invited(members: &[ProjectMemberRow]) -> bool {
114 + members.iter().any(|member| !member.accepted)
115 + }
116 +
117 + declare! {
118 + /// The panel's contents, in order.
119 + shape body(
120 + members: &[ProjectMemberRow],
121 + owner_split: i64,
122 + project: &str,
123 + said: Option<&str>,
124 + ) -> Vec<Node>;
125 +
126 + link "Docs: Collaborators" to get "/docs/splits" navigating;
127 + section "Members & Payouts";
128 + text "Add collaborators and set their share of revenue. The project owner receives the \
129 + remainder.";
130 + stats [
131 + Figure::new("{owner_split}%", "Owner's share"),
132 + Figure::new(members.len().to_string(), "Members")
122 133 ];
123 134
124 - if let Some(said) = said {
125 - out.push(Node::toast(layout::Tone::Success, said));
135 + // What a write answers with, when it has something to say. An `Option` is
136 + // an iterator of at most one, and `.into_iter()` is the method step that
137 + // says so.
138 + for note in said.into_iter() {
139 + toast layout::Tone::Success note;
126 140 }
127 141
128 - out.push(add_form(project));
142 + include add_form(project);
129 143
130 - if members.is_empty() {
131 - out.push(Node::empty(
132 - "No collaborators yet. Add team members above to share revenue automatically. \
133 - The project owner receives 100% until splits are configured.",
134 - ));
135 - return out;
136 - }
137 -
138 - out.push(table(members, project));
144 + empty "No collaborators yet. Add team members above to share revenue automatically. \
145 + The project owner receives 100% until splits are configured."
146 + when members.is_empty();
147 + include table(members, project) unless members.is_empty();
139 148
140 149 // The template repeated this inside every unaccepted row's split cell. A
141 150 // cell is a run of leaves and has no second line, and saying it once under
142 151 // the table is better anyway: it is one fact about the Invited badge, not a
143 152 // fact about each person wearing it.
144 - if members.iter().any(|m| !m.accepted) {
145 - out.push(Node::text(
146 - "An invited collaborator's percentage is reserved, but earns nothing until they \
147 - accept.",
148 - ));
153 + text "An invited collaborator's percentage is reserved, but earns nothing until they \
154 + accept."
155 + when any_invited(members);
156 + }
157 +
158 + declare! {
159 + /// The Add Member form, behind the disclosure the template gave it.
160 + ///
161 + /// The disclosure shape, exactly: a region showing at most one frame whose
162 + /// single frame is a labelled sub-region. See wiki
163 + /// `mnw-server-conversion-plan`, "How to say a disclosure".
164 + shape add_form(project: &str) -> Node;
165 +
166 + region "project-members-add" as Group {
167 + region "project-members-add-body" as Pane {
168 + label "Add Member";
169 + form post "{NEST}/{project}" awaiting {
170 + submit "Add";
171 + field Text "username" "Username" {
172 + required;
173 + placeholder "Enter username";
174 + }
175 + // The bounds the API validates against, said once here.
176 + field Number "split_percent" "Split %" {
177 + required;
178 + value "50";
179 + within "1" "99";
180 + }
181 + field Text "role" "Role (optional)" {
182 + placeholder "e.g. Producer, Artist, Engineer";
183 + }
184 + }
185 + }
186 + showing_at_most_one None;
149 187 }
150 -
151 - out
152 188 }
153 189
154 - /// The Add Member form, behind the disclosure the template gave it.
155 - fn add_form(project: &str) -> Node {
156 - // The disclosure shape, exactly: a region showing at most one frame whose
157 - // single frame is a labelled sub-region. See wiki
158 - // `mnw-server-conversion-plan`, "How to say a disclosure".
159 - Node::Region(
160 - Slot::new("project-members-add", RegionKind::Group)
161 - .with(Node::Region(
162 - Slot::new("project-members-add-body", RegionKind::Pane)
163 - .label("Add Member")
164 - .with(Node::Form {
165 - action: Action::post(format!("{NEST}/{project}")).awaiting(),
166 - submit: "Add".into(),
167 - fields: vec![
168 - hint(
169 - Field::new(layout::FieldKind::Text, "username", "Username")
170 - .required(),
171 - "Enter username",
172 - ),
173 - bounded(
174 - Field::new(layout::FieldKind::Number, "split_percent", "Split %")
175 - .required()
176 - .value("50"),
177 - 1,
178 - 99,
179 - ),
180 - hint(
181 - Field::new(layout::FieldKind::Text, "role", "Role (optional)"),
182 - "e.g. Producer, Artist, Engineer",
183 - ),
184 - ],
185 - }),
186 - ))
187 - .showing_at_most_one(None),
188 - )
189 - }
190 + declare! {
191 + /// Who is on the project.
192 + ///
193 + /// The columns are declared here and the cells are built in [`row`], a
194 + /// function away. Position is only safe when both lists are in front of you
195 + /// at once, so the cells name their columns and this list is the only place
196 + /// the order is decided.
197 + shape table(members: &[ProjectMemberRow], project: &str) -> Node;
190 198
191 - /// Set a field's placeholder.
192 - fn hint(mut field: Field, text: &str) -> Field {
193 - field.placeholder = Some(text.to_owned());
194 - field
195 - }
199 + table {
200 + column "Member" {
201 + width Fill;
202 + priority Essential;
203 + }
204 + column "Role" {
205 + width Content;
206 + }
207 + column "Split" {
208 + width Content;
209 + }
210 + column "Stripe" {
211 + width Content;
212 + }
213 + column "Added" {
214 + width Content;
215 + priority Optional;
216 + }
217 + // The last column carries no heading, because the button says what it
218 + // does. An empty name is still the name a cell has to match.
219 + column "" {
220 + width Content;
221 + }
196 222
197 - /// Bound a numeric field, matching the `min`/`max` the API validates against.
198 - fn bounded(mut field: Field, min: i32, max: i32) -> Field {
199 - field.min = Some(min.to_string());
200 - field.max = Some(max.to_string());
201 - field
202 - }
203 -
204 - /// Who is on the project.
205 - ///
206 - /// The columns are declared here and the cells are built in [`row`], a function
207 - /// away. Position is only safe when both lists are in front of you at once, so
208 - /// the cells name their columns and this list is the only place the order is
209 - /// decided.
210 - fn table(members: &[ProjectMemberRow], project: &str) -> Node {
211 - Table::new([
212 - Column::new("Member")
213 - .width(layout::Width::Fill)
214 - .priority(layout::Priority::Essential),
215 - Column::new("Role").width(layout::Width::Content),
216 - Column::new("Split").width(layout::Width::Content),
217 - Column::new("Stripe").width(layout::Width::Content),
218 - Column::new("Added")
219 - .width(layout::Width::Content)
220 - .priority(layout::Priority::Optional),
221 - Column::new("").width(layout::Width::Content),
222 - ])
223 - .rows(members.iter().map(|m| row(m, project)))
224 - .into()
225 - }
226 -
227 - /// One collaborator.
228 - fn row(member: &ProjectMemberRow, project: &str) -> Cells {
229 - let shown = member.display_name.as_deref().unwrap_or(&member.username);
230 -
231 - // The template packed the percentage, the badge and the sentence into one
232 - // `<td>`. Three facts, said as three.
233 - let mut split = Cell::new(format!("{}%", member.split_percent));
234 - if !member.accepted {
235 - let mut invited = Tag::badge("Invited");
236 - invited.tone = layout::Tone::Warning;
237 - split = split.token(invited);
223 + for member in members.iter() {
224 + include row(member, project);
225 + }
238 226 }
227 + }
239 228
240 - let mut stripe = Tag::badge(if member.stripe_connected {
229 + /// What to call a collaborator: their display name, or their handle.
230 + fn shown(member: &ProjectMemberRow) -> &str {
231 + member.display_name.as_deref().unwrap_or(&member.username)
232 + }
233 +
234 + /// What the Stripe badge reads.
235 + fn stripe_label(member: &ProjectMemberRow) -> &'static str {
236 + if member.stripe_connected {
241 237 "Connected"
242 238 } else {
243 239 "Not connected"
244 - });
245 - stripe.tone = if member.stripe_connected {
240 + }
241 + }
242 +
243 + /// How it is toned. A collaborator who cannot be paid is a warning.
244 + fn stripe_tone(member: &ProjectMemberRow) -> layout::Tone {
245 + if member.stripe_connected {
246 246 layout::Tone::Success
247 247 } else {
248 248 layout::Tone::Warning
249 - };
249 + }
250 + }
250 251
251 - // Each cell names the column it belongs to. The headings live in [`table`],
252 - // so counting to a position here would be counting against a list this
253 - // function cannot see.
254 - Cells::default()
252 + declare! {
253 + /// One collaborator.
254 + ///
255 + /// Each cell names the column it belongs to. The headings live in
256 + /// [`table`], so counting to a position here would be counting against a
257 + /// list this function cannot see.
258 + shape row(member: &ProjectMemberRow, project: &str) -> Cells;
259 +
260 + cells {
255 261 // The template drew the display name and `@username` as two lines in
256 262 // one `<td>`. A cell is a run of leaves and has no second line, so the
257 263 // handle rides in the value where a reader still sees it.
258 - .at(
259 - "Member",
260 - Cell::new(format!("{shown} (@{})", member.username))
261 - .activate(Action::get(format!("/u/{}", member.username)).navigating()),
262 - )
263 - .at("Role", Cell::new(member.role.clone()))
264 - .at("Split", split)
265 - .at("Stripe", Cell::new(String::new()).token(stripe))
266 - .at("Added", Cell::new(member.added_at.clone()))
267 - // The last column carries no heading, because the button says what it
268 - // does. An empty name is still the name the cell has to match.
269 - .at(
270 - "",
271 - Cell::new(String::new()).act(
272 - Act::new(
273 - "Remove",
274 - Action::delete(format!("{NEST}/{project}/{}", member.user_id)),
275 - )
276 - .tone(layout::Tone::Danger)
277 - .confirm(format!("Remove {shown} from this project?")),
278 - ),
279 - )
264 + cell at "Member" "{shown(member)} (@{member.username})" {
265 + activate to get "/u/{member.username}" navigating;
266 + }
267 + cell at "Role" member.role.clone();
268 + // The template packed the percentage, the badge and the sentence into
269 + // one `<td>`. Three facts, said as three: the sentence moved under the
270 + // table, and the badge is the cell's token.
271 + cell at "Split" "{member.split_percent}%" {
272 + token Tag::badge("Invited").tone(layout::Tone::Warning) unless member.accepted;
273 + }
274 + cell at "Stripe" "" {
275 + token Tag::badge(stripe_label(member)).tone(stripe_tone(member));
276 + }
277 + cell at "Added" member.added_at.clone();
278 + cell at "" "" {
279 + act "Remove" to delete "{NEST}/{project}/{member.user_id}" {
280 + tone Danger;
281 + confirm "Remove {shown(member)} from this project?";
282 + }
283 + }
284 + }
280 285 }
281 286
282 287 /// The project this write is about, and the reader's right to touch it.
@@ -40,9 +40,9 @@
40 40 //! `Email` was, and the format named once as `layout::DATE_FORMAT`. See
41 41 //! [`add_token_form`].
42 42
43 - use makeover_layout as layout;
44 - use quasi_router::screen::{Act, Cell, Cells, Choice, Column, Field, Table};
45 - use quasi_router::{Action, Method, Node, RegionKind, Request, Response, RouteError, Slot};
43 + use quasi_declare::declare;
44 + use quasi_router::screen::Choice;
45 + use quasi_router::{Method, Request, Response, RouteError};
46 46 use quasi_webview::Webview;
47 47
48 48 use super::Viewer;
@@ -196,215 +196,245 @@
196 196 at.map_or_else(|| "Never".to_owned(), |d| d.format("%b %d, %Y").to_string())
197 197 }
198 198
199 - /// Everything inside the settings pane.
200 - ///
201 - /// Split from the handler so a test can build it without a database, which is
202 - /// the same split `quasi_spike` used and the reason the description layer is
203 - /// testable at all: the screen is a value.
204 - fn pane(username: &str, keys: &[KeyView], tokens: &[TokenView], themes: &[ThemeOption]) -> Node {
205 - Node::Region(
206 - Slot::new(REGION, RegionKind::Pane)
207 - .with(Node::section("SSH Keys"))
208 - .with(Node::text(format!(
209 - "Manage SSH keys for git clone and push access. \
210 - Clone URL: git@makenot.work:{username}/{{repo}}.git"
211 - )))
212 - .with(keys_list(keys))
213 - .with(add_key_form())
214 - .with(Node::section("Console theme"))
215 - .with(Node::text(
216 - "Color palette for your terminal dashboard over ssh makenot.work.",
217 - ))
218 - .with(theme_form(themes))
219 - .with(Node::section("Access Tokens (HTTPS)"))
220 - .with(Node::text(format!(
221 - "Personal access tokens for git over HTTPS. Use a token as the password. \
222 - Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git"
223 - )))
224 - .with(tokens_list(tokens))
225 - .with(add_token_form()),
226 - )
227 - }
199 + declare! {
200 + /// Everything inside the settings pane.
201 + ///
202 + /// Split from the handler so a test can build it without a database, which
203 + /// is the same split `quasi_spike` used and the reason the description
204 + /// layer is testable at all: the screen is a value.
205 + shape pane(
206 + username: &str,
207 + keys: &[KeyView],
208 + tokens: &[TokenView],
209 + themes: &[ThemeOption],
210 + ) -> Node;
228 211
229 - /// The registered keys, or the sentence saying there are none.
230 - fn keys_list(keys: &[KeyView]) -> Node {
231 - if keys.is_empty() {
232 - return Node::empty("No SSH keys registered.");
233 - }
234 - // The template's four columns, in its order, the last one the empty
235 - // header its actions sit under.
236 - let columns = vec![
237 - Column::new("Fingerprint")
238 - .width(layout::Width::Fill)
239 - .priority(layout::Priority::Essential),
240 - Column::new("Label").width(layout::Width::Content),
241 - Column::new("Added")
242 - .width(layout::Width::Content)
243 - .priority(layout::Priority::Optional),
244 - Column::new("")
245 - .width(layout::Width::Content)
246 - .priority(layout::Priority::Essential),
247 - ];
248 - // The cells stay positional because the column list is right here to read
249 - // against them: four columns, four cells in every row, and no branch that
250 - // drops one. Naming would buy nothing a reader cannot already see.
251 - //
252 - // No paging described here either: every one of these tables is a whole
253 - // set the handler already counted.
254 - Table::new(columns)
255 - .rows(keys.iter().map(|key| {
256 - Cells::new([
257 - // `19d7602d`. A SHA256 fingerprint is a run of characters a
258 - // reader compares against another one, and a proportional
259 - // face makes that harder than it has to be. Plain rather
260 - // than classified: nothing lexed it and nothing should, so
261 - // what this buys is the monospace and not a colour.
262 - Cell::default().part(Node::Code {
263 - runs: vec![quasi_router::screen::Lexeme::plain(key.fingerprint.clone())],
264 - language: None,
265 - inline: true,
266 - }),
267 - Cell::new(key.label.clone()),
268 - Cell::new(format!("Added {}", key.added)),
269 - Cell::acts([Act::new(
270 - "Remove",
271 - // This screen's own route, under its own nest, so the
272 - // answer is the pane it changed. It addressed the API
273 - // route until 2026-08-11 and swapped a whole Askama
274 - // table into this button; see `remove_key`.
275 - // `awaiting` for the same reason the forms carry it: the
276 - // answer is the whole pane rebuilt, so there is a wait
277 - // with nothing on screen saying so. The confirm gates
278 - // the first press, not the second one after it.
279 - Action::delete(format!("{PATH}/keys/{}", key.id)).awaiting(),
280 - )
281 - // The template asked with hx-confirm. Said here, a terminal
282 - // host asks in its own way and no host can forget to ask.
283 - .confirm("Remove this SSH key?")
284 - .tone(layout::Tone::Danger)]),
285 - ])
286 - }))
287 - .into()
288 - }
212 + region REGION as Pane {
213 + section "SSH Keys";
214 + text "Manage SSH keys for git clone and push access. \
215 + Clone URL: git@makenot.work:{username}/{{repo}}.git";
216 + include keys_list(keys);
217 + include add_key_form();
289 218
290 - /// The add-a-key form.
291 - ///
292 - /// `awaiting` is the double-submit guard: this creates a record, so a second
293 - /// submit while the first is in flight is a duplicate key. It reads as one word
294 - /// here and the renderer locks the submit button from it, which is what
295 - /// `frontend/src/core/loading.ts` was written to do by hand and is losing
296 - /// ground against.
297 - fn add_key_form() -> Node {
298 - Node::Form {
299 - action: Action::post("/api/users/me/ssh-keys").awaiting(),
300 - submit: "Add SSH Key".into(),
301 - fields: vec![
302 - Field::new(layout::FieldKind::Textarea, "public_key", "Public Key")
303 - .required()
304 - .hint(
305 - "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file",
306 - ),
307 - Field::new(layout::FieldKind::Text, "label", "Label"),
308 - ],
219 + section "Console theme";
220 + text "Color palette for your terminal dashboard over ssh makenot.work.";
221 + include theme_form(themes);
222 +
223 + section "Access Tokens (HTTPS)";
224 + text "Personal access tokens for git over HTTPS. Use a token as the password. \
225 + Clone URL: https://<token>@makenot.work/{username}/{{repo}}.git";
226 + include tokens_list(tokens);
227 + include add_token_form();
309 228 }
310 229 }
311 230
312 - /// The console-theme picker.
313 - fn theme_form(themes: &[ThemeOption]) -> Node {
314 - let options: Vec<Choice> = themes
231 + declare! {
232 + /// The registered keys, or the sentence saying there are none.
233 + ///
234 + /// The template's four columns, in its order, the last one the empty header
235 + /// its actions sit under.
236 + ///
237 + /// The cells stay positional because the column list is right here to read
238 + /// against them: four columns, four cells in every row, and no branch that
239 + /// drops one. Naming would buy nothing a reader cannot already see.
240 + ///
241 + /// No paging described here either: every one of these tables is a whole
242 + /// set the handler already counted.
243 + shape keys_list(keys: &[KeyView]) -> Node;
244 +
245 + given keys.is_empty() {
246 + true -> empty "No SSH keys registered.";
247 + otherwise -> table {
248 + column "Fingerprint" {
249 + width Fill;
250 + priority Essential;
251 + }
252 + column "Label" {
253 + width Content;
254 + }
255 + column "Added" {
256 + width Content;
257 + priority Optional;
258 + }
259 + column "" {
260 + width Content;
261 + priority Essential;
262 + }
263 +
264 + for key in keys.iter() {
265 + cells {
266 + // `19d7602d`. A SHA256 fingerprint is a run of characters a
267 + // reader compares against another one, and a proportional
268 + // face makes that harder than it has to be. Plain rather
269 + // than classified: nothing lexed it and nothing should, so
270 + // what this buys is the monospace and not a colour.
271 + cell "" {
272 + literal key.fingerprint.clone();
273 + }
274 + cell key.label.clone();
275 + cell "Added {key.added}";
276 + cell "" {
277 + // This screen's own route, under its own nest, so the
278 + // answer is the pane it changed. It addressed the API
279 + // route until 2026-08-11 and swapped a whole Askama
280 + // table into this button; see `remove_key`. `awaiting`
281 + // for the same reason the forms carry it: the answer is
282 + // the whole pane rebuilt, so there is a wait with
283 + // nothing on screen saying so. The confirm gates the
284 + // first press, not the second one after it.
285 + act "Remove" to delete "{PATH}/keys/{key.id}" awaiting {
286 + // The template asked with hx-confirm. Said here, a
287 + // terminal host asks in its own way and no host can
288 + // forget to ask.
289 + confirm "Remove this SSH key?";
290 + tone Danger;
291 + }
292 + }
293 + }
294 + }
295 + }
296 + }
297 + }
298 +
299 + declare! {
300 + /// The add-a-key form.
301 + ///
302 + /// `awaiting` is the double-submit guard: this creates a record, so a
303 + /// second submit while the first is in flight is a duplicate key. It reads
304 + /// as one word here and the renderer locks the submit button from it, which
305 + /// is what `frontend/src/core/loading.ts` was written to do by hand and is
306 + /// losing ground against.
307 + shape add_key_form() -> Node;
308 +
309 + form post "/api/users/me/ssh-keys" awaiting {
310 + submit "Add SSH Key";
311 + field Textarea "public_key" "Public Key" {
312 + required;
313 + hint "Paste the contents of your ~/.ssh/id_ed25519.pub or similar public key file";
314 + }
315 + field Text "label" "Label";
316 + }
317 + }
318 +
319 + /// Which theme the reader is on, or nothing.
320 + ///
321 + /// A supplier because `find` takes a closure. It hands back a `String`, which
322 + /// is the smallest type that works and keeps it out of the population, and
323 + /// [`a_theme_is_chosen`] is the predicate that decides whether it is asked for.
324 + fn chosen_theme(themes: &[ThemeOption]) -> String {
325 + themes
315 326 .iter()
316 - .map(|t| Choice::new(t.id.clone(), t.name.clone()))
317 - .collect();
318 - let mut field = Field::select("theme_id", "Console theme", options).hint(
319 - "Following the terminal picks a light or dark palette from what your terminal reports. \
320 - Separate from your profile theme, which is what visitors see.",
321 - );
322 - if let Some(chosen) = themes.iter().find(|t| t.selected) {
323 - field = field.value(chosen.id.clone());
324 - }
325 - Node::Form {
326 - // A PUT, so a second submit overwrites rather than duplicating. Marked
327 - // anyway: the wait is real and the reader has no other way to tell the
328 - // save landed from the save being slow.
329 - action: Action::put("/api/users/me/console-theme").awaiting(),
330 - submit: "Save Theme".into(),
331 - fields: vec![field],
327 + .find(|theme| theme.selected)
328 + .map(|theme| theme.id.clone())
329 + .unwrap_or_default()
330 + }
331 +
332 + /// Whether any of them is marked.
333 + fn a_theme_is_chosen(themes: &[ThemeOption]) -> bool {
334 + themes.iter().any(|theme| theme.selected)
335 + }
336 +
337 + declare! {
338 + /// The console-theme picker.
339 + shape theme_form(themes: &[ThemeOption]) -> Node;
340 +
341 + // A PUT, so a second submit overwrites rather than duplicating. Marked
342 + // anyway: the wait is real and the reader has no other way to tell the save
343 + // landed from the save being slow.
344 + form put "/api/users/me/console-theme" awaiting {
345 + submit "Save Theme";
346 + field Select "theme_id" "Console theme" {
347 + hint "Following the terminal picks a light or dark palette from what your \
348 + terminal reports. Separate from your profile theme, which is what \
349 + visitors see.";
350 + for theme in themes.iter() {
351 + option Choice::new(theme.id.clone(), theme.name.clone());
352 + }
353 + value chosen_theme(themes) when a_theme_is_chosen(themes);
354 + }
332 355 }
333 356 }
334 357
335 - /// The issued tokens, or the sentence saying there are none.
336 - fn tokens_list(tokens: &[TokenView]) -> Node {
337 - if tokens.is_empty() {
338 - return Node::empty("No access tokens.");
358 + declare! {
359 + /// The issued tokens, or the sentence saying there are none.
360 + ///
361 + /// The four independent facts the list version had to run together into one
362 + /// `meta` string, back in their own columns. This is the table that lost the
363 + /// most by being a list: name, scope, expiry and last use are read down the
364 + /// column, which is what a table is for.
365 + ///
366 + /// Positional for the same reason the key table is: the columns are in this
367 + /// declaration, every row carries all five cells, and nothing branches.
368 + ///
369 + /// No paging described here either: every one of these tables is a whole
370 + /// set the handler already counted.
371 + shape tokens_list(tokens: &[TokenView]) -> Node;
372 +
373 + given tokens.is_empty() {
374 + true -> empty "No access tokens.";
375 + otherwise -> table {
376 + column "Name" {
377 + width Fill;
378 + priority Essential;
379 + }
380 + column "Scope" {
381 + width Content;
382 + }
383 + column "Expires" {
384 + width Content;
385 + priority Optional;
386 + }
387 + column "Last used" {
388 + width Content;
389 + priority Optional;
390 + }
391 + column "" {
392 + width Content;
393 + priority Essential;
394 + }
395 +
396 + for token in tokens.iter() {
397 + cells {
398 + cell token.name.clone();
399 + cell token.scope;
400 + cell token.expires.clone();
401 + cell token.last_used.clone();
402 + cell "" {
403 + act "Revoke" to delete "{PATH}/tokens/{token.id}" awaiting {
404 + confirm "Revoke this token?";
405 + tone Danger;
406 + }
407 + }
408 + }
409 + }
410 + }
339 411 }
340 - // The four independent facts the list version had to run together into
341 - // one `meta` string, back in their own columns. This is the table that
342 - // lost the most by being a list: name, scope, expiry and last use are
343 - // read down the column, which is what a table is for.
344 - let columns = vec![
345 - Column::new("Name")
346 - .width(layout::Width::Fill)
347 - .priority(layout::Priority::Essential),
348 - Column::new("Scope").width(layout::Width::Content),
349 - Column::new("Expires")
350 - .width(layout::Width::Content)
351 - .priority(layout::Priority::Optional),
352 - Column::new("Last used")
353 - .width(layout::Width::Content)
354 - .priority(layout::Priority::Optional),
355 - Column::new("")
356 - .width(layout::Width::Content)
357 - .priority(layout::Priority::Essential),
358 - ];
359 - // Positional for the same reason the key table is: the columns are in
360 - // this function, every row carries all five cells, and nothing branches.
361 - //
362 - // No paging described here either: every one of these tables is a whole
363 - // set the handler already counted.
364 - Table::new(columns)
365 - .rows(tokens.iter().map(|token| {
366 - Cells::new([
367 - Cell::new(token.name.clone()),
368 - Cell::new(token.scope),
369 - Cell::new(token.expires.clone()),
370 - Cell::new(token.last_used.clone()),
371 - Cell::acts([Act::new(
372 - "Revoke",
373 - Action::delete(format!("{PATH}/tokens/{}", token.id)).awaiting(),
374 - )
375 - .confirm("Revoke this token?")
376 - .tone(layout::Tone::Danger)]),
377 - ])
378 - }))
379 - .into()
380 412 }
381 413
382 - /// The mint-a-token form.
383 - ///
384 - /// `expires_on` was a `Text` field carrying a "YYYY-MM-DD" hint, against an
385 - /// Askama form that spelled it `<input type="date">`: no native picker, no
386 - /// platform validation, and the hint doing both jobs in prose. That was a
387 - /// vocabulary gap rather than a choice, and `layout::FieldKind::Date` closed it
388 - /// at makeover-layout 0.15.0. It did become one word, and the hint came out with
389 - /// it: the format is the description's now, `layout::DATE_FORMAT`, so saying it
390 - /// again here would be a second place for it to drift.
391 - fn add_token_form() -> Node {
392 - Node::Form {
393 - // Creates a record, and unlike an SSH key the answer is a secret shown
394 - // once. Two of these from one impatient double-press is two tokens, one
395 - // of which the creator never sees and cannot recognise later.
396 - action: Action::post("/api/users/me/git-tokens").awaiting(),
397 - submit: "Create Token".into(),
398 - fields: vec![
399 - Field::new(layout::FieldKind::Text, "name", "Name").required(),
400 - Field::new(layout::FieldKind::Date, "expires_on", "Expires (optional)")
401 - .hint("Leave blank for a token that does not expire."),
402 - Field::new(
403 - layout::FieldKind::Checkbox,
404 - "can_push",
405 - "Allow push (write access)",
406 - ),
407 - ],
414 + declare! {
415 + /// The mint-a-token form.
416 + ///
417 + /// `expires_on` was a `Text` field carrying a "YYYY-MM-DD" hint, against an
418 + /// Askama form that spelled it `<input type="date">`: no native picker, no
419 + /// platform validation, and the hint doing both jobs in prose. That was a
420 + /// vocabulary gap rather than a choice, and `layout::FieldKind::Date` closed
421 + /// it at makeover-layout 0.15.0. It did become one word, and the hint came
422 + /// out with it: the format is the description's now, `layout::DATE_FORMAT`,
423 + /// so saying it again here would be a second place for it to drift.
424 + shape add_token_form() -> Node;
425 +
426 + // Creates a record, and unlike an SSH key the answer is a secret shown
427 + // once. Two of these from one impatient double-press is two tokens, one of
428 + // which the creator never sees and cannot recognise later.
429 + form post "/api/users/me/git-tokens" awaiting {
430 + submit "Create Token";
431 + field Text "name" "Name" {
432 + required;
433 + }
434 + field Date "expires_on" "Expires (optional)" {
435 + hint "Leave blank for a token that does not expire.";
436 + }
437 + field Checkbox "can_push" "Allow push (write access)";
408 438 }
409 439 }
410 440
@@ -424,6 +454,7 @@
424 454 mod tests {
425 455 use super::*;
426 456 use quasi_axum::Serves;
457 + use quasi_router::Node;
427 458
428 459 fn key(id: &str, fingerprint: &str) -> KeyView {
429 460 KeyView {
@@ -35,10 +35,8 @@
35 35 //! from fields the screen holds.
36 36
37 37 use makeover_layout as layout;
38 - use quasi_router::screen::Row;
39 - use quasi_router::{
40 - Act, Action, Document, Feed, FeedKind, Node, RegionKind, Screen as Described, Slot,
41 - };
38 + use quasi_declare::declare;
39 + use quasi_router::{Document, Feed, FeedKind};
42 40 use quasi_webview::Webview;
43 41
44 42 use crate::types::{Collection, CustomLink, Project, User};
@@ -106,6 +104,43 @@
106 104 .map_or_else(|| "Creator on Makenotwork".to_owned(), str::to_owned)
107 105 }
108 106
107 + /// Whether the creator has written anything about themselves.
108 + ///
109 + /// A predicate rather than an `Option` the description reaches into, which
110 + /// is the call `super::embeds::ItemView::has_cover` records: the form has
111 + /// no binding pattern, and whitespace is not a bio.
112 + fn has_bio(&self) -> bool {
113 + !self.bio().trim().is_empty()
114 + }
115 +
116 + /// What they wrote, or nothing. R9: the paragraph is built whether or not
117 + /// [`Self::has_bio`] places it.
118 + fn bio(&self) -> &str {
119 + self.user.bio.as_deref().unwrap_or_default()
120 + }
121 +
122 + /// Whether this reader is offered the follow control.
123 + ///
124 + /// Following is a write, so it needs a session, and a creator does not
125 + /// follow themselves. Both were the template's conditions, and the count
126 + /// stands in where the control does not.
127 + fn can_follow(&self) -> bool {
128 + self.signed_in && !self.is_own_profile
129 + }
130 +
131 + /// What a tip on this page is about.
132 + ///
133 + /// A method rather than the literal the caller used to write inline: a
134 + /// `Type { .. }` aggregate is the form's hard limit, and the offer is a
135 + /// fact about the profile anyway.
136 + fn tip_offer(&self) -> super::tip::Offer<'_> {
137 + super::tip::Offer {
138 + creator_id: self.user_id,
139 + project_id: None,
140 + signed_in: self.signed_in,
141 + }
142 + }
143 +
109 144 /// The picture a link preview shows: their avatar, or the site's card.
110 145 fn image(&self) -> String {
111 146 self.user
@@ -136,134 +171,110 @@
136 171 }
137 172 }
138 173
139 - /// The whole document.
140 - #[must_use]
141 - pub fn screen(profile: &Profile<'_>, theme_css: &str) -> Described {
142 - let user = profile.user;
143 - let name = user.display_name_or_username();
144 - let feed = feed_path(&user.username);
174 + declare! {
175 + /// The whole document.
176 + ///
177 + /// The creator's Tier 0 sheet rides on `styled`, written last in the head
178 + /// and outside the layer statement, which is the property the hand-written
179 + /// `<style id="creator-theme">` block existed to hold.
180 + #[must_use]
181 + pub shape screen(profile: &Profile<'_>, theme_css: &str) -> Screen;
145 182
146 - let mut page = Slot::new(PAGE_REGION, RegionKind::Pane);
183 + let name = profile.user.display_name_or_username();
184 + let feed = feed_path(&profile.user.username);
147 185
148 - // A creator on a break, said before anything they published: a reader
149 - // about to buy is the one who needs it.
150 - if profile.paused {
151 - page = page.with(Node::banner(
152 - layout::Tone::Info,
153 - "This creator is currently on break. Existing purchases remain accessible.",
154 - ));
186 + screen single "{name} - Makenotwork" {
187 + measured MEASURE;
188 + documented Document::default()
189 + .classed(crate::shell::body_class(MEASURE, &["user-page"]))
190 + .styled(theme_css.to_owned());
191 + summarised profile.summary();
192 + illustrated profile.image();
193 + canonical_at profile.canonical();
194 + about quasi_router::SocialKind::Profile;
195 + syndicating Feed::new(FeedKind::Rss, "{name} - RSS Feed", feed);
196 +
197 + region PAGE_REGION as Pane {
198 + // A creator on a break, said before anything they published: a
199 + // reader about to buy is the one who needs it.
200 + banner layout::Tone::Info
201 + "This creator is currently on break. Existing purchases remain accessible."
202 + when profile.paused;
203 +
204 + page name;
205 + text profile.user.username.clone();
206 + text profile.bio() when profile.has_bio();
207 +
208 + act "Edit Profile" to get "/dashboard?tab=settings" navigating
209 + when profile.is_own_profile;
210 +
211 + include super::follow::control(
212 + "user",
213 + profile.user_id,
214 + profile.is_following,
215 + profile.follower_count
216 + ) when profile.can_follow();
217 + // An `-> Option<_>` shape is placed with a loop, because an
218 + // `Option` is an iterator of at most one. `.into_iter()` is a
219 + // method step the hole grammar already admits, and it is what says
220 + // at-most-one was meant rather than a fallible iterated by
221 + // accident. The guard is the other half of the control's: a reader
222 + // offered the control is not also told the count.
223 + for counted in super::follow::count_only(profile.follower_count).into_iter() {
224 + include counted unless profile.can_follow();
225 + }
226 +
227 + include super::tip::control(&profile.tip_offer()) when profile.tips_enabled;
228 +
229 + act "RSS Feed" to get feed.clone() navigating;
230 + // Already sayable: `copying` is "the value this press puts on the
231 + // clipboard", which is the whole of what `data-copy-link` meant.
232 + // The value is absolute, because a relative path on a clipboard is
233 + // not an address anybody can paste anywhere.
234 + act "Copy link" to local {
235 + copying profile.canonical();
236 + }
237 +
238 + list {
239 + for link in profile.custom_links.iter() {
240 + row link.title.clone() {
241 + secondary link.description.clone();
242 + // Somewhere else on the web, and the reader is expected
243 + // to come back: external rather than leaving. The
244 + // template said the same thing with `target="_blank"
245 + // rel="ugc nofollow noopener"`.
246 + activate to external link.url.clone();
247 + }
248 + }
249 + } unless profile.custom_links.is_empty();
250 +
251 + section "Projects" unless profile.projects.is_empty();
252 + list {
253 + for project in profile.projects.iter() {
254 + row project.title.clone() {
255 + meta "{project.project_type} - {project.item_count} items";
256 + activate to get "/p/{project.slug}" navigating;
257 + }
258 + }
259 + } unless profile.projects.is_empty();
260 +
261 + section "Collections" unless profile.collections.is_empty();
262 + list {
263 + for collection in profile.collections.iter() {
264 + row collection.title.clone() {
265 + meta "{collection.item_count} items";
266 + activate to get
267 + "/c/{profile.user.username}/{collection.slug}" navigating;
268 + }
269 + }
270 + } unless profile.collections.is_empty();
271 +
272 + // The footer attribution. Content rather than decoration, for the
273 + // reason `project_blog` keeps its: it is where a reader on a
274 + // creator's page finds out whose platform they are on.
275 + link "Powered by Makenot.work" to get "/" navigating;
276 + }
155 277 }
156 -
157 - page = page
158 - .with(Node::page(name.to_owned()))
159 - .with(Node::text(user.username.clone()));
160 -
161 - if let Some(bio) = user.bio.as_deref().filter(|bio| !bio.trim().is_empty()) {
162 - page = page.with(Node::text(bio.to_owned()));
163 - }
164 -
165 - if profile.is_own_profile {
166 - page = page.with(Node::act(
167 - "Edit Profile",
168 - Action::get("/dashboard?tab=settings").navigating(),
169 - ));
170 - }
171 -
172 - // Following is a write, so it needs a session, and a creator does not
173 - // follow themselves. Both were the template's conditions.
174 - if profile.signed_in && !profile.is_own_profile {
175 - page = page.with(super::follow::control(
176 - "user",
177 - profile.user_id,
178 - profile.is_following,
179 - profile.follower_count,
180 - ));
181 - } else if let Some(count) = super::follow::count_only(profile.follower_count) {
182 - page = page.with(count);
183 - }
184 -
185 - if profile.tips_enabled {
186 - page = page.with(super::tip::control(&super::tip::Offer {
187 - creator_id: profile.user_id,
188 - project_id: None,
189 - signed_in: profile.signed_in,
190 - }));
191 - }
192 -
193 - page = page
194 - .with(Node::act(
195 - "RSS Feed",
196 - Action::get(feed.clone()).navigating(),
197 - ))
198 - // Already sayable: `Act::copies` is "the value this press puts on the
199 - // clipboard", which is the whole of what `data-copy-link` meant. The
200 - // value is absolute, because a relative path on a clipboard is not an
201 - // address anybody can paste anywhere.
202 - .with(Node::Act(
203 - Act::new("Copy link", Action::local()).copying(profile.canonical()),
204 - ));
205 -
206 - if !profile.custom_links.is_empty() {
207 - page = page.with(Node::list(profile.custom_links.iter().map(|link| {
208 - Row::new(link.title.clone())
209 - .secondary(link.description.clone())
210 - // Somewhere else on the web, and the reader is expected to come
211 - // back: `External` rather than `Leaving`. The template said the
212 - // same thing with `target="_blank" rel="ugc nofollow noopener"`.
213 - .activate(Action::external(link.url.clone()))
214 - })));
215 - }
216 -
217 - if !profile.projects.is_empty() {
218 - page = page
219 - .with(Node::section("Projects"))
220 - .with(Node::list(profile.projects.iter().map(|project| {
221 - Row::new(project.title.clone())
222 - .meta(format!(
223 - "{} - {} items",
224 - project.project_type, project.item_count
225 - ))
226 - .activate(Action::get(format!("/p/{}", project.slug)).navigating())
227 - })));
228 - }
229 -
230 - if !profile.collections.is_empty() {
231 - page = page.with(Node::section("Collections")).with(Node::list(
232 - profile.collections.iter().map(|collection| {
233 - Row::new(collection.title.clone())
234 - .meta(format!("{} items", collection.item_count))
235 - .activate(
236 - Action::get(format!("/c/{}/{}", user.username, collection.slug))
237 - .navigating(),
238 - )
239 - }),
240 - ));
241 - }
242 -
243 - // The footer attribution. Content rather than decoration, for the reason
244 - // `project_blog` keeps its: it is where a reader on a creator's page finds
245 - // out whose platform they are on.
246 - page = page.with(Node::Link {
247 - text: "Powered by Makenot.work".to_owned(),
248 - action: Action::get("/").navigating(),
249 - });
250 -
251 - Described::single(format!("{name} - Makenotwork"))
252 - .measured(MEASURE)
253 - .documented(
254 - Document::default()
255 - .classed(crate::shell::body_class(MEASURE, &["user-page"]))
256 - // Tier 0: the creator's own primitive-layer override, written
257 - // last in the head and unlayered, which is where the
258 - // hand-written block put it.
259 - .styled(theme_css.to_owned()),
260 - )
261 - .summarised(profile.summary())
262 - .illustrated(profile.image())
263 - .canonical_at(profile.canonical())
264 - .about(quasi_router::SocialKind::Profile)
265 - .syndicating(Feed::new(FeedKind::Rss, format!("{name} - RSS Feed"), feed))
266 - .with(page)
267 278 }
268 279
269 280 /// The document this screen is drawn in.
@@ -41,9 +41,9 @@
41 41 //! parameter and answers a redirect to the canonical path. One address for the
42 42 //! content, one address for the control, and the reader lands on the first.
43 43
44 - use makeover_layout as layout;
45 - use quasi_router::screen::{Field, Row};
46 - use quasi_router::{Action, Node, RegionKind, Run, Slot};
44 + use quasi_declare::declare;
45 + use quasi_router::Action;
46 + use quasi_router::screen::Choice;
47 47
48 48 use crate::git::{Breadcrumb, RefInfo};
49 49
@@ -75,32 +75,127 @@
75 75 pub refs: &'a [RefInfo],
76 76 }
77 77
78 + /// One place this repository has, as the strip draws it.
79 + ///
80 + /// Named members rather than a tuple, for `policy`'s reason: a description
81 + /// names what it draws, and `.1` is not a name. Whether the reader is here is
82 + /// decided in [`Nav::tabs`] rather than at the row, because `commit` is one of
83 + /// the commits Commits lists and the reader is in the same place either way --
84 + /// a fact about the set of tabs, not about one of them.
85 + struct Tab {
86 + /// What the tab is called, count included where there is one.
87 + label: String,
88 + /// Where it goes.
89 + route: String,
90 + /// Whether the reader is there.
91 + current: bool,
92 + }
93 +
78 94 impl Nav<'_> {
79 95 /// The repository's own prefix, which every address here is under.
80 96 fn base(&self) -> String {
81 97 format!("/git/{}/{}", self.owner, self.repo)
82 98 }
83 - }
84 99
85 - /// The bar, for a screen that owns its whole document.
86 - ///
87 - /// The chooser is drawn only where there is something to choose. Issues,
88 - /// a single issue and settings are facts about the repository rather than
89 - /// about a ref, and their handlers never open the repository at all, so
90 - /// offering a ref there would mean walking the object database for a control
91 - /// that changes nothing on the page. They pass no refs and get the strip.
92 - #[must_use]
93 - pub fn region(nav: &Nav<'_>) -> Node {
94 - // The bar is one row that wraps below the breakpoint. Nothing in it may
95 - // drop: the chooser and the strip are the only ways off this page.
96 - let mut bar = Run::new(layout::Fallback::Wrap);
97 -
98 - if !nav.refs.is_empty() {
99 - bar = bar.beside(Node::field(chooser(nav)), layout::Priority::Essential);
100 + /// Where the chooser's answer goes. See the module header.
101 + fn ref_path(&self) -> String {
102 + format!("{}/ref", self.base())
100 103 }
101 104
102 - let bar = bar.beside(strip(nav), layout::Priority::Essential);
103 - Node::Region(Slot::new(REGION, RegionKind::Group).across(bar))
105 + /// Whether there is anything to choose between.
106 + fn has_refs(&self) -> bool {
107 + !self.refs.is_empty()
108 + }
109 +
110 + /// The six places this repository has, in the order the strip draws them.
111 + fn tabs(&self) -> Vec<Tab> {
112 + let base = self.base();
113 + let current =
114 + |key: &str| self.active_tab == key || (key == "commits" && self.active_tab == "commit");
115 +
116 + let mut tabs = vec![
117 + Tab {
118 + label: "Files".to_owned(),
119 + route: format!("{base}/tree/{}", self.current_ref),
120 + current: current("files"),
121 + },
122 + Tab {
123 + label: "Commits".to_owned(),
124 + route: format!("{base}/commits/{}", self.current_ref),
125 + current: current("commits"),
126 + },
127 + Tab {
128 + label: "Tags".to_owned(),
129 + route: format!("{base}/tags"),
130 + current: current("tags"),
131 + },
132 + Tab {
133 + label: "Notes".to_owned(),
134 + route: format!("{base}/notes"),
135 + current: current("notes"),
136 + },
137 + Tab {
138 + // The count is part of the name a reader reads, and there is no
139 + // second thing to say: a tab reading `Issues` when there are
140 + // none and `Issues (3)` when there are three is one label.
141 + label: if self.open_issue_count > 0 {
142 + format!("Issues ({})", self.open_issue_count)
143 + } else {
144 + "Issues".to_owned()
145 + },
146 + route: format!("{base}/issues"),
147 + current: current("issues"),
148 + },
149 + ];
150 + if self.is_owner {
151 + tabs.push(Tab {
152 + label: "Settings".to_owned(),
153 + route: format!("{base}/settings"),
154 + current: current("settings"),
155 + });
156 + }
157 + tabs
158 + }
159 + }
160 +
161 + /// What a ref is called in the chooser.
162 + ///
163 + /// A tag reads as `tag: v1.2.0` and a branch as its bare name, which is the
164 + /// distinction the shipped `<option>` drew and the only one the list makes. The
165 + /// submitted value is the ref name either way, so the label carrying the word
166 + /// costs nothing.
167 + ///
168 + /// A supplier handing back a `String` rather than a `Choice`, which is the
169 + /// smallest type that works and keeps it out of the population.
170 + fn ref_label(info: &RefInfo) -> String {
171 + if info.is_branch {
172 + info.name.clone()
173 + } else {
174 + format!("tag: {}", info.name)
175 + }
176 + }
177 +
178 + declare! {
179 + /// The bar, for a screen that owns its whole document.
180 + ///
181 + /// One row that wraps below the breakpoint. Nothing in it may drop: the
182 + /// chooser and the strip are the only ways off this page.
183 + ///
184 + /// The chooser is drawn only where there is something to choose. Issues, a
185 + /// single issue and settings are facts about the repository rather than
186 + /// about a ref, and their handlers never open the repository at all, so
187 + /// offering a ref there would mean walking the object database for a
188 + /// control that changes nothing on the page. They pass no refs and get the
189 + /// strip.
190 + #[must_use]
191 + pub shape region(nav: &Nav<'_>) -> Node;
192 +
193 + region REGION as Group {
194 + across Wrap {
195 + beside Essential include chooser(nav) when nav.has_refs();
196 + beside Essential include strip(nav);
197 + }
198 + }
104 199 }
105 200
106 201 /// The markup, for an Askama template to drop in.
@@ -112,154 +207,96 @@
112 207 quasi_webview::Webview::new().fragment(&region(nav))
113 208 }
114 209
115 - /// The branch and tag chooser.
116 - ///
117 - /// A tag reads as `tag: v1.2.0` and a branch as its bare name, which is the
118 - /// distinction the shipped `<option>` drew and the only one the list makes. The
119 - /// submitted value is the ref name either way, so the label carrying the word
120 - /// costs nothing: [`Choice`](quasi_router::screen::Choice) has been two strings
121 - /// since it existed for exactly this.
122 - fn chooser(nav: &Nav<'_>) -> Field {
123 - use quasi_router::screen::Choice;
210 + declare! {
211 + /// The branch and tag chooser.
212 + ///
213 + /// One question with many answers, and a list of two hundred branches drawn
214 + /// as links would be the page. Its answer goes somewhere, so it writes a
215 + /// navigating read to the join route, which redirects to the canonical tree
216 + /// address. See the module header.
217 + ///
218 + /// The options accrete one per ref, which is what `Field::option` is for:
219 + /// `Field::options` takes the whole list and the form has no expression to
220 + /// hold one in.
221 + shape chooser(nav: &Nav<'_>) -> Field;
124 222
125 - let options = nav
126 - .refs
127 - .iter()
128 - .map(|r| {
129 - if r.is_branch {
130 - Choice::plain(&r.name)
131 - } else {
132 - Choice::new(&r.name, format!("tag: {}", r.name))
133 - }
134 - })
135 - .collect();
136 -
137 - Field::select(CHOSEN, "Branch or tag", options)
138 - .value(nav.current_ref)
139 - .writes(Action::get(format!("{}/ref", nav.base())).navigating())
140 - }
141 -
142 - /// The six places this repository has.
143 - fn strip(nav: &Nav<'_>) -> Node {
144 - let base = nav.base();
145 -
146 - // `commit` is a single commit, which is one of the commits the Commits tab
147 - // lists: the shipped markup marked the tab for both and the reader is in
148 - // the same place either way.
149 - let current =
150 - |key: &str| nav.active_tab == key || (key == "commits" && nav.active_tab == "commit");
151 -
152 - let mut tabs = vec![
153 - (
154 - "files",
155 - "Files".to_owned(),
156 - format!("{base}/tree/{}", nav.current_ref),
157 - ),
158 - (
159 - "commits",
160 - "Commits".to_owned(),
161 - format!("{base}/commits/{}", nav.current_ref),
162 - ),
163 - ("tags", "Tags".to_owned(), format!("{base}/tags")),
164 - ("notes", "Notes".to_owned(), format!("{base}/notes")),
165 - (
166 - "issues",
167 - // The count is part of the name a reader reads, and there is no
168 - // second thing to say: a tab reading `Issues` when there are none
169 - // and `Issues (3)` when there are three is one label.
170 - if nav.open_issue_count > 0 {
171 - format!("Issues ({})", nav.open_issue_count)
172 - } else {
173 - "Issues".to_owned()
174 - },
175 - format!("{base}/issues"),
176 - ),
177 - ];
178 - if nav.is_owner {
179 - tabs.push((
180 - "settings",
181 - "Settings".to_owned(),
182 - format!("{base}/settings"),
183 - ));
223 + field Select CHOSEN "Branch or tag" {
224 + for info in nav.refs.iter() {
225 + option Choice::new(&info.name, ref_label(info));
226 + }
227 + value nav.current_ref;
228 + writes Action::get(nav.ref_path()).navigating();
184 229 }
185 -
186 - Node::list(
187 - tabs.into_iter()
188 - .map(|(key, label, route)| {
189 - let mut row = Row::new(label).activate(Action::get(route).navigating());
190 - row.current = current(key);
191 - row
192 - })
193 - .collect::<Vec<_>>(),
194 - )
195 230 }
196 231
197 - /// The line that says which repository this is.
198 - ///
199 - /// Two links and a separator in the shipped markup, and only the links are
200 - /// described: a `/` between an owner and a repository is punctuation the reader
201 - /// never presses, which is presentation and stays in the stylesheet. Named
202 - /// rather than headed, because [`Node::Heading`] carries text and this carries
203 - /// two addresses.
204 - #[must_use]
205 - pub fn heading(owner: &str, repo: &str) -> Node {
206 - Node::Region(
207 - Slot::new("git-repo-name", RegionKind::Group)
208 - .named(format!("{owner} / {repo}"))
209 - .across(
210 - Run::new(layout::Fallback::Wrap)
211 - .beside(
212 - Node::Link {
213 - text: owner.to_owned(),
214 - action: Action::get(format!("/git/{owner}")).navigating(),
215 - },
216 - layout::Priority::Essential,
217 - )
218 - .beside(
219 - Node::Link {
220 - text: repo.to_owned(),
221 - action: Action::get(format!("/git/{owner}/{repo}")).navigating(),
222 - },
223 - layout::Priority::Essential,
224 - ),
225 - ),
226 - )
227 - }
232 + declare! {
233 + /// The six places this repository has.
234 + ///
235 + /// Rows of the same kind of thing, each going somewhere, one of them
236 + /// current. That is the whole of what the six `<a>` elements said,
237 + /// `is-selected` included, and `current` is the member for it -- the app's
238 + /// own pointer at where the reader is, which is exactly what `aria-current`
239 + /// exists to announce.
240 + shape strip(nav: &Nav<'_>) -> Node;
228 241
229 - /// The path from the repository root down to what is being shown.
230 - ///
231 - /// The last crumb is the thing itself and goes nowhere, which is what
232 - /// [`Breadcrumb::is_link`] already says. The separators are the stylesheet's,
233 - /// for [`heading`]'s reason.
234 - #[must_use]
235 - pub fn breadcrumb(owner: &str, repo: &str, current_ref: &str, crumbs: &[Breadcrumb]) -> Node {
236 - let tree = format!("/git/{owner}/{repo}/tree/{current_ref}");
237 -
238 - let mut trail = Run::new(layout::Fallback::Wrap).beside(
239 - Node::Link {
240 - text: repo.to_owned(),
241 - action: Action::get(tree.clone()).navigating(),
242 - },
243 - layout::Priority::Essential,
244 - );
245 -
246 - for crumb in crumbs {
247 - let node = if crumb.is_link {
248 - Node::Link {
249 - text: crumb.name.clone(),
250 - action: Action::get(format!("{tree}/{}", crumb.path)).navigating(),
242 + list {
243 + for tab in nav.tabs() {
244 + row tab.label {
245 + current tab.current;
246 + activate to get tab.route navigating;
251 247 }
252 - } else {
253 - Node::text(crumb.name.clone())
254 - };
255 - trail = trail.beside(node, layout::Priority::Essential);
248 + }
256 249 }
250 + }
257 251
258 - Node::Region(
259 - Slot::new("git-breadcrumb", RegionKind::Group)
260 - .named("Path")
261 - .across(trail),
262 - )
252 + declare! {
253 + /// The line that says which repository this is.
254 + ///
255 + /// Two links and a separator in the shipped markup, and only the links are
256 + /// described: a `/` between an owner and a repository is punctuation the
257 + /// reader never presses, which is presentation and stays in the stylesheet.
258 + /// Named rather than headed, because a heading carries text and this
259 + /// carries two addresses.
260 + #[must_use]
261 + pub shape heading(owner: &str, repo: &str) -> Node;
262 +
263 + region "git-repo-name" as Group {
264 + named "{owner} / {repo}";
265 + across Wrap {
266 + beside Essential link owner to get "/git/{owner}" navigating;
267 + beside Essential link repo to get "/git/{owner}/{repo}" navigating;
268 + }
269 + }
270 + }
271 +
272 + declare! {
273 + /// The path from the repository root down to what is being shown.
274 + ///
275 + /// The last crumb is the thing itself and goes nowhere, which is what
276 + /// `Breadcrumb::is_link` already says. The separators are the stylesheet's,
277 + /// for [`heading`]'s reason.
278 + #[must_use]
279 + pub shape breadcrumb(
280 + owner: &str,
281 + repo: &str,
282 + current_ref: &str,
283 + crumbs: &[Breadcrumb],
284 + ) -> Node;
285 +
286 + let tree = "/git/{owner}/{repo}/tree/{current_ref}";
287 +
288 + region "git-breadcrumb" as Group {
289 + named "Path";
290 + across Wrap {
291 + beside Essential link repo to get tree.clone() navigating;
292 + for crumb in crumbs.iter() {
293 + beside Essential given crumb.is_link {
294 + true -> link crumb.name.clone() to get "{tree}/{crumb.path}" navigating;
295 + otherwise -> text crumb.name.clone();
296 + }
297 + }
298 + }
299 + }
263 300 }
264 301
265 302 #[cfg(test)]