Skip to main content

majordomus_cli/ui/
mod.rs

1//! `majordomus ui`: the primary surface, a product of its own, server-rendered (ADR 0135).
2//!
3//! Two surfaces of this process are for a person. The UI (`/ui`) is the product: the
4//! delivery in the project's own words — how the work stands, what is planned and blocked,
5//! what is in the queue, which release is live, who is on the mesh, what holds as evidence
6//! and which rules are proven. The Cockpit (`/cockpit`) is the technical layer beneath it,
7//! the registry rendered, and the UI refers to it only as the low-level reference of a value
8//! ("technical view" on a source line).
9//!
10//! It is a projection, never an application. Every screen composes capabilities through the
11//! executor every transport calls, and every value carries the capability and pointer it
12//! was read from (`source`): inferred, derived and projected, never kept. It holds no data,
13//! no status vocabulary and no route list beyond `SCREENS` and `PANELS`, which a test
14//! holds against the dispatcher. It shares mechanics rather than copying them:
15//! - the element builder and the asset server, pointed at `share/ui/`;
16//! - the content-security policy, with its one hash-pinned inline script;
17//! - the design tokens, the kit and the icon set every surface draws from.
18//!
19//! ```mermaid
20//! flowchart LR
21//!   declaration["capability!"] --> registry["registry"] --> executor["executor"]
22//!   executor --> api["MCP, HTTP, command line"]
23//!   executor --> ui["UI · /ui<br/>(the product)"]
24//!   executor --> cockpit["Cockpit · /cockpit<br/>(its technical reference)"]
25//! ```
26//!
27//! With no JavaScript, every screen is complete HTML and the menu still opens. The one
28//! script adds two things: it pulls in the panels whose sources are slow, which are
29//! otherwise reached by their own links, and it reveals the theme toggle.
30//!
31//! The UI is mounted on its own prefix, named in the web topology and among the local
32//! services by one id, and is a surface apart from the Cockpit:
33//!
34//! ```
35//! use majordomus_cli::ui::{PREFIX, SURFACE};
36//! assert_eq!(PREFIX, "/ui");
37//! assert_eq!(SURFACE, "ui");
38//! assert_ne!(PREFIX, majordomus_cli::cockpit::PREFIX, "the Cockpit is another surface");
39//! ```
40
41pub(crate) mod capabilities;
42pub(crate) mod claim;
43pub(crate) mod command;
44pub(crate) mod dashboard;
45pub(crate) mod delivery;
46pub(crate) mod deployments;
47pub(crate) mod docs;
48pub(crate) mod evidence;
49pub(crate) mod governance;
50pub(crate) mod icons;
51pub(crate) mod idd;
52pub(crate) mod intent;
53pub(crate) mod issues;
54pub(crate) mod knowledge;
55pub(crate) mod mesh;
56pub(crate) mod milestone;
57pub(crate) mod navbar;
58pub(crate) mod observatory;
59pub(crate) mod prose;
60pub(crate) mod prs;
61pub(crate) mod roadmap;
62pub(crate) mod rule;
63pub(crate) mod shell;
64pub(crate) mod skills;
65pub(crate) mod source;
66pub(crate) mod timings;
67pub(crate) mod view;
68pub(crate) mod work;
69
70use std::sync::Arc;
71
72use crate::cockpit::assets::Assets;
73use crate::http::router::{Request, Response};
74use crate::live::{IntoLive, Live};
75
76/// Every UI route starts here.
77pub const PREFIX: &str = "/ui";
78
79/// The UI's id in the web topology and among the local services, the one name every
80/// reader of "which surface is the UI" asks.
81pub const SURFACE: &str = "ui";
82
83/// The directory of the distribution's `share/` that holds the UI's files.
84pub const ASSETS_DIR: &str = "ui";
85
86/// The URL prefix the UI's files are served under.
87pub const ASSETS_PREFIX: &str = "/ui/assets/";
88
89/// The UI's favicon, written from the canonical mark by `majordomus generate`.
90pub const FAVICON: &str = "share/ui/favicon.svg";
91
92/// The stylesheet every screen links.
93pub const STYLESHEET: &str = "ui.css";
94
95/// The script every screen loads.
96pub const SCRIPT: &str = "ui.js";
97/// The site's own scripts the bar every screen wears behaves by — its menus, then its theme
98/// toggle — served as byte copies of `site/nav.js` and `site/theme.js` that
99/// `scripts/cockpit-assets` writes and checks ([`navbar`]).
100pub(crate) const BAR_SCRIPTS: &[&str] = &["nav.js", "theme.js"];
101
102/// The vendored graph library, loaded only by a screen that draws a graph.
103pub const GRAPH_LIB: &str = "vendor/cytoscape.min.js";
104
105/// The query parameter that asks a panel route for the panel alone, without the frame:
106/// what the script fetches to fill a slow panel in place.
107pub const FRAGMENT: &str = "fragment";
108
109/// One screen of the UI: where it is, what the navigation calls it, its icon in the kit's
110/// set, and the question it answers.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub(crate) struct Screen {
113    /// The route.
114    pub route: &'static str,
115    /// The navigation label, in the project's vocabulary.
116    pub title: &'static str,
117    /// The kit icon drawn beside the label.
118    pub icon: &'static str,
119    /// One line: the question the screen answers.
120    pub answers: &'static str,
121}
122
123/// The screens there are, in the order the navigation shows them. A screen of the owner's
124/// concept that has no route yet is not listed, because a link to nothing is a dead end
125/// (`every_screen_is_answered_and_every_answered_route_is_a_screen_or_a_panel`).
126pub(crate) const SCREENS: &[Screen] = &[
127    Screen {
128        route: PREFIX,
129        title: "Dashboard",
130        icon: "home",
131        answers: "How the delivery stands: health, release, plan, mesh and the queue.",
132    },
133    Screen {
134        route: command::ROUTE,
135        title: "Command",
136        icon: "terminal",
137        answers: "Everything that can be run, what it changes, and what is running.",
138    },
139    Screen {
140        route: idd::ROUTE,
141        title: "Intent workspace",
142        icon: "list-checks",
143        answers: "One intent and what serves it: milestones, issues, waves, dependencies and the next move.",
144    },
145    Screen {
146        route: roadmap::ROUTE,
147        title: "Roadmap",
148        icon: "target",
149        answers: "Which milestones are open, in what order, and which intents they serve.",
150    },
151    Screen {
152        route: work::ROUTE,
153        title: "Issues & PRs",
154        icon: "merge",
155        answers: "Which issues are ready or blocked, and what the pull-request queue would do.",
156    },
157    Screen {
158        route: issues::ROUTE,
159        title: "Issues",
160        icon: "list",
161        answers: "Every issue of the plan, by status, milestone, priority or text.",
162    },
163    Screen {
164        route: prs::ROUTE,
165        title: "PR Drain",
166        icon: "branch",
167        answers: "Every open pull request, what the lane would do with it, and why.",
168    },
169    Screen {
170        route: capabilities::ROUTE,
171        title: "Capabilities",
172        icon: "puzzle",
173        answers: "Everything the product can be asked, and on which surfaces.",
174    },
175    Screen {
176        route: skills::ROUTE,
177        title: "Skills",
178        icon: "sparkles",
179        answers: "What an agent can be asked by name, and where each name comes from.",
180    },
181    Screen {
182        route: mesh::ROUTE,
183        title: "Mesh",
184        icon: "nodes",
185        answers: "Which machines and runtimes are on the mesh, and what they recorded.",
186    },
187    Screen {
188        route: delivery::ROUTE,
189        title: "CI/CD & Releases",
190        icon: "layers",
191        answers: "What was released, what the tree declares, and what the CI model runs.",
192    },
193    Screen {
194        route: deployments::ROUTE,
195        title: "Deployments",
196        icon: "server",
197        answers: "What is live, and whether it is what was built.",
198    },
199    Screen {
200        route: evidence::ROUTE,
201        title: "Evidence",
202        icon: "file-check",
203        answers: "Which claims hold, which are stale or untested, and why.",
204    },
205    Screen {
206        route: knowledge::ROUTE,
207        title: "Knowledge",
208        icon: "sparkles",
209        answers: "What the work left for review, and whether the deriver is still writing.",
210    },
211    Screen {
212        route: docs::ROUTE,
213        title: "Documentation",
214        icon: "book",
215        answers: "What the product does, and where it is written down.",
216    },
217    Screen {
218        route: governance::ROUTE,
219        title: "Governance",
220        icon: "shield-check",
221        answers: "Which rules bind this repository and how each is proven.",
222    },
223    Screen {
224        route: observatory::ROUTE,
225        title: "Observatory",
226        icon: "eye",
227        answers: "What the repository and its mesh are doing, drawn as rotating scenes for a wall display.",
228    },
229];
230
231/// The routes that answer one panel of a screen, each also a page of its own for a reader
232/// without JavaScript.
233pub(crate) const PANELS: &[&str] = &[
234    dashboard::PULL_REQUESTS_PANEL,
235    deployments::DELIVERY_PANEL,
236    prs::PANEL,
237];
238
239/// The UI over the repository as it is now.
240pub(crate) struct Ui {
241    live: Arc<Live>,
242    version: &'static str,
243    assets: Assets,
244    /// Whether this UI's screens have been read once behind its first reader.
245    warmed: std::sync::atomic::AtomicBool,
246}
247
248impl Ui {
249    /// A UI over a view of the repository, serving its assets from `share_dir/ui`.
250    pub(crate) fn new(
251        live: impl IntoLive,
252        version: &'static str,
253        share_dir: Option<&std::path::Path>,
254    ) -> Self {
255        let assets = match share_dir {
256            Some(dir) => Assets::under(dir, ASSETS_DIR, ASSETS_PREFIX),
257            None => Assets::none_under(ASSETS_DIR, ASSETS_PREFIX),
258        };
259        if !assets.present() {
260            tracing::warn!(
261                "the distribution has no share/ui/ directory: the UI will serve unstyled markup; run scripts/cockpit-assets to build it"
262            );
263        }
264        Ui {
265            live: live.into_live(),
266            version,
267            assets,
268            warmed: std::sync::atomic::AtomicBool::new(false),
269        }
270    }
271
272    /// Read every screen and every panel once, behind the first reader of this UI, so that
273    /// the screen they open next has its sources read before they ask for it.
274    ///
275    /// The routes are [`SCREENS`] and [`PANELS`], the lists the navigation and the
276    /// dispatcher are held to, so a screen added tomorrow is warmed with no edit here. What
277    /// each request renders is discarded: its effect is that the sources it names are now
278    /// remembered and kept current (`crate::capability::readings`). A process that runs
279    /// every capability without its cache does not warm, and neither do the crate's own
280    /// unit tests, whose fixtures are gone before a background reader would finish.
281    pub(crate) fn warm_once(ui: &Arc<Ui>) {
282        use std::sync::atomic::Ordering;
283        if cfg!(test) || crate::capability::executor::bypassed() {
284            return;
285        }
286        if ui.warmed.swap(true, Ordering::SeqCst) {
287            return;
288        }
289        let ui = Arc::clone(ui);
290        let warm = move || {
291            let routes = SCREENS
292                .iter()
293                .map(|s| s.route)
294                .chain(PANELS.iter().copied());
295            for route in routes {
296                let _ = ui.handle(&Request::parse_target("GET", route, Vec::new()));
297            }
298        };
299        if let Err(refused) = std::thread::Builder::new()
300            .name("mj-ui-warm".into())
301            .spawn(warm)
302        {
303            tracing::warn!("no thread to warm the UI's screens: {refused}");
304        }
305    }
306
307    /// Does this request belong to the UI? The prefix itself and every path below it, and
308    /// nothing that merely starts with the same letters.
309    #[cfg(test)]
310    pub(crate) fn owns(path: &str) -> bool {
311        path == PREFIX || path.starts_with(&format!("{PREFIX}/"))
312    }
313
314    /// Answer one request under [`PREFIX`].
315    pub(crate) fn handle(&self, req: &Request) -> Response {
316        if req.method != "GET" {
317            return Response::error(
318                405,
319                "method_not_allowed",
320                "the UI's screens are read with GET; a capability that changes something is called on its own route",
321            );
322        }
323        if let Some(name) = req.path.strip_prefix(ASSETS_PREFIX) {
324            return self.assets.respond(name, req);
325        }
326        // one reading of the repository for the whole screen: the generation there is, never
327        // a wait for the next one (ADR 0142); a screen drawn from a generation the repository
328        // has moved past says so, and the page's script reads it again
329        let (reading, behind) = self.live.view_without_waiting();
330        let ctx = reading.ctx;
331        let fragment = req.query.iter().any(|(k, _)| k == FRAGMENT);
332        let path = req.path.trim_end_matches('/');
333        // an earlier or mistyped name leads to the route it meant, query kept
334        if let Some((_, target)) = observatory::REDIRECTS.iter().find(|(old, _)| *old == path) {
335            let query = req
336                .query
337                .iter()
338                .map(|(k, v)| format!("{k}={v}"))
339                .collect::<Vec<_>>()
340                .join("&");
341            let to = match query.is_empty() {
342                true => target.to_string(),
343                false => format!("{target}?{query}"),
344            };
345            return Response::new(308, "text/plain; charset=utf-8", format!("{to}\n"))
346                .with_header("Location", to);
347        }
348        // the kiosk is the Observatory's screen under a second name, never a second screen
349        let kiosk = path == observatory::KIOSK;
350        let path = if kiosk { observatory::ROUTE } else { path };
351        let mut screen = match path {
352            "" | PREFIX => dashboard::screen(&ctx),
353            idd::ROUTE => idd::screen(&ctx, &req.query),
354            roadmap::ROUTE => roadmap::screen(&ctx, &req.query),
355            work::ROUTE => work::screen(&ctx, &req.query),
356            issues::ROUTE => issues::screen(&ctx, &req.query),
357            prs::ROUTE => prs::screen(&ctx, &req.query),
358            mesh::ROUTE => mesh::screen(&ctx, &req.query),
359            delivery::ROUTE => delivery::screen(&ctx),
360            deployments::ROUTE => deployments::screen(&ctx),
361            command::ROUTE => command::screen(&ctx, &req.query),
362            capabilities::ROUTE => capabilities::screen(&ctx, &req.query),
363            skills::ROUTE => skills::screen(&ctx),
364            docs::ROUTE => docs::screen(&ctx),
365            evidence::ROUTE => evidence::screen(&ctx, &req.query),
366            knowledge::ROUTE => knowledge::screen(&ctx),
367            governance::ROUTE => governance::screen(&ctx, &req.query),
368            observatory::ROUTE => {
369                let libs = observatory::Libs {
370                    director: self.assets.url(observatory::LIB),
371                    scenes: self.assets.url(observatory::SCENES_LIB),
372                    p5: self.assets.url(observatory::P5_LIB),
373                };
374                observatory::screen(&ctx, &req.query, &libs, kiosk)
375            }
376            dashboard::PULL_REQUESTS_PANEL => dashboard::pull_requests_panel(&ctx, &req.query),
377            deployments::DELIVERY_PANEL => deployments::delivery_panel(&ctx),
378            prs::PANEL => prs::panel_view(&ctx, &req.query),
379            other => shell::not_found(other),
380        };
381        if behind {
382            screen.main = crate::cockpit::html::el("div")
383                .class("mj-ui-stack")
384                .child(
385                    crate::cockpit::html::el("p")
386                        .class("mj-ui-dim")
387                        .attr(view::CATCHING_UP, "")
388                        .text("The repository changed: this screen shows it as it was, and is being read again."),
389                )
390                .child(screen.main);
391        }
392        // A panel read as a page of its own has no hero, so it carries the page's one heading
393        // itself; the fragment a screen pulls in does not, or the screen would have two.
394        if !fragment && PANELS.contains(&path) {
395            screen.main = crate::cockpit::html::el("div")
396                .class("mj-ui-stack")
397                .child(
398                    crate::cockpit::html::el("h1")
399                        .class("mj-ui-title")
400                        .text(&screen.title),
401                )
402                .child(screen.main);
403        }
404        let body = if fragment {
405            screen.main.render()
406        } else {
407            shell::page(
408                &ctx,
409                &shell::Frame {
410                    current: path,
411                    stylesheet: self.assets.url(STYLESHEET),
412                    favicon: self.assets.url("favicon.svg"),
413                    script: self.assets.url(SCRIPT),
414                    bar_scripts: BAR_SCRIPTS.iter().map(|s| self.assets.url(s)).collect(),
415                    graph_lib: self.assets.url(GRAPH_LIB),
416                    assets_present: self.assets.present(),
417                    version: self.version,
418                },
419                &screen,
420            )
421        };
422        let mut response = Response::new(screen.status, "text/html; charset=utf-8", body);
423        for (name, value) in [
424            ("Cache-Control", "no-cache"),
425            ("Content-Security-Policy", crate::cockpit::csp()),
426            ("X-Content-Type-Options", "nosniff"),
427            ("Referrer-Policy", "no-referrer"),
428        ] {
429            response.headers.push((name.into(), value.into()));
430        }
431        response
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    /// The navigation and the dispatcher describe one UI. The dispatcher's arms are read
440    /// from this file's own source, so an arm added without a screen or a panel, or a
441    /// screen with no arm, fails here.
442    #[test]
443    fn every_screen_is_answered_and_every_answered_route_is_a_screen_or_a_panel() {
444        let source = include_str!("mod.rs");
445        let dispatcher = source
446            .split("let mut screen = match path {")
447            .nth(1)
448            .and_then(|rest| rest.split("other =>").next())
449            .expect("the dispatcher's match");
450        let arms: Vec<&str> = dispatcher
451            .lines()
452            .filter_map(|l| l.trim().split_once(" =>").map(|(arm, _)| arm.trim()))
453            .collect();
454        assert_eq!(
455            arms.len(),
456            SCREENS.len() + PANELS.len(),
457            "an arm that is neither a screen nor a panel: {arms:?}"
458        );
459        for screen in SCREENS {
460            assert!(Ui::owns(screen.route), "{} is outside the UI", screen.route);
461        }
462        for panel in PANELS {
463            assert!(Ui::owns(panel), "{panel} is outside the UI");
464        }
465        // the prefix and what is below it, and nothing that only shares its letters
466        assert!(Ui::owns("/ui") && Ui::owns("/ui/panels/pull-requests"));
467        assert!(!Ui::owns("/uix") && !Ui::owns("/cockpit"));
468        let mut routes: Vec<&str> = SCREENS.iter().map(|s| s.route).collect();
469        routes.extend(PANELS);
470        routes.sort_unstable();
471        routes.dedup();
472        assert_eq!(
473            routes.len(),
474            SCREENS.len() + PANELS.len(),
475            "a route is declared twice"
476        );
477    }
478
479    /// The Observatory has one screen and three more names: the kiosk is the same screen told
480    /// to run unattended, its earlier name redirects to it, and the misspelling to the screen,
481    /// each with its query kept.
482    #[test]
483    fn the_observatory_is_one_screen_under_its_names() {
484        let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
485            .expect("a synthetic repository");
486        let ui = Ui::new(repo.context().expect("a context"), "0.0.0-test", None);
487        let get = |target: &str| ui.handle(&Request::parse_target("GET", target, Vec::new()));
488        let text = |r: &Response| r.body.text().into_owned();
489        let screen = get(observatory::ROUTE);
490        assert_eq!(screen.status, 200);
491        assert!(text(&screen).contains("data-observatory-mode=\"screen\""));
492        let kiosk = get(observatory::KIOSK);
493        assert_eq!(kiosk.status, 200);
494        assert!(text(&kiosk).contains("data-observatory-mode=\"kiosk\""));
495        for (old, target) in observatory::REDIRECTS {
496            let r = get(&format!("{old}?scene=delivery"));
497            assert_eq!(r.status, 308, "{old}");
498            let to = r
499                .headers
500                .iter()
501                .find(|(k, _)| k == "Location")
502                .map(|(_, v)| v.clone());
503            assert_eq!(to, Some(format!("{target}?scene=delivery")), "{old}");
504        }
505        assert_eq!(
506            observatory::REDIRECTS,
507            &[
508                ("/ui/screensaver", "/ui/kiosk"),
509                ("/ui/obsrvatory", "/ui/observatory")
510            ]
511        );
512    }
513
514    /// The UI answers in-process exactly as it does over a socket: every screen and panel as
515    /// a page with one heading, a fragment without the frame, an asset by its digest, a
516    /// route it does not serve as a page that says so, and anything but GET refused.
517    #[test]
518    fn the_ui_answers_every_route_in_process() {
519        use crate::http::router::Request;
520        let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
521            .expect("a synthetic repository");
522        let ctx = repo.context().expect("a context");
523        let share = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../share");
524        let ui = Ui::new(std::sync::Arc::clone(&ctx), "0.0.0-test", Some(&share));
525        let get = |target: &str| ui.handle(&Request::parse_target("GET", target, Vec::new()));
526
527        for route in SCREENS
528            .iter()
529            .map(|s| s.route)
530            .chain(PANELS.iter().copied())
531        {
532            let r = get(route);
533            let html = r.body.text();
534            assert_eq!(r.status, 200, "{route}");
535            assert!(
536                html.to_ascii_lowercase().starts_with("<!doctype html>"),
537                "{route} is a page"
538            );
539            assert_eq!(html.matches("<h1").count(), 1, "{route} has one heading");
540        }
541        let fragment = get(&format!("{}?{FRAGMENT}", dashboard::PULL_REQUESTS_PANEL));
542        let html = fragment.body.text();
543        assert!(
544            !html.to_ascii_lowercase().contains("<!doctype") && !html.contains("<h1"),
545            "{html}"
546        );
547
548        let asset = get(&format!("{ASSETS_PREFIX}{SCRIPT}?v=anything"));
549        assert_eq!(asset.status, 200, "the UI's script is served");
550
551        let missing = get("/ui/nowhere");
552        assert_eq!(missing.status, 404);
553        assert!(missing
554            .body
555            .text()
556            .contains("serves no screen at /ui/nowhere"));
557
558        let post = ui.handle(&Request::parse_target("POST", PREFIX, Vec::new()));
559        assert_eq!(post.status, 405);
560
561        // without the distribution's files the screens still answer, unstyled and saying so
562        let bare = Ui::new(ctx, "0.0.0-test", None);
563        let r = bare.handle(&Request::parse_target("GET", PREFIX, Vec::new()));
564        assert!(r.body.text().contains("the UI is unstyled"));
565    }
566
567    /// Every screen renders against a real registry, with its hero and at least one panel,
568    /// and none of them shows a value it did not read.
569    #[test]
570    fn every_screen_renders_its_hero_and_panels() {
571        let repo = crate::synthetic::SyntheticRepository::new(crate::synthetic::Shape::default())
572            .expect("a synthetic repository");
573        let ctx = repo.context().expect("a context");
574        for (route, view) in [
575            (PREFIX, dashboard::screen(&ctx)),
576            (
577                idd::ROUTE,
578                idd::screen(&ctx, &[("intent".into(), "no-such-intent".into())]),
579            ),
580            (
581                roadmap::ROUTE,
582                roadmap::screen(&ctx, &[("milestone".into(), "M001".into())]),
583            ),
584            (
585                work::ROUTE,
586                work::screen(&ctx, &[("pr".into(), "1".into())]),
587            ),
588            (
589                issues::ROUTE,
590                issues::screen(&ctx, &[("status".into(), "READY".into())]),
591            ),
592            (
593                prs::ROUTE,
594                prs::screen(&ctx, &[("issue".into(), "I0001".into())]),
595            ),
596            (
597                mesh::ROUTE,
598                mesh::screen(&ctx, &[("node".into(), "nowhere".into())]),
599            ),
600            (delivery::ROUTE, delivery::screen(&ctx)),
601            (
602                evidence::ROUTE,
603                evidence::screen(&ctx, &[("claim".into(), "no-such-claim".into())]),
604            ),
605            (
606                governance::ROUTE,
607                governance::screen(&ctx, &[("rule".into(), "no.such-rule".into())]),
608            ),
609            (command::ROUTE, command::screen(&ctx, &[])),
610            (
611                capabilities::ROUTE,
612                capabilities::screen(&ctx, &[("id".into(), "plan.status".into())]),
613            ),
614            (deployments::ROUTE, deployments::screen(&ctx)),
615            (skills::ROUTE, skills::screen(&ctx)),
616            (docs::ROUTE, docs::screen(&ctx)),
617            (knowledge::ROUTE, knowledge::screen(&ctx)),
618        ] {
619            let html = view.main.render();
620            assert_eq!(view.status, 200, "{route}");
621            assert!(html.contains("mj-ui-hero"), "{route} has no hero");
622            assert!(html.contains("mj-ui-panel"), "{route} has no panel");
623            assert!(html.contains("mj-ui-source"), "{route} names no source");
624            assert!(
625                !html.contains("NaN") && !html.contains("undefined"),
626                "{route}"
627            );
628        }
629    }
630}