Skip to main content

max / quasi

quasi-webview derives a region's chrome instead of knowing widget names A strip when the children carry labels, previous/position/next when they do not, a summary line for one named child that can close. Nothing reads RegionKind::Widget's name, so the next assembly showing one child at a time gets its chrome for free and the name goes back to being app vocabulary. The strip is makeover's tab markup verbatim rather than a second spelling of one; the controls are makeover's button for the reason act_html learned the hard way. The controls call no route. What changes is which of several children already in the document is showing, and a round trip to reveal downloaded bytes is worse on all three MNW galleries this was measured against -- so they carry data-shows and the host binds them, the relationship data-act and data-bespoke already have. Route-bound movement stays available and unbuilt: a description carrying an Action would make them links through control_tag and nothing here changes shape. Every child still ships. Only the wrapper marks which is current, and the rule that collapses the stack waits for data-ready.
Author: Max Johnson <me@maxj.phd> · 2026-08-14 22:28 UTC
Signed with PGP, not checked
Commit: a7bc26bf371312c66f8f45f6991099c04fcfa6e0
Parent: bbd8dd3
2 files changed, +327 insertions, -2 deletions
@@ -1425,6 +1425,131 @@
1425 1425 out.push_str("</div>");
1426 1426 }
1427 1427
1428 + /// Write a `class="..."` attribute, prefixed, plus `current` if it is the one.
1429 + ///
1430 + /// `current` is written bare rather than through [`class_attr`], because
1431 + /// `showing_rules` writes `.showing-frame:not(.current)` and the state half of
1432 + /// that pair does not move under a prefix. Same shape as makeover's `chosen`
1433 + /// and `latched`: the thing is prefixed, the state qualifying it is not.
1434 + fn frame_class_attr(current: bool, opts: &Emit, out: &mut String) {
1435 + out.push_str(" class=\"");
1436 + out.push_str(&escape(&class("showing-frame", opts)));
1437 + if current {
1438 + out.push_str(" current");
1439 + }
1440 + out.push('"');
1441 + }
1442 +
1443 + /// The chrome for a region showing one child at a time.
1444 + ///
1445 + /// Derived, once, for every widget there will ever be. Nothing here reads
1446 + /// [`quasi_router::RegionKind::Widget`]'s name, and that is the point of the
1447 + /// whole design: a carousel, a tab group and a disclosure are one region that
1448 + /// shows some of its children, and which idiom comes out falls out of what the
1449 + /// children carry rather than out of what the assembly is called.
1450 + ///
1451 + /// # The three shapes, and what picks between them
1452 + ///
1453 + /// - Children carrying labels get a strip of them, which is
1454 + /// [`layout::Selector::Tabs`] markup verbatim. A tab strip is already a
1455 + /// described thing here; deriving a second spelling of one would be this
1456 + /// renderer inventing a name makeover would then not style, which is the
1457 + /// SSH-keys bug with extra steps.
1458 + /// - One dismissible child with a name gets that name as a control, which is a
1459 + /// summary line that opens.
1460 + /// - Anything else gets previous, position, next.
1461 + ///
1462 + /// # Why these controls are not links
1463 + ///
1464 + /// A control here calls no route, because the description names none: what
1465 + /// changes is which of several children already in the document is showing, and
1466 + /// a round trip to reveal bytes the reader has downloaded is worse on every one
1467 + /// of the three MNW galleries this was measured against. So the controls carry
1468 + /// `data-shows` and whatever binds the region binds them, the same relationship
1469 + /// `data-act` and `data-bespoke` already have with their host.
1470 + ///
1471 + /// That leaves route-bound movement available and unbuilt. A description that
1472 + /// wanted a real fragment request would carry an [`Action`], the controls would
1473 + /// become links through [`control_tag`] exactly as a selector's options do, and
1474 + /// nothing here would have to change shape. Nothing asks for it today.
1475 + fn showing_html(slot: &Slot, opts: &Emit, out: &mut String) {
1476 + let labels = slot.labels();
1477 + let current = slot.current();
1478 + let total = slot.body.len();
1479 +
1480 + // A named single child that can close is a disclosure, and the check comes
1481 + // first because such a child is also a labelled one: a strip of one tab is
1482 + // not what a summary line is.
1483 + if slot.showing.dismissible()
1484 + && total == 1
1485 + && let [label] = labels.as_slice()
1486 + {
1487 + out.push_str("<button type=\"button\"");
1488 + // `button`, which is makeover's name for a control and carries its whole
1489 + // interactive set. `act_html` learned this the expensive way.
1490 + class_attr(&["button"], opts, out);
1491 + out.push_str(" data-shows=\"0\" aria-expanded=\"");
1492 + out.push_str(if current.is_some() {
1493 + "true\""
1494 + } else {
1495 + "false\""
1496 + });
1497 + out.push('>');
1498 + out.push_str(&escape(label));
1499 + out.push_str("</button>");
1500 + return;
1501 + }
1502 +
1503 + if !labels.is_empty() {
1504 + let tab = option_class(layout::Selector::Tabs);
1505 + out.push_str("<div");
1506 + class_attr(&["selector"], opts, out);
1507 + out.push_str(" data-selector=\"");
1508 + out.push_str(tab);
1509 + out.push_str("\" role=\"tablist\">");
1510 + for (at, label) in labels.iter().enumerate() {
1511 + let picked = current == Some(at);
1512 + out.push_str("<button type=\"button\" class=\"");
1513 + out.push_str(&escape(&class(tab, opts)));
1514 + if picked {
1515 + out.push_str(" chosen");
1516 + }
1517 + let _ = write!(out, "\" data-shows=\"{at}\" role=\"tab\" aria-selected=\"");
1518 + out.push_str(if picked { "true\"" } else { "false\"" });
1519 + out.push('>');
1520 + out.push_str(&escape(label));
1521 + out.push_str("</button>");
1522 + }
1523 + out.push_str("</div>");
1524 + return;
1525 + }
1526 +
1527 + // Previous, position, next. In flow, under the content, nothing overlaid:
1528 + // a terminal cannot honestly overlay anything, and the dot strip this
1529 + // replaces had no form at all past a handful of children -- two of the
1530 + // three galleries it shipped on are creator uploads of arbitrary length.
1531 + out.push_str("<div");
1532 + class_attr(&["showing"], opts, out);
1533 + out.push('>');
1534 +
1535 + out.push_str("<button type=\"button\"");
1536 + class_attr(&["button"], opts, out);
1537 + out.push_str(" data-shows=\"previous\">Prev</button>");
1538 +
1539 + out.push('<');
1540 + out.push_str("span");
1541 + class_attr(&["showing-position"], opts, out);
1542 + // Zero when a dismissible region is closed, which is a true statement about
1543 + // how many of its children are showing and needs no glyph of its own.
1544 + let _ = write!(out, ">{} / {total}</span>", current.map_or(0, |at| at + 1));
1545 +
1546 + out.push_str("<button type=\"button\"");
1547 + class_attr(&["button"], opts, out);
1548 + out.push_str(" data-shows=\"next\">Next</button>");
1549 +
1550 + out.push_str("</div>");
1551 + }
1552 +
1428 1553 /// A region, and everything under it.
1429 1554 pub(crate) fn slot_html(
1430 1555 slot: &Slot,
@@ -1477,6 +1602,20 @@
1477 1602 out.push('"');
1478 1603 }
1479 1604
1605 + // What a binder looks for, and the one attribute that says this region has
1606 + // chrome to bind. It is not the widget's name: a script that moved between
1607 + // a carousel's frames by matching `data-widget="carousel"` would have to be
1608 + // written again for the next assembly, which is the per-widget code the
1609 + // whole derivation exists to stop.
1610 + //
1611 + // Nothing is emitted for `Showing::All`, so the hook is present exactly
1612 + // when there is something to bind.
1613 + match slot.showing {
1614 + layout::Showing::One => out.push_str(" data-showing=\"one\""),
1615 + layout::Showing::AtMostOne => out.push_str(" data-showing=\"at-most-one\""),
1616 + _ => {}
1617 + }
1618 +
1480 1619 if matches!(slot.readiness, layout::Readiness::Pending) {
1481 1620 out.push_str(" aria-busy=\"true\"");
1482 1621 }
@@ -1488,8 +1627,38 @@
1488 1627 }
1489 1628
1490 1629 out.push('>');
1491 - for node in &slot.body {
1492 - node_html(node, morphs, opts, fills, out);
1630 + if slot.showing.selective() {
1631 + let current = slot.current();
1632 + let labelled = !slot.labels().is_empty();
1633 +
1634 + // A strip sits above the panes it opens and a counter row sits under
1635 + // the content it counts. That is the only placement decision here, and
1636 + // it is the folder semantic rather than a preference: a tab that came
1637 + // after its pane would not read as the tab of it.
1638 + if labelled {
1639 + showing_html(slot, opts, out);
1640 + }
1641 +
1642 + // Each child is wrapped, because the rule that collapses the stack has
1643 + // to have something to select and a described child emits whatever
1644 + // element it is. The wrapper appears only here, so a region that shows
1645 + // everything -- which is every region written before `Showing` existed
1646 + // -- emits exactly the markup it always did.
1647 + for (at, node) in slot.body.iter().enumerate() {
1648 + out.push_str("<div");
1649 + frame_class_attr(current == Some(at), opts, out);
1650 + out.push('>');
1651 + node_html(node, morphs, opts, fills, out);
1652 + out.push_str("</div>");
1653 + }
1654 +
1655 + if !labelled {
1656 + showing_html(slot, opts, out);
1657 + }
1658 + } else {
1659 + for node in &slot.body {
1660 + node_html(node, morphs, opts, fills, out);
1661 + }
1493 1662 }
1494 1663
1495 1664 // The host's markup for this region, after whatever the description put
@@ -2626,3 +2626,159 @@
2626 2626 assert!(!html.contains("quasi-overlay"), "{html}");
2627 2627 assert!(!html.contains("data-chrome"), "{html}");
2628 2628 }
2629 +
2630 + /// A carousel: three frames, the second up, and no label anywhere.
2631 + fn gallery() -> Slot {
2632 + Slot::widget("shots", "carousel")
2633 + .extend((0..3).map(|n| {
2634 + Node::Image(quasi_router::screen::Picture::new(
2635 + format!("/shot-{n}.png"),
2636 + format!("shot {n}"),
2637 + ))
2638 + }))
2639 + .showing_one(1)
2640 + }
2641 +
2642 + #[test]
2643 + fn a_region_showing_everything_emits_exactly_what_it_always_did() {
2644 + // The whole additive claim. `Showing::All` is the default, so every
2645 + // description written before the member existed has to come out unchanged:
2646 + // no wrapper, no hook, no row.
2647 + let screen = Screen::list_detail("Tasks", false)
2648 + .with(Slot::new("main", RegionKind::Pane).with(Node::text("plain")));
2649 + let html = render(&screen);
2650 +
2651 + assert!(!html.contains("data-showing"));
2652 + assert!(!html.contains("showing-frame"));
2653 + assert!(!html.contains("showing-position"));
2654 + }
2655 +
2656 + #[test]
2657 + fn a_carousel_gets_a_row_without_this_renderer_knowing_what_a_carousel_is() {
2658 + // The point of the whole design. Nothing below reads the widget's name, and
2659 + // a second assembly showing one child at a time gets the same row for free.
2660 + let screen = Screen::list_detail("Product", false).with(gallery());
2661 + let html = render(&screen);
2662 +
2663 + assert!(html.contains("data-showing=\"one\""), "{html}");
2664 + assert!(html.contains("data-shows=\"previous\""), "{html}");
2665 + assert!(html.contains("data-shows=\"next\""), "{html}");
2666 + // Position counts from one for a reader, and off `current()` rather than
2667 + // off `shown`, so a clamped index reports where the frame actually is.
2668 + assert!(html.contains(">2 / 3</span>"), "{html}");
2669 + // The name is still there and is still nobody's business here.
2670 + assert!(html.contains("data-widget=\"carousel\""));
2671 + }
2672 +
2673 + #[test]
2674 + fn the_row_sits_under_the_frames_and_overlays_nothing() {
2675 + // Max, 2026-08-14: the shipped arrows were absolutely positioned over the
2676 + // picture, which a terminal cannot honestly do and which read as clutter
2677 + // here. In flow, after the content, on every host.
2678 + let html = render(&Screen::list_detail("Product", false).with(gallery()));
2679 +
2680 + let last_frame = html.rfind("showing-frame").expect("frames are wrapped");
2681 + let row = html.rfind("showing-position").expect("a row is derived");
2682 + assert!(row > last_frame, "{html}");
2683 + }
2684 +
2685 + #[test]
2686 + fn only_the_current_frame_is_marked_and_the_rest_are_still_in_the_document() {
2687 + // Degradation runs toward more content. Every frame ships; the rule that
2688 + // collapses them waits for whatever binds the region, so a reader with no
2689 + // script gets the whole gallery instead of one frame and two dead buttons.
2690 + let html = render(&Screen::list_detail("Product", false).with(gallery()));
2691 +
2692 + assert_eq!(html.matches("showing-frame").count(), 3, "{html}");
2693 + assert_eq!(html.matches("showing-frame current").count(), 1, "{html}");
2694 + assert!(html.contains("/shot-0.png") && html.contains("/shot-2.png"));
2695 + }
2696 +
2697 + #[test]
2698 + fn labelled_children_get_a_strip_and_it_is_makeovers_tab_markup() {
2699 + // A tab strip is already a described thing. Deriving a second spelling of
2700 + // one is how `tabs` and `segmented` came to render flat.
2701 + let screen = Screen::list_detail("Project", false).with(
2702 + Slot::new("detail", RegionKind::TabGroup)
2703 + .with(Node::Region(
2704 + Slot::new("overview", RegionKind::Pane).label("Overview"),
2705 + ))
2706 + .with(Node::Region(
2707 + Slot::new("files", RegionKind::Pane).label("Files"),
2708 + ))
2709 + .showing_one(1),
2710 + );
2711 + let html = render(&screen);
2712 +
2713 + assert!(html.contains("data-selector=\"tab\""), "{html}");
2714 + assert!(html.contains("role=\"tablist\""), "{html}");
2715 + assert!(html.contains(">Overview</button>"), "{html}");
2716 + assert!(html.contains("chosen\" data-shows=\"1\""), "{html}");
2717 + assert!(
2718 + html.contains("aria-selected=\"true\">Files</button>"),
2719 + "{html}"
2720 + );
2721 + assert!(
2722 + html.contains("aria-selected=\"false\">Overview</button>"),
2723 + "{html}"
2724 + );
2725 + // A strip, not a counter row: the labels are what the reader steers by.
2726 + assert!(!html.contains("showing-position"), "{html}");
2727 + }
2728 +
2729 + #[test]
2730 + fn a_strip_sits_above_the_panes_it_opens() {
2731 + // The folder semantic. A tab after its pane would not read as the tab of
2732 + // it, and this is the only placement decision the derivation makes.
2733 + let screen = Screen::list_detail("Project", false).with(
2734 + Slot::new("detail", RegionKind::TabGroup)
2735 + .with(Node::Region(
2736 + Slot::new("overview", RegionKind::Pane).label("Overview"),
2737 + ))
2738 + .with(Node::Region(
2739 + Slot::new("files", RegionKind::Pane).label("Files"),
2740 + ))
2741 + .showing_one(0),
2742 + );
2743 + let html = render(&screen);
2744 +
2745 + let strip = html
2746 + .find("data-selector=\"tab\"")
2747 + .expect("a strip is derived");
2748 + let first_frame = html.find("showing-frame").expect("frames are wrapped");
2749 + assert!(strip < first_frame, "{html}");
2750 + }
2751 +
2752 + #[test]
2753 + fn a_named_child_that_can_close_is_a_summary_line() {
2754 + // Disclosure, which is `871e7f21` and is the third findings this member
2755 + // collapses. A strip of one tab is not what a summary line is, so the
2756 + // dismissible case is checked before the labelled one.
2757 + let screen = Screen::list_detail("Item", false).with(
2758 + Slot::widget("more", "disclosure")
2759 + .with(Node::Region(
2760 + Slot::new("body", RegionKind::Pane)
2761 + .label("Technical details")
2762 + .with(Node::text("the rest")),
2763 + ))
2764 + .showing_at_most_one(None),
2765 + );
2766 + let html = render(&screen);
2767 +
2768 + assert!(html.contains("data-showing=\"at-most-one\""), "{html}");
2769 + assert!(html.contains("aria-expanded=\"false\""), "{html}");
2770 + assert!(html.contains(">Technical details</button>"), "{html}");
2771 + assert!(!html.contains("data-shows=\"next\""), "{html}");
2772 + }
2773 +
2774 + #[test]
2775 + fn a_derived_control_names_no_route_and_the_transport_stays_in_one_function() {
2776 + // The controls move between children already in the document, so there is
2777 + // nothing to fetch. Emitting htmx here would put the transport in a second
2778 + // place, which is the claim `action_attrs` exists to keep true.
2779 + let html = render(&Screen::list_detail("Product", false).with(gallery()));
2780 + let row = &html[html.find("showing-position").expect("a row is derived") - 200..];
2781 +
2782 + assert!(!row.contains("hx-get"), "{row}");
2783 + assert!(!row.contains("hx-post"), "{row}");
2784 + }