Skip to main content

max / makenotwork

Declare the overview panel, the analytics tab and the custom pages Nineteen screens across three files. mnw goes from declared 80 to 99. project_overview and user_analytics met nothing the form had not met: a checklist row, an accreting stats, a five column table, a list with a meta. Both productions came from custom_page, the one host here that serves somebody else's markup. What the data gave up, all of it the rule that a description names what it draws. The tools list and the range list were slices of tuples and are structs now. Step gained offers_act, act_label and act_href over its Option<(&str, String)>, and custom_page's ItemView gained has_description and description, both the predicate-and-reader pair embeds established. SCOPE_CLASS and UserView::scope_id hold the names the CSS sanitiser rewrote every creator rule for, because a profile and a project page both draw that element. strip(screen, view) -> Screen took a document and handed it back, which is what call_to_action was refused for. It is top(apex, canonical) -> Slot now and the three screens include it, which also removed item's synthesised UserView: an aggregate is the hard limit, and two strings are all a band draws. user_analytics gains a chip supplier, forced because token is a setting and cannot also be a member. Second time that has moved the denominator. 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 01:12 UTC
Signed with PGP, not checked
Commit: d7cad879224034af8aabcf364bff47a4c3093542
Parent: 8e9a085
3 files changed, +534 insertions, -378 deletions
@@ -69,8 +69,9 @@
69 69 //! example would quietly stop working.
70 70
71 71 use quasi_axum::Serves as _;
72 - use quasi_router::screen::{Canvas, Document, Row};
73 - use quasi_router::{Action, Chrome, Node, RegionKind, Screen, Slot, layout};
72 + use quasi_declare::declare;
73 + use quasi_router::screen::Document;
74 + use quasi_router::{Chrome, Screen};
74 75 use quasi_webview::{Shell, Webview};
75 76
76 77 /// The platform strip's styling, and the system slots' defaults.
@@ -187,157 +188,192 @@
187 188 Webview::new().with_shell(shell).screen(screen)
188 189 }
189 190
190 - /// A creator's profile.
191 - #[must_use]
192 - pub fn user(view: &UserView) -> Screen {
193 - strip(
194 - Screen::single(&view.page_title)
195 - .documented(Document::default().styled(&view.sanitized_css)),
196 - view,
197 - )
198 - .with(Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(canvas(view)))))
199 - .with(footer(view))
200 - }
201 -
202 - /// A creator's project page: their markup, then the platform's own blocks
203 - /// inside the same scope.
204 - #[must_use]
205 - pub fn project(view: &ProjectView) -> Screen {
206 - let page = &view.page;
207 - // Inside the canvas and after the markup, which is what the Askama template
208 - // did and is what lets a creator's sheet reach these blocks at all. Outside
209 - // would be a different page, quietly.
210 - let mut scope = canvas(page).with(slot_block(
211 - "mnw-buy",
212 - "mnw-buy-cta",
213 - &view.price_label,
214 - &view.buy_url,
215 - ));
216 - if !view.files.is_empty() {
217 - scope = scope.with(Node::Canvas(Box::new(
218 - Canvas::default().classed("mnw-files").with(Node::List {
219 - rows: view
220 - .files
221 - .iter()
222 - .map(|file| Row::new(&file.title).activate(Action::leaving(&file.url)))
223 - .collect(),
224 - more: None,
225 - }),
226 - )));
227 - }
228 -
229 - strip(
230 - Screen::single(&page.page_title)
231 - .documented(Document::default().styled(&page.sanitized_css)),
232 - page,
233 - )
234 - .with(Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(scope))))
235 - .with(footer(page))
236 - }
237 -
238 - /// An item page, which is the default layout in the parent project's clothes.
239 - #[must_use]
240 - pub fn item(view: &ItemView) -> Screen {
241 - let mut block = Canvas::default().classed("mnw-item").with(Node::Heading {
242 - level: layout::Heading::Page,
243 - text: view.item_title.clone(),
244 - });
245 - if let Some(description) = &view.item_description {
246 - block = block.with(Node::text(description));
247 - }
248 - let block = block
249 - .with(price(&view.price_label))
250 - .with(cta("mnw-item-cta", &view.buy_url));
251 -
252 - let page = UserView {
253 - page_title: view.page_title.clone(),
254 - apex_url: view.apex_url.clone(),
255 - canonical_url: view.canonical_url.clone(),
256 - creator_label: view.creator_label.clone(),
257 - canvas_id: view.canvas_id.clone(),
258 - sanitized_css: view.sanitized_css.clone(),
259 - sanitized_html: String::new(),
260 - };
261 -
262 - strip(
263 - Screen::single(&view.page_title)
264 - .documented(Document::default().styled(&view.sanitized_css)),
265 - &page,
266 - )
267 - // `item-canvas` and `ic-`, which is what `sanitize_item_css` scopes to. The
268 - // two names travel together and neither is this module's to choose.
269 - .with(
270 - Slot::new("canvas", RegionKind::Pane).with(Node::Canvas(Box::new(
271 - Canvas::default()
272 - .classed("item-canvas")
273 - .identified(format!("ic-{}", view.canvas_id))
274 - .with(Node::Canvas(Box::new(block))),
275 - ))),
276 - )
277 - .with(footer(&page))
278 - }
279 -
280 - /// The creator's markup under the scope its stylesheet was rewritten for.
191 + /// The class every creator rule was nested inside.
281 192 ///
282 - /// `.user-canvas#uc-{owner}` is `css_sanitizer::sanitize_css`'s, not this
283 - /// module's: the class and the id are what every creator rule was nested
284 - /// inside, so a name changed here silently unstyles every custom page.
285 - fn canvas(view: &UserView) -> Canvas {
286 - Canvas::new(&view.sanitized_html)
287 - .classed("user-canvas")
288 - .identified(format!("uc-{}", view.canvas_id))
193 + /// `css_sanitizer::sanitize_css`'s, not this module's: the class and the id it
194 + /// pairs with are what every creator rule was rewritten under, so a name
195 + /// changed here silently unstyles every custom page. [`UserView::scope_id`] is
196 + /// the other half.
197 + const SCOPE_CLASS: &str = "user-canvas";
198 +
199 + impl UserView {
200 + /// The id half of the scope the sanitiser rewrote every rule for.
201 + ///
202 + /// See [`SCOPE_CLASS`]. Two screens draw this element -- a profile and a
203 + /// project page -- so the name is read from one place rather than written
204 + /// out at both.
205 + fn scope_id(&self) -> String {
206 + format!("uc-{}", self.canvas_id)
207 + }
289 208 }
290 209
291 - /// The platform strip across the top.
292 - fn strip(screen: Screen, view: &UserView) -> Screen {
293 - screen.with(
294 - Slot::new("chrome-top", RegionKind::Band)
295 - .with(Node::Link {
296 - text: "makenot.work".into(),
297 - action: Action::leaving(&view.apex_url),
298 - })
299 - .with(Node::Link {
300 - text: "View on makenot.work".into(),
301 - action: Action::leaving(&view.canonical_url),
302 - }),
303 - )
210 + impl ItemView {
211 + /// Whether the creator said anything about this item.
212 + ///
213 + /// A predicate rather than an `Option` the description reaches into, which
214 + /// is the call `super::embeds::ItemView::has_cover` records: the form has
215 + /// no binding pattern.
216 + fn has_description(&self) -> bool {
217 + self.item_description.is_some()
218 + }
219 +
220 + /// What they said, or nothing.
221 + ///
222 + /// R9: the paragraph is built whether or not [`Self::has_description`]
223 + /// places it, so the case that is not drawn is answered rather than
224 + /// panicked on.
225 + fn description(&self) -> &str {
226 + self.item_description.as_deref().unwrap_or_default()
227 + }
304 228 }
305 229
306 - /// The platform strip across the bottom.
307 - fn footer(view: &UserView) -> Slot {
308 - Slot::new("chrome-bottom", RegionKind::Band).with(Node::Link {
309 - text: format!("{} on makenot.work", view.creator_label),
310 - action: Action::leaving(&view.canonical_url),
311 - })
230 + declare! {
231 + /// The platform strip across the top.
232 + ///
233 + /// A region on the screen rather than [`quasi_router::Chrome`], because
234 + /// every link in it is per request: the canonical URL and the creator's
235 + /// name change with the page. See the module header.
236 + shape top(apex_url: &str, canonical_url: &str) -> Slot;
237 +
238 + region "chrome-top" as Band {
239 + link "makenot.work" to leaving apex_url;
240 + link "View on makenot.work" to leaving canonical_url;
241 + }
312 242 }
313 243
314 - /// A system slot: a price and the way to buy, under a published class name.
315 - fn slot_block(block_class: &str, cta_class: &str, price_label: &str, buy_url: &str) -> Node {
316 - Node::Canvas(Box::new(
317 - Canvas::default()
318 - .classed(block_class)
319 - .with(price(price_label))
320 - .with(cta(cta_class, buy_url)),
321 - ))
244 + declare! {
245 + /// The platform strip across the bottom.
246 + shape footer(creator_label: &str, canonical_url: &str) -> Slot;
247 +
248 + region "chrome-bottom" as Band {
249 + link "{creator_label} on makenot.work" to leaving canonical_url;
250 + }
322 251 }
323 252
324 - /// What it costs, under the class the guide names.
325 - fn price(label: &str) -> Node {
326 - Node::Canvas(Box::new(
327 - Canvas::default()
328 - .classed("mnw-price")
329 - .with(Node::text(label)),
330 - ))
253 + declare! {
254 + /// A creator's profile.
255 + ///
256 + /// The strips are siblings of the canvas rather than ancestors, which is
257 + /// the whole of the isolation guarantee: a creator selector naming anything
258 + /// in a strip is emitted under the canvas and matches nothing.
259 + #[must_use]
260 + pub shape user(view: &UserView) -> Screen;
261 +
262 + screen single &view.page_title {
263 + documented Document::default().styled(&view.sanitized_css);
264 +
265 + include top(&view.apex_url, &view.canonical_url);
266 + region "canvas" as Pane {
267 + canvas &view.sanitized_html {
268 + classed SCOPE_CLASS;
269 + identified view.scope_id();
270 + }
271 + }
272 + include footer(&view.creator_label, &view.canonical_url);
273 + }
331 274 }
332 275
333 - /// The way to buy, under the class the guide names.
334 - fn cta(class: &str, buy_url: &str) -> Node {
335 - Node::Canvas(Box::new(Canvas::default().classed(class).with(
336 - Node::Link {
337 - text: "Get on makenot.work".into(),
338 - action: Action::leaving(buy_url),
339 - },
340 - )))
276 + declare! {
277 + /// A creator's project page: their markup, then the platform's own blocks
278 + /// inside the same scope.
279 + #[must_use]
280 + pub shape project(view: &ProjectView) -> Screen;
281 +
282 + screen single &view.page.page_title {
283 + documented Document::default().styled(&view.page.sanitized_css);
284 +
285 + include top(&view.page.apex_url, &view.page.canonical_url);
286 + region "canvas" as Pane {
287 + canvas &view.page.sanitized_html {
288 + classed SCOPE_CLASS;
289 + identified view.page.scope_id();
290 +
291 + // Inside the canvas and after the markup, which is what the
292 + // Askama template did and is what lets a creator's sheet reach
293 + // these blocks at all. Outside would be a different page,
294 + // quietly.
295 + include slot_block("mnw-buy", "mnw-buy-cta", &view.price_label, &view.buy_url);
296 + canvas "" unless view.files.is_empty() {
297 + classed "mnw-files";
298 + list {
299 + for file in view.files.iter() {
300 + row &file.title {
301 + activate to leaving &file.url;
302 + }
303 + }
304 + }
305 + }
306 + }
307 + }
308 + include footer(&view.page.creator_label, &view.page.canonical_url);
309 + }
310 + }
311 +
312 + declare! {
313 + /// An item page, which is the default layout in the parent project's
314 + /// clothes.
315 + ///
316 + /// `item-canvas` and `ic-` are what `sanitize_item_css` scopes to. The two
317 + /// names travel together and neither is this module's to choose.
318 + #[must_use]
319 + pub shape item(view: &ItemView) -> Screen;
320 +
321 + screen single &view.page_title {
322 + documented Document::default().styled(&view.sanitized_css);
323 +
324 + include top(&view.apex_url, &view.canonical_url);
325 + region "canvas" as Pane {
326 + canvas "" {
327 + classed "item-canvas";
328 + identified "ic-{view.canvas_id}";
329 +
330 + canvas "" {
331 + classed "mnw-item";
332 + page &view.item_title;
333 + text view.description() when view.has_description();
334 + include price(&view.price_label);
335 + include cta("mnw-item-cta", &view.buy_url);
336 + }
337 + }
338 + }
339 + include footer(&view.creator_label, &view.canonical_url);
340 + }
341 + }
342 +
343 + declare! {
344 + /// A system slot: a price and the way to buy, under a published class name.
345 + shape slot_block(
346 + block_class: &str,
347 + cta_class: &str,
348 + price_label: &str,
349 + buy_url: &str,
350 + ) -> Node;
351 +
352 + canvas "" {
353 + classed block_class;
354 + include price(price_label);
355 + include cta(cta_class, buy_url);
356 + }
357 + }
358 +
359 + declare! {
360 + /// What it costs, under the class the guide names.
361 + shape price(label: &str) -> Node;
362 +
363 + canvas "" {
364 + classed "mnw-price";
365 + text label;
366 + }
367 + }
368 +
369 + declare! {
370 + /// The way to buy, under the class the guide names.
371 + shape cta(class: &str, buy_url: &str) -> Node;
372 +
373 + canvas "" {
374 + classed class;
375 + link "Get on makenot.work" to leaving buy_url;
376 + }
341 377 }
342 378
343 379 #[cfg(test)]
@@ -45,8 +45,9 @@
45 45 //! changing user-visible copy should say so.
46 46
47 47 use makeover_layout as layout;
48 - use quasi_router::screen::{Act, Figure, Row, Tag};
49 - use quasi_router::{Action, Node, RegionKind, Slot};
48 + use quasi_declare::declare;
49 + use quasi_router::screen::{Figure, Tag};
50 + use quasi_router::{Node, RegionKind, Slot};
50 51 use quasi_webview::Webview;
51 52
52 53 use crate::types::StatCard;
@@ -105,20 +106,42 @@
105 106 steps.iter().all(|step| step.done)
106 107 }
107 108
109 + /// Whether this step offers somewhere to go.
110 + ///
111 + /// A predicate rather than an `Option` the description reaches into, which
112 + /// is the call `embeds::ItemView::has_cover` records: the form has no
113 + /// binding pattern, and a finished step offers nothing however its `act`
114 + /// reads.
115 + fn offers_act(&self) -> bool {
116 + !self.done && self.act.is_some()
117 + }
118 +
119 + /// What that control says, or nothing.
120 + ///
121 + /// R9: the control is built whether or not [`Self::offers_act`] places it,
122 + /// so the case that is not drawn is answered rather than panicked on.
123 + fn act_label(&self) -> &'static str {
124 + self.act.as_ref().map_or("", |(label, _)| *label)
125 + }
126 +
127 + /// Where it goes, or nowhere. See [`Self::act_label`].
128 + fn act_href(&self) -> &str {
129 + self.act.as_ref().map_or("", |(_, href)| href.as_str())
130 + }
131 + }
132 +
133 + declare! {
108 134 /// One step as a row.
109 - fn row(&self) -> Row {
110 - let mut row = Row::new(self.label);
135 + ///
136 + /// The template wrote each step twice, once done and once not, and the two
137 + /// branches differ in a tick, a label class, and whether a CTA is there at
138 + /// all. Said once, that is two guarded settings over one row: a token when
139 + /// it is finished, a control when there is something to do about it.
140 + shape step_row(step: &Step) -> Row;
111 141
112 - if self.done {
113 - let mut tick = Tag::badge("Done");
114 - tick.tone = layout::Tone::Success;
115 - return row.token(tick);
116 - }
117 -
118 - if let Some((label, href)) = &self.act {
119 - row = row.act(Act::new(*label, Action::external(href.clone())));
120 - }
121 - row
142 + row step.label {
143 + token Tag::badge("Done").tone(layout::Tone::Success) when step.done;
144 + act step.act_label() to external step.act_href() when step.offers_act();
122 145 }
123 146 }
124 147
@@ -158,123 +181,158 @@
158 181 out
159 182 }
160 183
161 - /// The panel's contents, in order.
162 - fn body(
163 - slug: &str,
164 - stats: &[StatCard],
165 - stripe_connected: bool,
166 - has_items: bool,
167 - has_published: bool,
168 - ) -> Vec<Node> {
169 - let mut out = Vec::new();
184 + declare! {
185 + /// The panel's contents, in order.
186 + shape body(
187 + slug: &str,
188 + stats: &[StatCard],
189 + stripe_connected: bool,
190 + has_items: bool,
191 + has_published: bool,
192 + ) -> Vec<Node>;
170 193
171 194 let steps = Step::all(slug, stripe_connected, has_items, has_published);
172 - if !Step::all_done(&steps) {
173 - out.push(setup(&steps));
195 + include setup(&steps) unless Step::all_done(&steps);
196 +
197 + link "Docs: Projects" to get "/docs/projects" navigating;
198 + include figures(stats);
199 + section "Quick Actions";
200 + for node in quick_actions(slug) {
201 + include node;
174 202 }
175 -
176 - out.push(Node::Link {
177 - text: "Docs: Projects".into(),
178 - action: Action::get("/docs/projects").navigating(),
179 - });
180 - out.push(figures(stats));
181 - out.push(Node::section("Quick Actions"));
182 - out.extend(quick_actions(slug));
183 - out.push(tools());
184 - out
203 + include tools();
185 204 }
186 205
187 - /// What is left to do before the project can sell anything.
188 - fn setup(steps: &[Step]) -> Node {
189 - Node::Region(
190 - Slot::new("project-overview-setup", RegionKind::Pane)
191 - .with(Node::Heading {
192 - level: layout::Heading::Subsection,
193 - text: "Project Setup".into(),
194 - })
195 - .with(Node::list(steps.iter().map(Step::row))),
196 - )
206 + declare! {
207 + /// What is left to do before the project can sell anything.
208 + shape setup(steps: &[Step]) -> Node;
209 +
210 + region "project-overview-setup" as Pane {
211 + subsection "Project Setup";
212 + list {
213 + for step in steps.iter() {
214 + include step_row(step);
215 + }
216 + }
217 + }
197 218 }
198 219
199 - /// The figures across the top.
200 - ///
201 - /// The same shape as `super::user_analytics::stats`, and toned the same way:
202 - /// the tone rides on the delta, so a card with nothing to report stays neutral
220 + /// The delta a card reports, or nothing.
221 + fn change(stat: &StatCard) -> &str {
222 + stat.change.as_deref().unwrap_or_default()
223 + }
224 +
225 + /// The tone rides on the delta, so a card with nothing to report stays neutral
203 226 /// rather than going green for having no news.
204 - fn figures(stats: &[StatCard]) -> Node {
205 - Node::Stats {
206 - figures: stats
207 - .iter()
208 - .map(|stat| {
209 - let mut figure = Figure::new(stat.value.clone(), stat.label.clone());
210 - if let Some(change) = &stat.change {
211 - figure = figure.change(change.clone()).tone(if stat.is_positive {
212 - layout::Tone::Success
213 - } else {
214 - layout::Tone::Danger
215 - });
216 - }
217 - (figure, None)
218 - })
219 - .collect(),
227 + fn delta_tone(stat: &StatCard) -> layout::Tone {
228 + if stat.is_positive {
229 + layout::Tone::Success
230 + } else {
231 + layout::Tone::Danger
220 232 }
221 233 }
222 234
223 - /// The three controls under Quick Actions.
224 - fn quick_actions(slug: &str) -> Vec<Node> {
225 - vec![
226 - // Whole pages rather than fragments, so both leave. An internal
227 - // `Action::get` would fetch them into this panel.
228 - Node::act(
229 - "New Item",
230 - Action::external(format!("/dashboard/project/{slug}/new-item")),
231 - ),
232 - Node::act("View Public Page", Action::external(format!("/p/{slug}"))),
233 - // Through `export_act`, which is the described control five other sites
234 - // already use. See the module header.
235 - super::export_act::act("/api/export/projects", "projects.csv"),
236 - ]
235 + declare! {
236 + /// The figures across the top.
237 + ///
238 + /// The same shape as `super::user_analytics::stats`, and toned the same
239 + /// way: see [`delta_tone`].
240 + ///
241 + /// The empty list is what the figures accrete onto, which is the wart
242 + /// `super::project_analytics` recorded and this is its second site.
243 + shape figures(stats: &[StatCard]) -> Node;
244 +
245 + stats [] {
246 + for stat in stats.iter() {
247 + figure Figure::new(stat.value.clone(), stat.label.clone())
248 + when stat.change.is_none();
249 + figure Figure::new(stat.value.clone(), stat.label.clone())
250 + .change(change(stat))
251 + .tone(delta_tone(stat))
252 + unless stat.change.is_none();
253 + }
254 + }
237 255 }
238 256
239 - /// The tour of the other tabs, behind a disclosure.
240 - fn tools() -> Node {
241 - const TOOLS_LIST: &[(&str, &str)] = &[
242 - ("Content", "Upload items, manage versions, set prices."),
243 - (
244 - "Blog",
245 - "Write posts that appear on your project page and RSS feed.",
246 - ),
247 - (
248 - "Promo Codes",
249 - "Create discounts, free access codes, or trial periods.",
250 - ),
251 - (
252 - "Membership Tiers",
253 - "Recurring subscriptions with gated content access.",
254 - ),
255 - ("Team", "Add collaborators and split revenue automatically."),
256 - ("Analytics", "Track sales, revenue, and views over time."),
257 - ];
257 + declare! {
258 + /// The three controls under Quick Actions.
259 + shape quick_actions(slug: &str) -> Vec<Node>;
258 260
259 - // The shape is what makes this a disclosure, and it is exact: a region
260 - // showing at most one frame, whose single frame is a LABELLED sub-region.
261 - // The label is the summary line. Put it on the outer slot instead and the
262 - // renderer finds no labels, falls through to the frame-stepper branch, and
263 - // draws Prev/Next buttons and a "0 / 1" counter. Measured 2026-08-26 by
264 - // doing exactly that.
265 - Node::Region(
266 - Slot::new(TOOLS, RegionKind::Group)
267 - .with(Node::Region(
268 - Slot::new(TOOLS_BODY, RegionKind::Pane)
269 - .label("Explore Your Project Tools")
270 - .with(Node::list(TOOLS_LIST.iter().map(|(name, description)| {
271 - Row::new(*name).secondary(*description)
272 - }))),
273 - ))
274 - // `None` is closed, which is where the template's `<details>` rests:
275 - // it carries no `open`.
276 - .showing_at_most_one(None),
277 - )
261 + // Whole pages rather than fragments, so both leave. An internal
262 + // `get` would fetch them into this panel.
263 + act "New Item" to external "/dashboard/project/{slug}/new-item";
264 + act "View Public Page" to external "/p/{slug}";
265 + // Through `export_act`, which is the described control five other sites
266 + // already use. See the module header.
267 + include super::export_act::act("/api/export/projects", "projects.csv");
268 + }
269 +
270 + /// One tab the disclosure tours.
271 + ///
272 + /// Named members rather than a tuple, for `policy`'s reason: a description
273 + /// names what it draws, and `.1` is not a name.
274 + struct Tool {
275 + /// What the tab is called.
276 + name: &'static str,
277 + /// What it is for.
278 + description: &'static str,
279 + }
280 +
281 + /// The six tabs the disclosure tours, in the order it draws them.
282 + const TOOLS_LIST: &[Tool] = &[
283 + Tool {
284 + name: "Content",
285 + description: "Upload items, manage versions, set prices.",
286 + },
287 + Tool {
288 + name: "Blog",
289 + description: "Write posts that appear on your project page and RSS feed.",
290 + },
291 + Tool {
292 + name: "Promo Codes",
293 + description: "Create discounts, free access codes, or trial periods.",
294 + },
295 + Tool {
296 + name: "Membership Tiers",
297 + description: "Recurring subscriptions with gated content access.",
298 + },
299 + Tool {
300 + name: "Team",
301 + description: "Add collaborators and split revenue automatically.",
302 + },
303 + Tool {
304 + name: "Analytics",
305 + description: "Track sales, revenue, and views over time.",
306 + },
307 + ];
308 +
309 + declare! {
310 + /// The tour of the other tabs, behind a disclosure.
311 + ///
312 + /// The shape is what makes this a disclosure, and it is exact: a region
313 + /// showing at most one frame, whose single frame is a LABELLED sub-region.
314 + /// The label is the summary line. Put it on the outer region instead and
315 + /// the renderer finds no labels, falls through to the frame-stepper branch,
316 + /// and draws Prev/Next buttons and a "0 / 1" counter. Measured 2026-08-26
317 + /// by doing exactly that.
318 + ///
319 + /// `None` is closed, which is where the template's `<details>` rests: it
320 + /// carries no `open`.
321 + shape tools() -> Node;
322 +
323 + region TOOLS as Group {
324 + region TOOLS_BODY as Pane {
325 + label "Explore Your Project Tools";
326 + list {
327 + for tool in TOOLS_LIST {
328 + row tool.name {
329 + secondary tool.description;
330 + }
331 + }
332 + }
333 + }
334 + showing_at_most_one None;
335 + }
278 336 }
279 337
280 338 #[cfg(test)]
@@ -45,8 +45,9 @@
45 45 use std::fmt::Write as _;
46 46
47 47 use makeover_layout as layout;
48 - use quasi_router::screen::{Cell, Cells, Column, Figure, Row, Table, Tag};
49 - use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot};
48 + use quasi_declare::declare;
49 + use quasi_router::screen::{Figure, Tag};
50 + use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError};
50 51 use quasi_webview::Webview;
51 52
52 53 use super::Viewer;
@@ -72,12 +73,36 @@
72 73 /// The slot the chart's own markup mounts into.
73 74 const CHART_SLOT: &str = "analytics-chart";
74 75
76 + /// One window the selector offers.
77 + ///
78 + /// Named members rather than a tuple, for `policy`'s reason and
79 + /// `super::project_analytics`'s: a description names what it draws, and `.1` is
80 + /// not a name.
81 + struct Range {
82 + /// What the address carries.
83 + value: &'static str,
84 + /// What the heading calls it.
85 + label: &'static str,
86 + }
87 +
75 88 /// The ranges offered, in the order the selector draws them.
76 - const RANGES: [(&str, &str); 4] = [
77 - ("7d", "Last 7 days"),
78 - ("30d", "Last 30 days"),
79 - ("90d", "Last 90 days"),
80 - ("all", "All time"),
89 + const RANGES: &[Range] = &[
90 + Range {
91 + value: "7d",
92 + label: "Last 7 days",
93 + },
94 + Range {
95 + value: "30d",
96 + label: "Last 30 days",
97 + },
98 + Range {
99 + value: "90d",
100 + label: "Last 90 days",
101 + },
102 + Range {
103 + value: "all",
104 + label: "All time",
105 + },
81 106 ];
82 107
83 108 /// One stat card, as the screen needs it.
@@ -259,140 +284,176 @@
259 284 })
260 285 }
261 286
262 - /// Everything inside the tab pane.
263 - fn pane(analytics: &Analytics) -> Node {
264 - let mut slot =
265 - Slot::new(REGION, RegionKind::Pane).with(Node::section(range_heading(&analytics.range)));
287 + declare! {
288 + /// Everything inside the tab pane.
289 + ///
290 + /// One project is not a comparison, which is why that section carries the
291 + /// same guard twice: the heading and the table are two members and both are
292 + /// absent together.
293 + shape pane(analytics: &Analytics) -> Node;
266 294
267 - for chip in range_chips(&analytics.range) {
268 - slot = slot.with(chip);
269 - }
295 + region REGION as Pane {
296 + section range_heading(&analytics.range);
270 297
271 - slot = slot.with(stats(&analytics.stats));
298 + for chip in range_chips(&analytics.range) {
299 + include chip;
300 + }
272 301
273 - slot = slot.with(Node::section("Revenue Over Time"));
274 - slot = if analytics.bars.is_empty() {
275 - slot.with(Node::empty(
276 - "Once you publish items and make sales, revenue data will appear here.",
277 - ))
278 - } else {
302 + include stats(&analytics.stats);
303 +
304 + section "Revenue Over Time";
305 + empty "Once you publish items and make sales, revenue data will appear here."
306 + when analytics.bars.is_empty();
279 307 // The chart's own markup arrives through the renderer. The description
280 308 // says only that there is a region here and what it is called.
281 - slot.with(Node::Region(Slot::ceded(CHART_SLOT, "revenue-chart")))
282 - };
309 + region CHART_SLOT as RegionKind::ceded("revenue-chart") unless analytics.bars.is_empty() {}
283 310
284 - // One project is not a comparison, which is the condition the template
285 - // wraps this whole section in.
286 - if analytics.projects.len() > 1 {
287 - slot = slot
288 - .with(Node::section("Project Comparison"))
289 - .with(comparison(&analytics.projects));
311 + section "Project Comparison" when analytics.projects.len() over 1;
312 + include comparison(&analytics.projects) when analytics.projects.len() over 1;
313 +
314 + section "Top Projects by Revenue";
315 + empty "No revenue data yet. Sales across your projects will appear here."
316 + when analytics.totals.is_empty();
317 + include totals(&analytics.totals) unless analytics.totals.is_empty();
290 318 }
291 -
292 - slot = slot.with(Node::section("Top Projects by Revenue"));
293 - slot = if analytics.totals.is_empty() {
294 - slot.with(Node::empty(
295 - "No revenue data yet. Sales across your projects will appear here.",
296 - ))
297 - } else {
298 - slot.with(totals(&analytics.totals))
299 - };
300 -
301 - Node::Region(slot)
302 319 }
303 320
304 321 /// What the current range is called.
305 322 fn range_heading(range: &str) -> &'static str {
306 323 RANGES
307 324 .iter()
308 - .find(|(value, _)| *value == range)
309 - .map_or("All time", |(_, name)| *name)
325 + .find(|window| window.value == range)
326 + .map_or("All time", |window| window.label)
310 327 }
311 328
312 - /// The range selector: one chip per range, the current one held down.
313 - fn range_chips(range: &str) -> Vec<Node> {
314 - RANGES
315 - .iter()
316 - .map(|(value, _)| {
317 - Node::Token(
318 - Tag::chip(*value, Action::get(PATH).carrying("range", *value))
319 - .latched(*value == range),
320 - )
321 - })
322 - .collect()
329 + /// Whether this window is the one being shown.
330 + ///
331 + /// A supplier because a comparison is an expression, and the form admits none
332 + /// in an argument. It hands back a `bool`, which is the smallest thing that
333 + /// works and keeps it out of the population.
334 + fn is_shown(window: &Range, range: &str) -> bool {
335 + window.value == range
323 336 }
324 337
325 - /// The figures across the top.
326 - fn stats(stats: &[StatView]) -> Node {
327 - Node::Stats {
328 - figures: stats
329 - .iter()
330 - .map(|stat| {
331 - let mut figure = Figure::new(stat.value.clone(), stat.label.clone());
332 - // The tone rides on the delta, which is why a card without one
333 - // stays neutral rather than being coloured green for having
334 - // nothing to report. `positive` is `true` by default in the
335 - // source data, so toning on it alone would paint every
336 - // unchanged card.
337 - if let Some(change) = &stat.change {
338 - figure = figure.change(change.clone()).tone(if stat.positive {
339 - layout::Tone::Success
340 - } else {
341 - layout::Tone::Danger
342 - });
343 - }
344 - (figure, None)
345 - })
346 - .collect(),
338 + /// One range control.
339 + ///
340 + /// A supplier and not a member: `token` is a **setting** on a row and on a
341 + /// cell, so the name cannot also be a node member. `quasi_declare`'s
342 + /// `NODE_MEMBERS` records the ruling and `super::project_analytics::chip` is
343 + /// the same supplier for the project-level panel.
344 + fn chip(window: &Range, range: &str) -> Node {
345 + Node::token(
346 + Tag::chip(
347 + window.value,
348 + Action::get(PATH).carrying("range", window.value),
349 + )
350 + .latched(is_shown(window, range)),
351 + )
352 + }
353 +
354 + declare! {
355 + /// The range selector: one chip per range, the current one held down.
356 + shape range_chips(range: &str) -> Vec<Node>;
357 +
358 + for window in RANGES {
359 + include chip(window, range);
347 360 }
348 361 }
349 362
350 - /// The per-project comparison.
351 - fn comparison(projects: &[ProjectView]) -> Node {
352 - let columns = vec![
353 - Column::new("Project")
354 - .width(layout::Width::Fill)
355 - .priority(layout::Priority::Essential),
356 - Column::new("Revenue")
357 - .width(layout::Width::Content)
358 - .priority(layout::Priority::Essential),
359 - Column::new("Sales").width(layout::Width::Content),
360 - Column::new("Views").width(layout::Width::Content),
361 - Column::new("Conversion")
362 - .width(layout::Width::Content)
363 - .priority(layout::Priority::Optional),
364 - ];
365 - // The cells are positional because the column list is a few lines above
366 - // them and every project fills all five: an empty conversion is the string
367 - // "-" rather than a missing cell, so no row is ever short.
368 - //
369 - // No paging described here either: every one of these tables is a whole
370 - // set the handler already counted.
371 - Table::new(columns)
372 - .rows(projects.iter().map(|project| {
373 - Cells::new([
374 - Cell::new(project.title.clone()),
375 - // The number alone. The template draws a bar behind it
376 - // scaled against the biggest earner, and that is one site
377 - // in the whole template set, so it does not earn a member.
378 - // See the module header.
379 - Cell::new(project.revenue.clone()),
380 - Cell::new(project.sales.clone()),
381 - Cell::new(project.views.clone()),
382 - Cell::new(project.conversion.clone()),
383 - ])
384 - }))
385 - .into()
363 + /// The delta a card reports, or nothing.
364 + fn change(stat: &StatView) -> &str {
365 + stat.change.as_deref().unwrap_or_default()
386 366 }
387 367
388 - /// All-time revenue per project.
389 - fn totals(totals: &[TotalView]) -> Node {
390 - Node::List {
391 - rows: totals
392 - .iter()
393 - .map(|total| Row::new(total.title.clone()).meta(total.revenue.clone()))
394 - .collect(),
395 - more: None,
368 + /// The tone rides on the delta, which is why a card without one stays neutral
369 + /// rather than being coloured green for having nothing to report.
370 + ///
371 + /// `positive` is `true` by default in the source data, so toning on it alone
372 + /// would paint every unchanged card.
373 + fn delta_tone(stat: &StatView) -> layout::Tone {
374 + if stat.positive {
375 + layout::Tone::Success
376 + } else {
377 + layout::Tone::Danger
378 + }
379 + }
380 +
381 + declare! {
382 + /// The figures across the top.
383 + ///
384 + /// The empty list is what the figures accrete onto: `Node::stats` takes the
385 + /// whole list and this one is built a card at a time. See [`delta_tone`].
386 + shape stats(stats: &[StatView]) -> Node;
387 +
388 + stats [] {
389 + for stat in stats.iter() {
390 + figure Figure::new(stat.value.clone(), stat.label.clone())
391 + when stat.change.is_none();
392 + figure Figure::new(stat.value.clone(), stat.label.clone())
393 + .change(change(stat))
394 + .tone(delta_tone(stat))
395 + unless stat.change.is_none();
396 + }
397 + }
398 + }
399 +
400 + declare! {
401 + /// The per-project comparison.
402 + ///
403 + /// The cells are positional because the column list is a few lines above
404 + /// them and every project fills all five: an empty conversion is the string
405 + /// "-" rather than a missing cell, so no row is ever short.
406 + ///
407 + /// No paging described here either: every one of these tables is a whole
408 + /// set the handler already counted.
409 + ///
410 + /// The revenue cell is the number alone. The template drew a bar behind it
411 + /// scaled against the biggest earner, and that is one site in the whole
412 + /// template set, so it does not earn a member. See the module header.
413 + shape comparison(projects: &[ProjectView]) -> Node;
414 +
415 + table {
416 + column "Project" {
417 + width Fill;
418 + priority Essential;
419 + }
420 + column "Revenue" {
421 + width Content;
422 + priority Essential;
423 + }
424 + column "Sales" {
425 + width Content;
426 + }
427 + column "Views" {
428 + width Content;
429 + }
430 + column "Conversion" {
431 + width Content;
432 + priority Optional;
433 + }
434 +
435 + for project in projects.iter() {
436 + cells {
437 + cell project.title.clone();
438 + cell project.revenue.clone();
439 + cell project.sales.clone();
440 + cell project.views.clone();
441 + cell project.conversion.clone();
442 + }
443 + }
444 + }
445 + }
446 +
447 + declare! {
448 + /// All-time revenue per project.
449 + shape totals(totals: &[TotalView]) -> Node;
450 +
451 + list {
452 + for total in totals.iter() {
453 + row total.title.clone() {
454 + meta total.revenue.clone();
455 + }
456 + }
396 457 }
397 458 }
398 459
@@ -453,6 +514,7 @@
453 514 mod tests {
454 515 use super::*;
455 516 use quasi_axum::Serves;
517 + use quasi_router::Slot;
456 518
457 519 fn analytics() -> Analytics {
458 520 Analytics {