Skip to main content

max / makenotwork

Describe the revenue chart, and close the seam `/dashboard/tabs/analytics` was the last described screen off the compiled-template seam. Its chart was a ceded region: the handler drew the markup and the renderer looked it up WHILE it rendered, so a residual derived from a bare `Webview` held the empty container and nothing at serve time could get inside it. `OFF_THE_SEAM` is empty now. Max ruled a bespoke region is the mark of a screen the description layer has not finished converting, so the chart is described rather than compiled around. `RevenueChart` carries the axis the bars are read against instead of spending it at the point they are built -- each bar used to carry `revenue / max * 100.0` and the maximum was thrown away. Carrying both integers is what lets the screen compile at all, and it is also more honest markup: the DOM holds the real numbers rather than a percentage with nothing behind it. Three things the screen had to give up beyond the member. A latched chip is a guard now, because a `bool` has no stand-in and a loop body cannot carry "exactly one of these differs" as a value. The stats strip is three guarded figures, one per tone, because a `Tone` has no stand-in either. And the ceded seam itself is gone -- `Viewer::fill` and `Viewer::drawn` had one caller and this was it, so a bespoke region returning would be a deliberate act rather than a quiet reach for an existing hook. `project_analytics` drew the same chart through the same markup-building function, so it is described the same way; the two tabs are held together by the vocabulary rather than by a shared string. `chart_bars.html` emits the same markup for the Askama copies, and this site's own `.chart-*` rules give way to `makeover-webview`'s, so one set of rules draws every chart here. The dead-vocabulary seal falls 49 to 34, most of it the chart's own classes: they counted as dead while the site drew them on every analytics tab, because they were the app's markup rather than the renderer's.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-08 18:31 UTC
Signed with PGP, not checked
Commit: 76eb2281fd433cfa5c81972e72a762bf83760bcd
Parent: b1e1eef
16 files changed, +541 insertions, -354 deletions
@@ -163,7 +163,7 @@
163 163 # through quasi beside its Askama equivalent, so the cost of describing a screen
164 164 # here is measured rather than argued. Not load-bearing for any shipped route.
165 165 # See wiki look-wave-2, tier G.
166 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
166 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" }
167 167 # The declared form. A screen written as `declare!` is compiled to the same
168 168 # `quasi_router` constructors the hand-written shape called, at build time, so
169 169 # nothing new reaches the binary and the macro is not in the request path.
@@ -183,13 +183,13 @@
183 183 # For the request head the per-viewer state factory reads. axum re-exports it,
184 184 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
185 185 http = "1.3.1"
186 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
187 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
186 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" }
187 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" }
188 188 # The first-party widget set. The carousel was proved here against three pages
189 189 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
190 190 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
191 191 # sits above quasi-router rather than inside the description suite.
192 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
192 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" }
193 193
194 194 # Git source browser
195 195 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -288,7 +288,7 @@
288 288 # Written from the crate rather than copied into the repo for the reason the
289 289 # constants exist: a stale copy in a static directory is a silently dead bulk
290 290 # bar, and the version has to be the one the emitter agrees with.
291 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.108" }
291 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.109" }
292 292
293 293 [dev-dependencies]
294 294 tower = { version = "0.5.3", features = ["util"] }
M server/build.rs +10 -1
@@ -396,6 +396,15 @@
396 396 /// emitted by `quasi-webview` at request time, which no scan of this repo can
397 397 /// see. A class the description writes is dead here by construction.
398 398 ///
399 + /// # 34 since 2026-09-08, lowered by the last conversions
400 + ///
401 + /// `/dashboard/tabs/ssh-keys`, `/git`, `/feed` and `/dashboard/tabs/analytics`
402 + /// joined the seam, which is every described screen -- `OFF_THE_SEAM` is empty
403 + /// (mnw-server `14d42beb`). The chart is the interesting part of the fall: it
404 + /// was the app's own markup behind a ceded region, so its classes counted as
405 + /// dead vocabulary while the site drew them on every analytics tab. Described,
406 + /// they are the renderer's.
407 + ///
399 408 /// # 49 since 2026-09-07, lowered by converting screens
400 409 ///
401 410 /// The ratchet doing what the paragraph below says it should. `/team`,
@@ -422,7 +431,7 @@
422 431 /// is mid-flight, and this number should FALL as phase 3 converts screens
423 432 /// rather than being raised again. If it needs raising a second time, read the
424 433 /// list before doing it.
425 - const DEAD_VOCABULARY_HIGH_WATER: usize = 49;
434 + const DEAD_VOCABULARY_HIGH_WATER: usize = 34;
426 435
427 436 /// The scripts this build script generates into `static/`.
428 437 ///
@@ -416,62 +416,14 @@
416 416 font-weight: normal;
417 417 }
418 418
419 - .chart-bars {
420 - display: flex;
421 - align-items: flex-end;
422 - gap: 2px;
423 - height: 200px;
424 - padding: 0 var(--gap-peer);
425 - }
426 -
427 - .chart-bar-col {
428 - flex: 1;
429 - display: flex;
430 - flex-direction: column;
431 - align-items: center;
432 - min-width: 0;
433 - cursor: default;
434 - position: relative;
435 - }
436 -
437 - .chart-bar-col[data-tooltip]:hover::before {
438 - content: attr(data-tooltip);
439 - position: absolute;
440 - bottom: 100%;
441 - left: 50%;
442 - transform: translateX(-50%);
443 - background: var(--primary-dark);
444 - color: var(--primary-light);
445 - font-size: var(--text-fine);
446 - font-family: var(--font-mono);
447 - padding: var(--gap-bound) var(--gap-peer);
448 - white-space: nowrap;
449 - z-index: var(--z-dropdown);
450 - pointer-events: none;
451 - margin-bottom: var(--gap-bound);
452 - }
453 -
454 - .chart-bar {
455 - width: 100%;
456 - height: var(--fill, 0%);
457 - background: var(--primary-dark);
458 - min-height: 2px;
459 - transition: opacity 0.15s;
460 - }
461 -
462 - .chart-bar-col:hover .chart-bar {
463 - opacity: 0.8;
464 - }
465 -
466 - .chart-bar-label {
467 - font-size: var(--text-fine);
468 - opacity: 0.5;
469 - margin-top: var(--gap-bound);
470 - white-space: nowrap;
471 - overflow: hidden;
472 - text-overflow: ellipsis;
473 - max-width: 100%;
474 - }
419 + /* The revenue chart's rules live in makeover-webview (`chart::rules`) and
420 + arrive through layout.css, so both this site's charts -- the described one on
421 + /dashboard/tabs/analytics and the Askama copy in chart_bars.html -- are drawn
422 + by one set of rules rather than by two that agreed when they were written.
423 + What the app still says about a chart is said as the custom properties that
424 + module defers: --chart-height, --chart-gap, --chart-bar-least,
425 + --chart-readout-padding. Nothing here overrides them today, which means the
426 + defaults are what the design wanted. */
475 427
476 428 /* Time selector */
477 429 .time-selector {
@@ -4581,62 +4581,14 @@
4581 4581 font-weight: normal;
4582 4582 }
4583 4583
4584 - .chart-bars {
4585 - display: flex;
4586 - align-items: flex-end;
4587 - gap: 2px;
4588 - height: 200px;
4589 - padding: 0 var(--gap-peer);
4590 - }
4591 -
4592 - .chart-bar-col {
4593 - flex: 1;
4594 - display: flex;
4595 - flex-direction: column;
4596 - align-items: center;
4597 - min-width: 0;
4598 - cursor: default;
4599 - position: relative;
4600 - }
4601 -
4602 - .chart-bar-col[data-tooltip]:hover::before {
4603 - content: attr(data-tooltip);
4604 - position: absolute;
4605 - bottom: 100%;
4606 - left: 50%;
4607 - transform: translateX(-50%);
4608 - background: var(--primary-dark);
4609 - color: var(--primary-light);
4610 - font-size: var(--text-fine);
4611 - font-family: var(--font-mono);
4612 - padding: var(--gap-bound) var(--gap-peer);
4613 - white-space: nowrap;
4614 - z-index: var(--z-dropdown);
4615 - pointer-events: none;
4616 - margin-bottom: var(--gap-bound);
4617 - }
4618 -
4619 - .chart-bar {
4620 - width: 100%;
4621 - height: var(--fill, 0%);
4622 - background: var(--primary-dark);
4623 - min-height: 2px;
4624 - transition: opacity 0.15s;
4625 - }
4626 -
4627 - .chart-bar-col:hover .chart-bar {
4628 - opacity: 0.8;
4629 - }
4630 -
4631 - .chart-bar-label {
4632 - font-size: var(--text-fine);
4633 - opacity: 0.5;
4634 - margin-top: var(--gap-bound);
4635 - white-space: nowrap;
4636 - overflow: hidden;
4637 - text-overflow: ellipsis;
4638 - max-width: 100%;
4639 - }
4584 + /* The revenue chart's rules live in makeover-webview (`chart::rules`) and
4585 + arrive through layout.css, so both this site's charts -- the described one on
4586 + /dashboard/tabs/analytics and the Askama copy in chart_bars.html -- are drawn
4587 + by one set of rules rather than by two that agreed when they were written.
4588 + What the app still says about a chart is said as the custom properties that
4589 + module defers: --chart-height, --chart-gap, --chart-bar-least,
4590 + --chart-readout-padding. Nothing here overrides them today, which means the
4591 + defaults are what the design wanted. */
4640 4592
4641 4593 /* Time selector */
4642 4594 .time-selector {
@@ -154,21 +154,6 @@
154 154 /// `crate::auth::SESSION_TRACKING_KEY` carries no tracking id, and a
155 155 /// screen answering for one marks no row as current.
156 156 pub session_id: Option<crate::db::UserSessionId>,
157 - /// Markup for the bespoke regions this request's screen describes.
158 - ///
159 - /// The seam between a handler and its renderer, and the reason it has to be
160 - /// here rather than in either of them: a `Region::Handover` is filled on the
161 - /// [`Webview`](quasi_webview::Webview), which quasi-axum builds *after* the
162 - /// handler has answered, and the renderer factory is handed `&S` and the
163 - /// answer. So the handler writes what it drew here and the renderer reads
164 - /// it back off the same state. Both see one instance: the adapter builds a
165 - /// single `Arc<Viewer>` per request and passes it to the router and then to
166 - /// the factory.
167 - ///
168 - /// Behind a lock because the handler holds `&Viewer` and runs on a blocking
169 - /// thread. Uncontended in practice: one request writes it, then one
170 - /// renderer reads it, never at once.
171 - fills: std::sync::Mutex<std::collections::HashMap<String, String>>,
172 157 }
173 158
174 159 impl Viewer {
@@ -197,19 +182,6 @@
197 182 .ok_or_else(|| quasi_router::RouteError::denied("sign in to continue"))
198 183 }
199 184
200 - /// Hand the renderer the markup for one bespoke region.
201 - ///
202 - /// Called by a handler while it builds its description, keyed by the slot
203 - /// id the description gives that region. Markup, not text: a bespoke region
204 - /// is the app's own and is not escaped, which is the whole of what makes it
205 - /// bespoke and the whole of why a handler must not put a reader's string in
206 - /// one without escaping it first.
207 - pub fn fill(&self, slot_id: impl Into<String>, markup: impl Into<String>) {
208 - if let Ok(mut fills) = self.fills.lock() {
209 - fills.insert(slot_id.into(), markup.into());
210 - }
211 - }
212 -
213 185 /// The shell every described screen is drawn in.
214 186 ///
215 187 /// Here rather than in each screen's `renderer` because of what it carries:
@@ -249,11 +221,6 @@
249 221 pub fn document_shell(&self) -> quasi_webview::Shell {
250 222 document_shell(&self.csrf)
251 223 }
252 -
253 - /// Everything the handler drew, for the renderer to mount.
254 - pub fn drawn(&self) -> std::collections::HashMap<String, String> {
255 - self.fills.lock().map(|f| f.clone()).unwrap_or_default()
256 - }
257 224 }
258 225
259 226 /// The shell a described document is drawn in, by the token it carries.
@@ -357,7 +324,6 @@
357 324 runtime,
358 325 csrf,
359 326 session_id,
360 - fills: std::sync::Mutex::default(),
361 327 })
362 328 })
363 329 }
@@ -14,10 +14,14 @@
14 14 //! conflict instead of an edit. That is the ruling `buyer_contacts` made about
15 15 //! its table against `library_contacts` and it applies unchanged here.
16 16 //!
17 - //! The one thing that IS shared is [`super::user_analytics::chart_markup`],
18 - //! because a chart drawn two ways is two charts that drift, and the whole
19 - //! reason `templates/partials/chart_bars.html` existed was that three tabs had
20 - //! byte-identical copies of it.
17 + //! The chart is the exception, and it is shared by the vocabulary rather than
18 + //! by code: both tabs say `chart`, one renderer draws it, and
19 + //! `makeover-webview` emits the rules. A chart drawn two ways is two charts
20 + //! that drift, which is the whole reason
21 + //! `templates/partials/chart_bars.html` existed when three tabs had
22 + //! byte-identical copies of it. This used to share a markup-building function
23 + //! with `user_analytics` for the same reason; the member replaced it on
24 + //! 2026-09-08 (quasicoherent `7d6ad166`).
21 25 //!
22 26 //! # A fill, and for neither of the batch's two usual reasons
23 27 //!
@@ -43,45 +47,32 @@
43 47
44 48 use makeover_layout as layout;
45 49 use quasi_declare::declare;
46 - use quasi_router::screen::Figure;
50 + use quasi_router::screen::{Bar, Chart, Figure};
47 51 use quasi_router::{Node, RegionKind, Slot};
48 52 use quasi_webview::Webview;
49 53
50 - use crate::types::{ChartBar, ContentItem, StatCard};
54 + use crate::types::{ContentItem, RevenueChart, StatCard};
51 55
52 56 /// The region the answer replaces, keeping the id the page already used.
53 57 pub const REGION: &str = "project-analytics";
54 58
55 59 /// The bespoke region the chart is drawn into.
56 - const CHART_SLOT: &str = "project-revenue-chart";
57 60 /// The panel as the route answers it: the region, carrying its own id.
58 61 #[must_use]
59 62 pub fn fragment(
60 63 slug: &str,
61 64 range: &str,
62 65 stats: &[StatCard],
63 - bars: &[ChartBar],
66 + chart: &RevenueChart,
64 67 items: &[ContentItem],
65 68 ) -> String {
66 69 use quasi_axum::Serves as _;
67 70
68 71 let mut slot = Slot::new(REGION, RegionKind::Pane);
69 - for node in body(slug, range, stats, bars, items) {
72 + for node in body(slug, range, stats, chart, items) {
70 73 slot = slot.with(node);
71 74 }
72 - drawn(bars).fragment(&Node::Region(slot))
73 - }
74 -
75 - /// The renderer, carrying whatever the chart needs.
76 - ///
77 - /// A local `Webview` rather than the `Viewer` seam a mounted screen uses. See
78 - /// the module header.
79 - fn drawn(bars: &[ChartBar]) -> Webview {
80 - let mut webview = Webview::new();
81 - if !bars.is_empty() {
82 - webview = webview.with_fill(CHART_SLOT, super::user_analytics::chart_markup(bars));
83 - }
84 - webview
75 + Webview::new().fragment(&Node::Region(slot))
85 76 }
86 77
87 78 declare! {
@@ -93,7 +84,7 @@
93 84 slug: &str,
94 85 range: &str,
95 86 stats: &[StatCard],
96 - bars: &[ChartBar],
87 + chart: &RevenueChart,
97 88 items: &[ContentItem],
98 89 ) -> Vec<Node>;
99 90
@@ -110,8 +101,15 @@
110 101 section "Revenue Over Time";
111 102 empty "No revenue data yet. Revenue will appear here after your first sale. \
112 103 Publish an item and share it to get started."
113 - when bars.is_empty();
114 - region CHART_SLOT as RegionKind::ceded("revenue-chart") unless bars.is_empty() {}
104 + when chart.bars.is_empty();
105 + chart Chart::new(chart.most).label("revenue over time") unless chart.bars.is_empty() {
106 + for bar in chart.bars.iter() {
107 + bar Bar::at(bar.label.clone())
108 + .of(bar.cents)
109 + .reading(bar.value.clone())
110 + .note(super::user_analytics::sales(bar.count));
111 + }
112 + }
115 113
116 114 section "Top Performing Items";
117 115 empty "No sales data yet. Publish and promote your items to see analytics here."
@@ -130,7 +128,7 @@
130 128 for window in super::RANGES {
131 129 chip window.value
132 130 to get "/dashboard/project/{slug}/tabs/analytics" carrying "range" window.value {
133 - latched super::is_shown(window, range);
131 + latched when super::is_shown(window, range);
134 132 }
135 133 }
136 134 }
@@ -208,6 +206,7 @@
208 206 #[cfg(test)]
209 207 mod tests {
210 208 use super::*;
209 + use crate::types::ChartBar;
211 210 use quasi_axum::Serves;
212 211
213 212 fn stat(label: &str, change: Option<&str>) -> StatCard {
@@ -222,16 +221,26 @@
222 221 fn bar() -> ChartBar {
223 222 ChartBar {
224 223 label: "Aug 1".into(),
225 - height_pct: 42.5,
224 + cents: 1200,
226 225 value: "$12".into(),
227 226 count: 3,
228 227 }
229 228 }
230 229
230 + /// The bars against an axis they fill less than half of, so a drawn width
231 + /// could not be mistaken for a full bar.
232 + fn chart_of(bars: &[ChartBar]) -> RevenueChart {
233 + RevenueChart {
234 + most: 2400,
235 + bars: bars.to_vec(),
236 + }
237 + }
238 +
231 239 fn render(range: &str, bars: &[ChartBar], items: &[ContentItem]) -> String {
232 - let nodes = body("an-album", range, &[stat("Revenue", None)], bars, items);
240 + let chart = chart_of(bars);
241 + let nodes = body("an-album", range, &[stat("Revenue", None)], &chart, items);
233 242 let mut out = String::new();
234 - let webview = drawn(bars);
243 + let webview = Webview::new();
235 244 for node in &nodes {
236 245 out.push_str(&webview.fragment(node));
237 246 }
@@ -281,7 +290,12 @@
281 290 let without = render("30d", &[], &[]);
282 291
283 292 assert!(with.contains("class=\"chart-bars\""), "{with}");
284 - assert!(with.contains("--fill: 42.5000%"), "{with}");
293 + // Both numbers as themselves, and no width worked out here. See
294 + // `user_analytics`, which this chart is now shared with as a described
295 + // member rather than as a string of markup.
296 + assert!(with.contains("--most: 2400"), "{with}");
297 + assert!(with.contains("--value: 1200"), "{with}");
298 + assert!(!with.contains("--fill"), "{with}");
285 299
286 300 assert!(!without.contains("class=\"chart-bars\""), "{without}");
287 301 assert!(without.contains("No revenue data yet."), "{without}");
@@ -289,11 +303,16 @@
289 303
290 304 #[test]
291 305 fn the_chart_is_the_same_one_user_analytics_draws() {
292 - // Not a second chart. If these ever diverge the two tabs draw different
293 - // pictures of the same shape, which is what chart_bars.html existed to
294 - // stop and what this conversion must not undo.
295 - let shared = super::super::user_analytics::chart_markup(&[bar()]);
296 - assert!(render("30d", &[bar()], &[]).contains(&shared));
306 + // Not a second chart. The two tabs used to be held together by sharing
307 + // one markup-building function; they are held together by the
308 + // vocabulary now -- both say `chart`, and one renderer draws it -- which
309 + // is the stronger version of the same guarantee.
310 + let mine = render("30d", &[bar()], &[]);
311 + let theirs = Webview::new().fragment(
312 + &Node::chart(Chart::new(2400).label("revenue over time"))
313 + .bar(Bar::at("Aug 1").of(1200).reading("$12").note("3 sales")),
314 + );
315 + assert!(mine.contains(&theirs), "{mine}\n\n{theirs}");
297 316 }
298 317
299 318 #[test]
@@ -108,6 +108,9 @@
108 108 ("FEED", |plan| {
109 109 Node::Region(super::feeds::page_region_staged(plan))
110 110 }),
111 + ("USER_ANALYTICS", |plan| {
112 + super::user_analytics::pane_staged(plan)
113 + }),
111 114 ]
112 115 }
113 116
@@ -777,6 +780,73 @@
777 780 }
778 781 }
779 782
783 + /// The analytics tab fills to what the renderer builds, at every shape.
784 + ///
785 + /// The last screen to join the seam, and the one that needed a new member
786 + /// to do it (quasicoherent `7d6ad166`). Its revenue chart was a ceded
787 + /// region: the handler drew the markup and the renderer looked it up WHILE
788 + /// it rendered, so a residual derived from a bare `Webview` held the empty
789 + /// container and nothing at serve time could get inside it.
790 + ///
791 + /// Described, the chart is an axis and a run of bars, and what makes it
792 + /// compilable is that no renderer divides. The axis is a hole beside the
793 + /// loop and each magnitude is a hole inside it; a width worked out from the
794 + /// two would have left no stand-in to find and baked one reader's chart
795 + /// into the template. `quasi_router::stage::number_at` states the rule and
796 + /// `quasi-bench`'s `charted` proves the member against it.
797 + ///
798 + /// Four things vary here and each drops a different branch:
799 + ///
800 + /// - **The chart**, present and absent, against its own empty state.
801 + /// - **The stat cards**, which are three guarded figures -- no delta, a
802 + /// toned rise, a toned fall -- so a fill that only saw one would pass on
803 + /// a residual that had baked it.
804 + /// - **The comparison**, whose heading and table share a guard on there
805 + /// being more than one project.
806 + /// - **The totals**, against their own empty state.
807 + ///
808 + /// The range chips are crossed too, and they are the reason `latched` had
809 + /// to become guardable: which chip is held down is a fact a request brings,
810 + /// a `bool` has no stand-in, and a loop body cannot carry "exactly one of
811 + /// these differs" as a value. Said as a guard it is a clean deletion --
812 + /// `class="chip latched"` against `class="chip"` -- so it derives as a
813 + /// branch inside the loop. That is `Choice::chosen`'s answer on the other
814 + /// control; `quasi_declare::symbolic::PLACED` is where it is allowed.
815 + #[test]
816 + fn the_analytics_residual_fills_to_what_the_renderer_builds() {
817 + use crate::quasi::user_analytics::{pane, pane_serve, sample};
818 + use quasi_axum::Serves as _;
819 +
820 + // Every combination a card's delta can take, including the mixed strip
821 + // that draws two of the three guarded figures at once.
822 + let strips: &[&[Option<bool>]] = &[
823 + &[],
824 + &[None],
825 + &[Some(true)],
826 + &[Some(false)],
827 + &[None, Some(true), Some(false)],
828 + ];
829 +
830 + for bars in [0, 1, 5] {
831 + // One project is not a comparison, which is what that guard says,
832 + // so the three counts are the shapes it has.
833 + for projects in [0, 1, 3] {
834 + for totals in [0, 2] {
835 + for deltas in strips {
836 + let read = sample(bars, projects, totals, deltas);
837 + assert_eq!(
838 + pane_serve(&USER_ANALYTICS, &read),
839 + Webview::new().fragment(&pane(&read)),
840 + "analytics, {bars} bars, {projects} projects, \
841 + {totals} totals, {} cards",
842 + deltas.len(),
843 + );
844 + }
845 + }
846 + }
847 + }
848 + }
849 +
780 850 /// The git listing fills to what the renderer builds, at every page shape.
781 851 ///
782 852 /// The first residual holding a **described pager** (quasicoherent
@@ -960,13 +1030,17 @@
960 1030
961 1031 /// The addresses still served by building a `Node` per request.
962 1032 ///
963 - /// - `/dashboard/tabs/analytics`: its revenue chart is a ceded region,
964 - /// whose markup the renderer looks up while it renders, so there is
965 - /// nowhere in a residual for it to go. Ruled 2026-09-08: the answer
966 - /// is to describe the chart rather than to compile around it, so this
967 - /// screen stays here until a chart is in the vocabulary.
968 - /// quasicoherent `7d6ad166`.
969 - const OFF_THE_SEAM: &[&str] = &[crate::quasi::user_analytics::PATH];
1033 + /// **Empty, and that is the point.** Every described screen serves from
1034 + /// a generated template. The last one off it was
1035 + /// `/dashboard/tabs/analytics`, whose revenue chart was a ceded region
1036 + /// the renderer looked up while it rendered; ruled 2026-09-08, the
1037 + /// chart was described rather than compiled around, and the screen
1038 + /// joined. quasicoherent `7d6ad166`.
1039 + ///
1040 + /// The list stays rather than going, because what it is for is a screen
1041 + /// that CANNOT join saying so out loud. An empty one is the claim that
1042 + /// none is in that position today.
1043 + const OFF_THE_SEAM: &[&str] = &[];
970 1044
971 1045 let described = PATHS.len() + DOCUMENT_PATHS.len() + PUBLIC_DOCUMENT_PATHS.len();
972 1046
@@ -1007,8 +1081,9 @@
1007 1081 /// Screens with their own filling test: `/use-cases`, `/fan-plus`,
1008 1082 /// `/c/{username}/{slug}`, `/dashboard/export`, `/git/{owner}`, and the
1009 1083 /// two forum panes, the two contact panes, the payout card,
1010 - /// `/creators`, `/dashboard/tabs/ssh-keys`, `/git` and `/feed`.
1011 - const HOLED: usize = 14;
1084 + /// `/creators`, `/dashboard/tabs/ssh-keys`, `/git`, `/feed` and
1085 + /// `/dashboard/tabs/analytics`.
1086 + const HOLED: usize = 15;
1012 1087
1013 1088 assert_eq!(
1014 1089 roster().len(),
@@ -21,12 +21,16 @@
21 21 //! landed anyway because the alternative was folding the delta into the
22 22 //! caption, which loses the tone and turns a small second line into a longer
23 23 //! first one. makeover-layout 0.13.0, makeover-webview 0.24.0.
24 - //! 2. **A bar chart is not describable, and should not be.** makeover-layout
25 - //! names no chart, and the admission test is that a node composes something
26 - //! it already names. Inventing one there would be the layer naming a widget.
27 - //! So the chart is a [`quasi_router::RegionKind::Ceded`] fill, which is
28 - //! exactly what bespoke regions are for, and it is the first one in the
29 - //! tree. See [`chart_markup`].
24 + //! 2. **A bar chart IS describable, and this screen is why.** It was not, for a
25 + //! while: makeover-layout named no chart, and the admission test is that a
26 + //! node composes something it already names, so the chart was a
27 + //! `RegionKind::Ceded` fill -- the first and last one in the tree. That put
28 + //! this screen alone off the compiled-template seam, because a ceded
29 + //! region's markup is looked up WHILE the renderer renders and a residual
30 + //! has nowhere to keep it. Max ruled on 2026-09-08 that a bespoke region is
31 + //! the mark of a screen the description layer has not finished converting,
32 + //! so the chart was described instead: `makeover_layout::Chart` and `Bar`,
33 + //! drawn by each renderer. quasicoherent `7d6ad166`.
30 34 //! 3. **A proportion bar inside a table cell is one site.** The comparison
31 35 //! table's revenue cell draws a bar behind the number. One site across every
32 36 //! template, so it does not earn a `Cell::meter` and the cell is the number
@@ -42,12 +46,10 @@
42 46 //! for a held-down chip. Each carries the range it selects, so the address a
43 47 //! reader lands on is the view they are looking at.
44 48
45 - use std::fmt::Write as _;
46 -
47 49 use makeover_layout as layout;
48 50 use quasi_declare::declare;
49 - use quasi_router::screen::Figure;
50 - use quasi_router::{RegionKind, Request, Response, RouteError};
51 + use quasi_router::screen::{Bar, Chart, Figure};
52 + use quasi_router::{Request, Response, RouteError};
51 53 use quasi_webview::Webview;
52 54
53 55 use super::Viewer;
@@ -70,9 +72,6 @@
70 72 /// constant and a test there asserts the two agree.
71 73 pub const REGION: &str = "user-analytics";
72 74
73 - /// The slot the chart's own markup mounts into.
74 - const CHART_SLOT: &str = "analytics-chart";
75 -
76 75 /// One stat card, as the screen needs it.
77 76 pub struct StatView {
78 77 label: String,
@@ -101,7 +100,7 @@
101 100 pub struct Analytics {
102 101 range: String,
103 102 stats: Vec<StatView>,
104 - bars: Vec<crate::types::ChartBar>,
103 + chart: crate::types::RevenueChart,
105 104 projects: Vec<ProjectView>,
106 105 totals: Vec<TotalView>,
107 106 }
@@ -122,10 +121,6 @@
122 121
123 122 let analytics = read(viewer, &range)?;
124 123
125 - // Handed to the renderer through the state both of them see. The chart is
126 - // the app's own markup and there is no node for it; see the module header.
127 - viewer.fill(CHART_SLOT, chart_markup(&analytics.bars));
128 -
129 124 Ok(Response::fragment(REGION, pane(&analytics)))
130 125 }
131 126
@@ -152,11 +147,11 @@
152 147 ))
153 148 .map_err(failed)?;
154 149
155 - // `build_chart_bars` already produces exactly what the chart draws. This
156 - // remapped it into a private `BarView` with the same four fields, which
157 - // meant `project_analytics` could not reuse the chart without a third copy
158 - // of the type. Dropped 2026-08-26 when that screen converted.
159 - let bars = crate::routes::pages::dashboard::build_chart_bars(&buckets, currency);
150 + // `build_revenue_chart` already produces exactly what the chart draws. This
151 + // remapped it into a private `BarView` with the same fields, which meant
152 + // `project_analytics` could not reuse the chart without a third copy of the
153 + // type. Dropped 2026-08-26 when that screen converted.
154 + let chart = crate::routes::pages::dashboard::build_revenue_chart(&buckets, currency);
160 155
161 156 let view_change = db::analytics::pct_change(current_views, prev_views);
162 157 let mut stats = vec![
@@ -246,7 +241,7 @@
246 241 Ok(Analytics {
247 242 range: range.to_string(),
248 243 stats,
249 - bars,
244 + chart,
250 245 projects,
251 246 totals,
252 247 })
@@ -258,7 +253,8 @@
258 253 /// One project is not a comparison, which is why that section carries the
259 254 /// same guard twice: the heading and the table are two members and both are
260 255 /// absent together.
261 - shape pane(analytics: &Analytics) -> Node;
256 + #[staged]
257 + pub(crate) shape pane(analytics: &Analytics) -> Node;
262 258
263 259 region REGION as Pane {
264 260 section super::range_heading(&analytics.range);
@@ -269,10 +265,15 @@
269 265
270 266 section "Revenue Over Time";
271 267 empty "Once you publish items and make sales, revenue data will appear here."
272 - when analytics.bars.is_empty();
273 - // The chart's own markup arrives through the renderer. The description
274 - // says only that there is a region here and what it is called.
275 - region CHART_SLOT as RegionKind::ceded("revenue-chart") unless analytics.bars.is_empty() {}
268 + when analytics.chart.bars.is_empty();
269 + chart Chart::new(analytics.chart.most).label("revenue over time") unless analytics.chart.bars.is_empty() {
270 + for bar in analytics.chart.bars.iter() {
271 + bar Bar::at(bar.label.clone())
272 + .of(bar.cents)
273 + .reading(bar.value.clone())
274 + .note(sales(bar.count));
275 + }
276 + }
276 277
277 278 section "Project Comparison" when analytics.projects.len() over 1;
278 279 include comparison(&analytics.projects) when analytics.projects.len() over 1;
@@ -286,11 +287,12 @@
286 287
287 288 declare! {
288 289 /// The range selector: one chip per range, the current one held down.
290 + #[staged]
289 291 shape range_chips(range: &str) -> Vec<Node>;
290 292
291 293 for window in super::RANGES {
292 294 chip window.value to get PATH carrying "range" window.value {
293 - latched super::is_shown(window, range);
295 + latched when super::is_shown(window, range);
294 296 }
295 297 }
296 298 }
@@ -300,34 +302,32 @@
300 302 stat.change.as_deref().unwrap_or_default()
301 303 }
302 304
303 - /// The tone rides on the delta, which is why a card without one stays neutral
304 - /// rather than being coloured green for having nothing to report.
305 - ///
306 - /// `positive` is `true` by default in the source data, so toning on it alone
307 - /// would paint every unchanged card.
308 - fn delta_tone(stat: &StatView) -> layout::Tone {
309 - if stat.positive {
310 - layout::Tone::Success
311 - } else {
312 - layout::Tone::Danger
313 - }
314 - }
315 -
316 305 declare! {
317 306 /// The figures across the top.
318 307 ///
319 308 /// The empty list is what the figures accrete onto: `Node::stats` takes the
320 - /// whole list and this one is built a card at a time. See [`delta_tone`].
309 + /// whole list and this one is built a card at a time.
310 + #[staged]
321 311 shape stats(stats: &[StatView]) -> Node;
322 312
323 313 stats [] {
324 314 for stat in stats.iter() {
315 + // Three, one per tone a delta can carry, because a tone is not a
316 + // value a residual can hold: `Tone` has no stand-in, so a supplier
317 + // answering one hands the derivation a sentinel where an enum
318 + // belongs. Written out, each tone is a path the derivation bakes and
319 + // the guards are what a request picks between. `symbolic::PLACED`
320 + // names this site.
325 321 figure Figure::new(stat.value.clone(), stat.label.clone())
326 322 when stat.change.is_none();
327 323 figure Figure::new(stat.value.clone(), stat.label.clone())
328 324 .change(change(stat))
329 - .tone(delta_tone(stat))
330 - unless stat.change.is_none();
325 + .tone(layout::Tone::Success)
326 + when stat.change.is_some() and stat.positive;
327 + figure Figure::new(stat.value.clone(), stat.label.clone())
328 + .change(change(stat))
329 + .tone(layout::Tone::Danger)
330 + when stat.change.is_some() and not stat.positive;
331 331 }
332 332 }
333 333 }
@@ -345,6 +345,7 @@
345 345 /// The revenue cell is the number alone. The template drew a bar behind it
346 346 /// scaled against the biggest earner, and that is one site in the whole
347 347 /// template set, so it does not earn a member. See the module header.
348 + #[staged]
348 349 shape comparison(projects: &[ProjectView]) -> Node;
349 350
350 351 table {
@@ -381,6 +382,7 @@
381 382
382 383 declare! {
383 384 /// All-time revenue per project.
385 + #[staged]
384 386 shape totals(totals: &[TotalView]) -> Node;
385 387
386 388 list {
@@ -392,64 +394,98 @@
392 394 }
393 395 }
394 396
395 - /// The chart, as markup, because no description names one.
397 + /// One reading of the tab, as the tests draw it.
396 398 ///
397 - /// Byte-for-byte the structure `templates/partials/chart_bars.html` emits, so
398 - /// the existing `.chart-*` rules in `style.css` draw it unchanged and the
399 - /// described screen and the Askama one are the same chart rather than two that
400 - /// drifted.
399 + /// `pub(crate)` so `residuals` can fill the compiled template against the
400 + /// renderer at every shape this screen takes; `ssh_keys::sample_key` is the
401 + /// same arrangement for the same reason.
401 402 ///
402 - /// Everything interpolated here is escaped. A bespoke region is not escaped by
403 - /// the renderer, which is what makes it bespoke, so the escaping is this
404 - /// function's job and a label reaching it from a database is exactly why.
405 - pub(super) fn chart_markup(bars: &[crate::types::ChartBar]) -> String {
406 - let mut html = String::from("<div class=\"chart-bars\">");
407 - for bar in bars {
408 - let plural = if bar.count == 1 { "" } else { "s" };
409 - let _ = write!(
410 - html,
411 - "<div class=\"chart-bar-col\" data-tooltip=\"{} / {} sale{plural}\">\
412 - <div class=\"chart-bar\" style=\"--fill: {}%;\"></div>\
413 - <div class=\"chart-bar-label\">{}</div></div>",
414 - escape(&bar.value),
415 - bar.count,
416 - // A float straight from the database, so it is formatted rather
417 - // than printed: `{:?}` on an f64 can emit an exponent, and
418 - // `--fill: 1e-7%` is not a length any browser accepts.
419 - format_args!("{:.4}", bar.height_pct),
420 - escape(&bar.label),
421 - );
403 + /// `deltas` picks what the stat cards report, because the three figures are
404 + /// three guarded members -- one per tone -- and a fill that only ever saw one
405 + /// of them would pass on a residual that had baked it.
406 + #[cfg(test)]
407 + pub(crate) fn sample(
408 + bars: usize,
409 + projects: usize,
410 + totals: usize,
411 + deltas: &[Option<bool>],
412 + ) -> Analytics {
413 + let cents: Vec<usize> = (0..bars).map(|n| (n + 1) * 137).collect();
414 + Analytics {
415 + range: "30d".into(),
416 + stats: deltas
417 + .iter()
418 + .enumerate()
419 + .map(|(n, delta)| StatView {
420 + label: format!("Stat {n}"),
421 + value: format!("{n}00"),
422 + change: delta.map(|up| if up { "+1.0%".into() } else { "-1.0%".into() }),
423 + positive: delta.unwrap_or(true),
424 + })
425 + .collect(),
426 + chart: crate::types::RevenueChart {
427 + most: cents.iter().copied().max().unwrap_or(1).max(1),
428 + bars: cents
429 + .iter()
430 + .enumerate()
431 + .map(|(n, value)| crate::types::ChartBar {
432 + label: format!("Aug {}", n + 1),
433 + cents: *value,
434 + value: crate::formatting::format_revenue(
435 + i64::try_from(*value).unwrap_or(i64::MAX),
436 + crate::currency::SettlementCurrency::default(),
437 + ),
438 + count: n as i64,
439 + })
440 + .collect(),
441 + },
442 + projects: (0..projects)
443 + .map(|n| ProjectView {
444 + title: format!("Project {n}"),
445 + revenue: format!("${n}0.00"),
446 + sales: n.to_string(),
447 + views: format!("{n}00"),
448 + conversion: format!("{n}.1%"),
449 + })
450 + .collect(),
451 + totals: (0..totals)
452 + .map(|n| TotalView {
453 + title: format!("Project {n}"),
454 + revenue: format!("${n}80.00"),
455 + })
456 + .collect(),
422 457 }
423 - html.push_str("</div>");
424 - html
425 - }
426 -
427 - /// The five characters that matter in markup and in an attribute value.
428 - fn escape(text: &str) -> String {
429 - text.replace('&', "&amp;")
430 - .replace('<', "&lt;")
431 - .replace('>', "&gt;")
432 - .replace('"', "&quot;")
433 - .replace('\'', "&#39;")
434 458 }
435 459
460 + /// What a bar's sale count says, worded.
461 + ///
462 + /// The description carries this already worded rather than carrying the number
463 + /// and a noun, because the noun inflects with the count and a renderer that
464 + /// pluralised would be growing a lexer for one language. `makeover_layout::Bar`
465 + /// says the same thing from the other side.
436 466 /// The renderer this screen is drawn with.
437 467 ///
438 - /// Mounts whatever the handler drew for its bespoke regions. The two see one
439 - /// `Viewer`; see [`super::Viewer::fills`].
468 + /// The plain one every other converted screen uses. It carried a loop over the
469 + /// handler's drawn markup while the chart was a ceded region; the chart is
470 + /// described now, so there is nothing bespoke left to mount.
440 471 pub fn renderer(viewer: &Viewer) -> Webview {
441 - let mut webview = Webview::new().with_shell(viewer.shell());
442 - for (slot, markup) in viewer.drawn() {
443 - webview = webview.with_fill(slot, markup);
472 + Webview::new().with_shell(viewer.shell())
473 + }
474 +
475 + /// What a bar's sale count says, worded.
476 + pub(super) fn sales(count: i64) -> String {
477 + if count == 1 {
478 + "1 sale".to_string()
479 + } else {
480 + format!("{count} sales")
444 481 }
445 - webview
446 482 }
447 483
448 484 #[cfg(test)]
449 485 mod tests {
450 486 use super::*;
451 487 use quasi_axum::Serves;
452 - use quasi_router::{Node, Slot};
488 + use quasi_router::{Node, RegionKind, Slot};
453 489
454 490 fn analytics() -> Analytics {
455 491 Analytics {
@@ -468,12 +504,15 @@
468 504 positive: true,
469 505 },
470 506 ],
471 - bars: vec![crate::types::ChartBar {
472 - label: "Aug 1".into(),
473 - value: "$42.00".into(),
474 - count: 3,
475 - height_pct: 62.5,
476 - }],
507 + chart: crate::types::RevenueChart {
508 + most: 6720,
509 + bars: vec![crate::types::ChartBar {
510 + label: "Aug 1".into(),
511 + value: "$42.00".into(),
512 + count: 3,
513 + cents: 4200,
514 + }],
515 + },
477 516 projects: vec![
478 517 ProjectView {
479 518 title: "Atlas".into(),
@@ -551,39 +590,40 @@
551 590 }
552 591
553 592 #[test]
554 - fn the_chart_is_the_markup_the_template_already_emits() {
555 - // The described screen and the Askama one draw one chart, against one
556 - // set of `.chart-*` rules. If this structure drifts the two diverge
557 - // silently, because nothing else renders it.
558 - let html = chart_markup(&analytics().bars);
593 + fn the_chart_hands_over_both_numbers_and_computes_no_width() {
594 + // The property the described chart exists for. A width worked out here
595 + // would be baked into the compiled template as one request's constant,
596 + // so the axis and each magnitude have to reach the markup as
597 + // themselves. `quasi_router::stage::number_at` is where this is
598 + // enforced from the other side.
599 + let html = render(&pane(&analytics()));
559 600
560 - assert!(html.contains("class=\"chart-bars\""), "{html}");
561 - assert!(html.contains("class=\"chart-bar-col\""), "{html}");
562 - assert!(html.contains("--fill: 62.5000%"), "{html}");
601 + assert!(html.contains("--most: 6720"), "{html}");
602 + assert!(html.contains("--value: 4200"), "{html}");
603 + assert!(!html.contains("--fill"), "{html}");
563 604 assert!(html.contains("3 sales"), "{html}");
564 -
565 - // The template's own pluralisation, which a described copy is easy to
566 - // get wrong in exactly one direction.
567 - let one = chart_markup(&[crate::types::ChartBar {
568 - label: "Aug 2".into(),
569 - count: 1,
570 - ..analytics().bars.pop().expect("one bar")
571 - }]);
572 - assert!(one.contains("1 sale ") || one.contains("1 sale\""), "{one}");
573 605 }
574 606
575 607 #[test]
576 - fn a_bespoke_fill_is_the_apps_markup_and_still_escapes_its_data() {
577 - // A bespoke region is not escaped by the renderer, which is the whole
578 - // of what makes it bespoke. A bar's label is a formatted date today and
579 - // its value comes from the database, so the escaping is this screen's
580 - // job and nothing else will do it.
581 - let html = chart_markup(&[crate::types::ChartBar {
582 - label: "<script>x()</script>".into(),
583 - value: "\" onload=\"x()".into(),
584 - count: 1,
585 - height_pct: 10.0,
586 - }]);
608 + fn a_count_of_one_is_worded_as_one() {
609 + // The pluralisation the Askama template does with an `{% if %}`, which
610 + // is the description's job here: a renderer that inflected a noun would
611 + // be growing a lexer for one language.
612 + assert_eq!(sales(1), "1 sale");
613 + assert_eq!(sales(0), "0 sales");
614 + assert_eq!(sales(3), "3 sales");
615 + }
616 +
617 + #[test]
618 + fn a_label_and_a_reading_are_escaped_by_the_renderer() {
619 + // What the ceded region made this screen do for itself. The chart is a
620 + // described member now, so the renderer escapes it like every other
621 + // value, and this is the test that the conversion did not quietly drop
622 + // the protection along with the bespoke markup.
623 + let mut hostile = analytics();
624 + hostile.chart.bars[0].label = "<script>x()</script>".into();
625 + hostile.chart.bars[0].value = "\" onload=\"x()".into();
626 + let html = render(&pane(&hostile));
587 627
588 628 assert!(!html.contains("<script>x()"), "{html}");
589 629 assert!(!html.contains("\" onload="), "{html}");
@@ -606,7 +646,10 @@
606 646 let empty = Analytics {
607 647 range: "30d".into(),
608 648 stats: vec![],
609 - bars: vec![],
649 + chart: crate::types::RevenueChart {
650 + most: 1,
651 + bars: vec![],
652 + },
610 653 projects: vec![],
611 654 totals: vec![],
612 655 };
@@ -614,8 +657,8 @@
614 657
615 658 assert!(html.contains("revenue data will appear here."), "{html}");
616 659 assert!(html.contains("Sales across your projects"), "{html}");
617 - // And the chart region is absent rather than empty, so the stand-in is
618 - // not sitting next to a blank chart.
619 - assert!(!html.contains(CHART_SLOT), "{html}");
660 + // And the chart is absent rather than empty, so the stand-in is not
661 + // sitting next to a blank chart.
662 + assert!(!html.contains("chart-bars"), "{html}");
620 663 }
621 664 }