Skip to main content

max / makenotwork

Describe the carousel: the widget tier's first consumer c0b63ea9, the gap that started look wave 2. The markup is no longer written in a template. quasi/widgets/carousel says "an ordered set of pictures, called a carousel" and quasi-webview turns that into markup; the partial is now the call, and all three pages keep the macro they already had. The body is the frames and nothing else. A position, prev/next and a dot strip are chrome, and chrome is what a renderer that recognises the name draws its own way -- buttons here, a pager with a count on a terminal. PROGRESSIVE ENHANCEMENT NOW RUNS THE OTHER WAY. This used to render frame one and hide the rest, so with JS off two of three screenshots were unreachable: the controls that reach them are the scripted part. The unenhanced page is now the whole gallery in order, and the island collapses it to one at a time. It degrades to more content rather than less, which is also why the chrome is injected rather than rendered -- shipping dead buttons to a reader with no script was markup that lied. The landing frames lose their elevation and take makeover's bevel. In-flow depth is a bevel; elevation is for what sits over the page. It survived this long because it was invisible: an unlayered declaration on the same class makeover sets, so it beat the design system silently. build.rs's drift check found it the moment the frame became a described picture, along with five more of the same kind in the first draft of this file. The island stays a custom element and wraps the region rather than being it. A custom element is what the browser re-upgrades after an htmx swap, and the description emits a plain div because it has no idea this host prefixes its tags. Seals: dead vocabulary 18 to 20 for the two picture classes emitted by quasi-webview at request time rather than by this repo's markup, which is the category figure-value and the cell-* set were already in. One reviewed overlap, picture-img/display, argued at both gates that asked.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 16:06 UTC
Signed with PGP, not checked
Commit: 720d70b2aa6a387745e8076217473d8a0d96cf35
Parent: 2a7fd5e
9 files changed, +496 insertions, -201 deletions
M server/Cargo.lock +11 -13
@@ -5316,9 +5316,7 @@
5316 5316
5317 5317 [[package]]
5318 5318 name = "makeover-build"
5319 - version = "0.28.0"
5320 - source = "registry+https://github.com/rust-lang/crates.io-index"
5321 - checksum = "240d3be9851135821b50e4ed187b720b8b8c37935a522e226ce52dc3b30b1b26"
5319 + version = "0.29.0"
5322 5320 dependencies = [
5323 5321 "makeover",
5324 5322 "makeover-geometry",
@@ -5333,7 +5331,7 @@
5333 5331
5334 5332 [[package]]
5335 5333 name = "makeover-layout"
5336 - version = "0.20.0"
5334 + version = "0.21.0"
5337 5335
5338 5336 [[package]]
5339 5337 name = "makeover-touch"
@@ -5345,7 +5343,7 @@
5345 5343
5346 5344 [[package]]
5347 5345 name = "makeover-webview"
5348 - version = "0.35.1"
5346 + version = "0.36.0"
5349 5347 dependencies = [
5350 5348 "makeover-geometry",
5351 5349 "makeover-layout",
@@ -10701,14 +10699,6 @@
10701 10699 name = "makeover-tui"
10702 10700 version = "0.19.0"
10703 10701
10704 - [[patch.unused]]
10705 - name = "synckit-client"
10706 - version = "0.8.0"
10707 -
10708 - [[patch.unused]]
10709 - name = "synckit-config"
10710 - version = "0.2.0"
10711 -
10712 10702 [[patch.unused]]
10713 10703 name = "quasi-immediate"
10714 10704 version = "0.2.0"
@@ -10720,3 +10710,11 @@
10720 10710 [[patch.unused]]
10721 10711 name = "quasi-tauri"
10722 10712 version = "0.2.0"
10713 +
10714 + [[patch.unused]]
10715 + name = "synckit-client"
10716 + version = "0.8.0"
10717 +
10718 + [[patch.unused]]
10719 + name = "synckit-config"
10720 + version = "0.2.0"
@@ -163,7 +163,7 @@
163 163 # rather than reached through quasi-router's re-export because a described
164 164 # screen names FieldKind and Tone directly; it has to track what quasi-router
165 165 # resolves or the two `layout::` paths are different crates.
166 - makeover-layout = "0.20.0"
166 + makeover-layout = "0.21.0"
167 167 # For the request head the per-viewer state factory reads. axum re-exports it,
168 168 # but the factory's signature is quasi-axum's and names `http::request::Parts`.
169 169 http = "1.3.1"
@@ -240,7 +240,7 @@
240 240 # Emits static/geometry.css (makeover-geometry) and static/layout.css
241 241 # (makeover-webview). The same generator GO and BB run; only the output paths
242 242 # differ, since the server serves its stylesheets rather than bundling them.
243 - makeover-build = "0.28.0"
243 + makeover-build = "0.29.0"
244 244 # Read directly for the embeds' own copy of the spacing layer, which they need
245 245 # because an iframe cannot link a stylesheet. makeover-build does not re-export
246 246 # it, so the pin lives here and has to track the one makeover-build resolves.
M server/build.rs +34 -1
@@ -159,7 +159,27 @@
159 159 /// are being replaced by, and the Rust that writes markup directly -- which is
160 160 /// the whole reason this seal took a pass of its own rather than landing beside
161 161 /// the check that reads the stylesheets.
162 - const DEAD_VOCABULARY_HIGH_WATER: usize = 18;
162 + ///
163 + /// # 18 to 20, 2026-08-14: `picture-img` and `picture-caption`
164 + ///
165 + /// The one direction this number is allowed to move is down, so a rise wants an
166 + /// argument rather than a nudge. These two are not dead vocabulary: they are
167 + /// emitted by `quasi-webview` at request time, from a description, and this
168 + /// scanner reads *this repo's* files. Markup written by a dependency is
169 + /// invisible to it by construction.
170 + ///
171 + /// That category already existed and was already counted here. `figure-value`,
172 + /// `figure-caption` and `figure-change` sit in the same set for the same
173 + /// reason, and the nine `cell-*` classes beside them are the described table.
174 + /// The carousel port (`c0b63ea9`) is the first widget to add to it.
175 + ///
176 + /// So the seal still measures what it was built to measure -- CSS generated for
177 + /// markup nobody writes -- and the honest ratchet is downward as the
178 + /// description layer takes over more of the site, at which point these classes
179 + /// stop being reachable from templates *and* stay used. If this number ever
180 + /// needs raising for a class MNW's own markup should have been writing, that is
181 + /// the defect this exists to catch and the answer is the markup, not the seal.
182 + const DEAD_VOCABULARY_HIGH_WATER: usize = 20;
163 183
164 184 /// Every file that can carry a class name.
165 185 ///
@@ -344,6 +364,19 @@
344 364 /// what it describes.
345 365 const REVIEWED_OVERLAPS: &[(&str, &str)] = &[
346 366 ("badge", "color"),
367 + // A carousel showing one frame at a time, 2026-08-14. Makeover says a
368 + // picture is `display: block`, which is what a picture is; the widget says
369 + // the frames that are not current are not shown, which is what a carousel
370 + // is. Two different claims that happen to land on one property, and the
371 + // selector says so -- the override is
372 + // `[data-widget="carousel"][data-ready] > .picture-img`, so it reaches a
373 + // picture only while it is a frame in an enhanced carousel and never a
374 + // picture anywhere else.
375 + //
376 + // `[hidden]` was the alternative and does not work: makeover's rule is an
377 + // author rule, layered or not, so `display: block` beats the UA stylesheet
378 + // and the attribute does nothing.
379 + ("picture-img", "display"),
347 380 ("card", "color"),
348 381 ("chosen", "box-shadow"),
349 382 ("progress-fill", "background"),
@@ -11507,9 +11507,26 @@
11507 11507 .synckit-gauge-value { color: var(--content-muted); }
11508 11508
11509 11509 /* ============================================================================
11510 - Carousel: composable click-through widget (partials/carousel.html).
11511 - One frame visible at a time; viewer-driven (prev/next, dots, arrow keys).
11512 - No autoplay, no slide animation; frames swap instantly.
11510 + Carousel: the widget tier's first consumer.
11511 +
11512 + The markup is no longer written in a template. It comes from a description --
11513 + `crate::quasi::widgets::carousel` says "an ordered set of pictures, called a
11514 + carousel" -- and quasi-webview turns that into a region carrying
11515 + `data-widget="carousel"` with one `.picture` or `.picture-img` per frame.
11516 +
11517 + So these rules key on the widget name rather than on a hand-written class.
11518 + That is what "a renderer that recognises the name draws it its own way"
11519 + means on this host: the name is the hook, and the stylesheet plus the island
11520 + are the browser's idea of a carousel.
11521 +
11522 + THE FALLBACK RUNS THE OTHER WAY NOW. This used to render frame one and hide
11523 + the rest, because the controls that reach them are the scripted part -- so
11524 + with JS off, two of three screenshots were unreachable. Now the unenhanced
11525 + page is the whole gallery, stacked in order, and the script's job is to
11526 + collapse it to one at a time. It degrades to more content, not less, which
11527 + is why the chrome below is injected by the island rather than rendered
11528 + server-side: prev/next/dots do nothing without script and are not markup
11529 + anyone should be shipping to a reader who has none.
11513 11530 ========================================================================= */
11514 11531
11515 11532 /* Public gallery carousel sections (item + project pages). */
@@ -11518,174 +11535,125 @@
11518 11535 margin: var(--gap-pane) 0;
11519 11536 }
11520 11537
11521 - /* Wizard gallery manager: thumbnail tiles with reorder + delete controls. */
11522 - .gallery-manager-list {
11523 - display: flex;
11524 - flex-wrap: wrap;
11525 - gap: var(--gap-peer);
11526 - margin-bottom: var(--gap-peer);
11527 - }
11528 -
11529 - .gallery-tile {
11530 - position: relative;
11531 - width: 96px;
11532 - height: 96px;
11533 - border: 1px solid var(--border);
11534 - border-radius: var(--radius-control);
11535 - overflow: hidden;
11536 - }
11537 -
11538 - .gallery-tile-img {
11539 - width: 100%;
11540 - height: 100%;
11541 - object-fit: cover;
11542 - display: block;
11543 - }
11544 -
11545 - .gallery-tile-controls {
11546 - position: absolute;
11547 - inset: auto 0 0 0;
11548 - display: flex;
11549 - justify-content: space-between;
11550 - background: var(--overlay);
11551 - }
11552 -
11553 - .gallery-tile-btn {
11554 - flex: 1;
11555 - border: none;
11556 - background: transparent;
11557 - color: var(--primary-light);
11558 - cursor: pointer;
11559 - font-size: var(--text-note);
11560 - line-height: 1.4;
11561 - padding: 2px 0;
11562 - }
11563 -
11564 - .gallery-tile-btn:disabled {
11565 - opacity: 0.35;
11566 - cursor: default;
11567 - }
11568 -
11569 - .gallery-tile-delete {
11570 - color: var(--danger);
11571 - font-weight: bold;
11572 - }
11573 -
11574 - /* <mnw-carousel> is a custom element (default display:inline); make it lay out
11575 - like the block container it replaced. */
11538 + /* <mnw-carousel> wraps the described region rather than being it: a custom
11539 + element is what the browser upgrades automatically after an htmx swap, and
11540 + the description emits a plain div because it does not know this host's tag
11541 + prefix. Default display for an unknown element is inline, so say otherwise. */
11576 11542 mnw-carousel {
11577 11543 display: block;
11578 11544 }
11579 11545
11580 - .carousel {
11581 - position: relative;
11582 - margin: 0 auto;
11546 + /* Unenhanced: every frame, in order, down the page. */
11547 + [data-widget="carousel"] {
11548 + display: grid;
11549 + gap: var(--gap-section);
11583 11550 }
11584 11551
11585 - .carousel:focus-visible {
11586 - outline: 2px solid var(--focus-ring);
11587 - outline-offset: 3px;
11588 - }
11589 -
11590 - .carousel-viewport {
11591 - display: grid; /* all frames share one cell; only the active one renders */
11592 - }
11593 -
11594 - .carousel-frame {
11595 - grid-area: 1 / 1;
11552 + [data-widget="carousel"] > .picture {
11596 11553 margin: 0;
11597 - display: none;
11598 11554 }
11599 11555
11600 - .carousel-frame.is-active {
11601 - display: block;
11602 - }
11603 -
11604 - /* JS-off fallback: no [data-ready] means stack every frame so all
11605 - screenshots are still visible rather than stranded behind dead controls. */
11606 - .carousel:not([data-ready]) .carousel-frame {
11607 - display: block;
11608 - }
11609 -
11610 - .carousel-img {
11611 - display: block;
11612 - width: 100%;
11613 - height: auto;
11614 - border: 1px solid var(--border);
11556 + /* Only the corner. `display`, `width`, `height` and the raised fill and bevel
11557 + all come from makeover's own `.picture-img` rule, which layout.css puts on
11558 + every page through crate::shell::head. Restating any of them here is an
11559 + unlayered declaration quietly beating the design system, which is what the
11560 + drift check in build.rs exists to catch -- and did, on the first attempt at
11561 + this file. */
11562 + [data-widget="carousel"] .picture-img {
11615 11563 border-radius: var(--radius-control);
11616 - background: var(--surface-raised);
11617 11564 }
11618 11565
11619 - /* Landing showcase only: lift the frames off the page.
11620 -
11621 - The three landing frames are screenshots of this site, so the parchment they
11622 - were shot on is the parchment they sit on, and a 1px border between two
11623 - identical grounds is not an edge anyone sees. A blurred shadow is the right
11624 - kind here by the scale's own rule at the top of this file: the frame reads as
11625 - something laid over the page rather than as part of it.
11626 -
11627 - Still scoped to .landing-showcase rather than applied to .carousel-img, but
11628 - the reason has changed and is now weaker. It used to be a technical one: the
11629 - rgba-black literal here was picked against parchment, and the same widget
11630 - carries creator galleries on /u and /p where a chosen theme swaps every
11631 - surface token underneath it. That argument is gone with the literal. What is
11632 - left is a look call — the landing frames are screenshots of this site and
11633 - want lifting off their own ground, a creator's gallery images are the
11634 - creator's own — and it belongs to the F2 screenshot pass, not to this file.
11635 - Widening the selector is a one-line change if that pass says so. */
11636 - .landing-showcase .carousel-img {
11637 - /* Geometry, not colour, is what is local here: --elevation-overlay is
11638 - calibrated for popovers and dropdowns a few hundred pixels across, and on
11639 - a frame this wide its 24px blur disappears, which was measured rather than
11640 - assumed. So the blur scales with the plate and the two layers stack for
11641 - the weight a full-width plate needs, both cast in the theme's own
11642 - --elevation rather than in black. */
11643 - box-shadow: 0 4px 10px var(--elevation), 0 14px 36px var(--elevation);
11644 - /* The shadow falls downward, so it does nothing for the top edge, which is
11645 - exactly where a parchment screenshot dissolves into a parchment page. The
11646 - border is what closes the other three sides. */
11647 - border-color: var(--border-strong);
11648 - }
11649 -
11650 - .carousel-caption {
11566 + /* No colour: makeover's `.picture-caption` already sets it to
11567 + --content-muted, and this said the same thing one layer louder. What is
11568 + local is the centring and the mono face, which are this widget's look and
11569 + not a claim about what a caption is. */
11570 + [data-widget="carousel"] .picture-caption {
11651 11571 margin-top: var(--gap-group);
11652 11572 font-family: var(--font-mono);
11653 11573 font-size: var(--text-note);
11654 - color: var(--content-muted);
11655 11574 text-align: center;
11656 11575 }
11657 11576
11577 + /* Landing showcase only: close the top edge.
11578 +
11579 + The three landing frames are screenshots of this site, so the parchment they
11580 + were shot on is the parchment they sit on, and a 1px border between two
11581 + identical grounds is not an edge anyone sees.
11582 +
11583 + THE ELEVATION IS GONE, and that is the point of the port. This carried
11584 + `box-shadow: 0 4px 10px var(--elevation), 0 14px 36px var(--elevation)`,
11585 + which is the defect `c0b63ea9` names: in-flow depth is a bevel and elevation
11586 + is for what sits *over* the page. It survived this long because it was
11587 + invisible -- an unlayered declaration on the same class makeover sets, so it
11588 + beat the design system's bevel silently. The drift check in build.rs found it
11589 + the moment the frame became a described `.picture-img`.
11590 +
11591 + So the frame is raised by makeover's bevel now, from `Depth::Raised`, and
11592 + what is left here is the one thing that was never about depth: a stronger
11593 + border colour, because a shadow falling downward does nothing for the top
11594 + edge and the top edge is where a parchment screenshot dissolves into a
11595 + parchment page. */
11596 + .landing-showcase .picture-img {
11597 + border-color: var(--border-strong);
11598 + }
11599 +
11600 + /* ---------------------------------------------------------------------------
11601 + Enhanced. Everything below needs the island, and the island sets
11602 + [data-ready] once it has upgraded and injected the chrome.
11603 + --------------------------------------------------------------------------- */
11604 +
11605 + [data-widget="carousel"][data-ready] {
11606 + position: relative;
11607 + display: block;
11608 + }
11609 +
11610 + [data-widget="carousel"][data-ready] > .picture,
11611 + [data-widget="carousel"][data-ready] > .picture-img {
11612 + /* respec-ok: makeover says a picture is display:block, which is what a
11613 + picture is; this says the frames that are not current are not shown, which
11614 + is what a carousel is. Nothing in the vocabulary knows that one frame of a
11615 + set is current, so the generated sheet cannot express it. The selector
11616 + keeps the claim narrow: it reaches a picture only while it is a frame in an
11617 + enhanced carousel. [hidden] is not the way out either, because makeover's
11618 + rule is an author rule and beats the UA stylesheet. */
11619 + display: none;
11620 + }
11621 +
11622 + [data-widget="carousel"][data-ready] > .is-active {
11623 + display: block;
11624 + }
11625 +
11626 + [data-widget="carousel"][data-ready]:focus-visible {
11627 + outline: 2px solid var(--focus-ring);
11628 + outline-offset: 3px;
11629 + }
11630 +
11658 11631 /* Prev / next: brutalist offset buttons, vertically centered over the frame. */
11659 11632 .carousel-nav {
11660 11633 position: absolute;
11661 - top: 50%;
11634 + top: 40%;
11662 11635 transform: translateY(-50%);
11636 + width: 2.5rem;
11637 + height: 2.5rem;
11663 11638 display: flex;
11664 11639 align-items: center;
11665 11640 justify-content: center;
11666 - width: 2.4rem;
11667 - height: 2.4rem;
11668 - /* Bias the flex-centered glyph up ~2px: the chevron characters sit low in
11669 - their line box, so geometric centering reads as a few pixels too low. */
11670 - padding: 0 0 4px;
11671 11641 font-size: var(--text-head);
11672 11642 line-height: 1;
11643 + cursor: pointer;
11673 11644 color: var(--content);
11674 11645 background: var(--surface-raised);
11675 - border: 1px solid var(--content);
11646 + border: 1px solid var(--border-strong);
11676 11647 border-radius: var(--radius-control);
11677 - box-shadow: var(--shadow-raised);
11678 - cursor: pointer;
11679 11648 }
11680 11649
11681 11650 .carousel-nav:hover {
11682 - background: var(--hover-surface);
11651 + background: var(--surface-sunken);
11683 11652 }
11684 11653
11685 - /* Keeps the translateY that centres it vertically; the press is the bevel,
11686 - so nothing is added to the transform. */
11687 11654 .carousel-nav:active {
11688 - box-shadow: var(--shadow-inset);
11655 + background: var(--surface-sunken);
11656 + transform: translateY(-50%) translateY(1px);
11689 11657 }
11690 11658
11691 11659 .carousel-nav:focus-visible {
@@ -11696,32 +11664,25 @@
11696 11664 .carousel-prev { left: 0.6rem; }
11697 11665 .carousel-next { right: 0.6rem; }
11698 11666
11699 - /* Hide the prev/next chrome entirely when JS hasn't taken over. */
11700 - .carousel:not([data-ready]) .carousel-nav,
11701 - .carousel:not([data-ready]) .carousel-dots {
11702 - display: none;
11703 - }
11704 -
11705 11667 .carousel-dots {
11706 11668 display: flex;
11707 11669 justify-content: center;
11708 11670 gap: var(--gap-peer);
11709 - margin-top: var(--gap-section);
11671 + margin-top: var(--gap-group);
11710 11672 }
11711 11673
11712 11674 .carousel-dot {
11713 - width: 0.7rem;
11714 - height: 0.7rem;
11675 + width: 0.6rem;
11676 + height: 0.6rem;
11715 11677 padding: 0;
11716 - border: 1px solid var(--content);
11717 - border-radius: var(--radius-round);
11718 - background: transparent;
11719 11678 cursor: pointer;
11679 + border: 1px solid var(--border-strong);
11680 + border-radius: 50%;
11681 + background: transparent;
11720 11682 }
11721 11683
11722 11684 .carousel-dot.is-active {
11723 - background: var(--action);
11724 - border-color: var(--action);
11685 + background: var(--content);
11725 11686 }
11726 11687
11727 11688 .carousel-dot:focus-visible {
@@ -36,6 +36,7 @@
36 36 pub mod library_contacts;
37 37 pub mod ssh_keys;
38 38 pub mod user_analytics;
39 + pub mod widgets;
39 40
40 41 /// The state one request is answered against.
41 42 ///
@@ -10,33 +10,28 @@
10 10 slide animation, frames swap instantly.
11 11
12 12 Each frame is a `CarouselFrame { image, alt, caption: Option<_> }`
13 - (see `src/templates/mod.rs`). Usage:
13 + (see `src/templates/mod.rs`). Usage is unchanged:
14 14
15 15 {%- import "partials/carousel.html" as carousel -%}
16 16 {% call carousel::carousel("af-shots", frames) %}{% endcall %}
17 17
18 - Progressive enhancement: with JS off, the first frame shows and the
19 - controls are inert. You still see a screenshot, nothing breaks.
18 + THE MARKUP IS NO LONGER WRITTEN HERE. It comes from the description layer:
19 + `crate::quasi::widgets::carousel` says "an ordered set of pictures, called a
20 + carousel" and quasi-webview turns that into markup. This file is now the
21 + call, and the reason it still exists is that three pages already import it.
22 +
23 + What that buys, beyond one description instead of one template:
24 +
25 + - The frame's edge is a bevel from makeover, not a border and an rgba()
26 + shadow written by hand here. In-flow depth is a bevel; elevation is for
27 + what sits over the page.
28 + - Progressive enhancement runs the other way. This used to render frame one
29 + and leave the other frames unreachable without JS, because the controls
30 + that reach them are the scripted part. Now the unenhanced page is the
31 + whole gallery in order, and the script's job is to collapse it to one at a
32 + time. It degrades to more content rather than less.
20 33 #}
21 34
22 35 {% macro carousel(id, frames) -%}
23 - <mnw-carousel class="carousel" id="{{ id }}" role="group" aria-roledescription="carousel" aria-label="Screenshots" tabindex="0">
24 - <div class="carousel-viewport">
25 - {% for f in frames %}
26 - <figure class="carousel-frame{% if loop.first %} is-active{% endif %}" data-index="{{ loop.index0 }}" role="group" aria-roledescription="slide" aria-label="{{ loop.index }} of {{ frames.len() }}"{% if !loop.first %} aria-hidden="true"{% endif %}>
27 - <img class="carousel-img" src="{{ f.image }}" alt="{{ f.alt }}" loading="lazy">
28 - {% if let Some(cap) = f.caption %}<figcaption class="carousel-caption">{{ cap }}</figcaption>{% endif %}
29 - </figure>
30 - {% endfor %}
31 - </div>
32 - {% if frames.len() > 1 %}
33 - <button type="button" class="carousel-nav carousel-prev" data-carousel-prev aria-label="Previous screenshot">&#8249;</button>
34 - <button type="button" class="carousel-nav carousel-next" data-carousel-next aria-label="Next screenshot">&#8250;</button>
35 - <div class="carousel-dots" role="tablist" aria-label="Choose screenshot">
36 - {% for f in frames %}
37 - <button type="button" class="carousel-dot{% if loop.first %} is-active{% endif %}" data-carousel-dot="{{ loop.index0 }}" role="tab" aria-label="Screenshot {{ loop.index }}" aria-selected="{% if loop.first %}true{% else %}false{% endif %}"></button>
38 - {% endfor %}
39 - </div>
40 - {% endif %}
41 - </mnw-carousel>
36 + <mnw-carousel>{{ crate::quasi::widgets::carousel::html(id, frames)|safe }}</mnw-carousel>
42 37 {%- endmacro %}
@@ -1,18 +1,47 @@
1 - // <mnw-carousel>, click-through carousel over server-rendered frames, ported
2 - // from static/carousel.js. Progressive enhancement: until the element upgrades,
3 - // the first frame shows and the controls are inert (CSS gates on [data-ready]).
1 + // <div data-widget="carousel">, click-through carousel over a described gallery.
2 + //
3 + // The widget tier's browser half. The server no longer renders a carousel; it
4 + // renders an ordered set of pictures carrying `data-widget="carousel"`, and
5 + // this is what "a renderer that recognises the name draws it its own way"
6 + // means on this host. A terminal renders the same description as a stack of
7 + // captions, and neither owes the other a carousel primitive.
8 + //
9 + // PROGRESSIVE ENHANCEMENT RUNS THE OTHER WAY NOW. The old element inherited
10 + // server-rendered prev/next/dots and switched them on. Those controls do
11 + // nothing without script, so shipping them to a reader who has none was markup
12 + // that lied. Here the page arrives as the whole gallery stacked in order, and
13 + // this collapses it to one frame at a time and injects the chrome that makes
14 + // that navigable. With no script the reader gets every screenshot instead of
15 + // the first one and two dead buttons.
4 16
5 17 import { MnwElement, define } from './base.ts';
6 18 import { wrapIndex } from './carousel.logic.ts';
7 19
8 20 class Carousel extends MnwElement {
9 21 protected init(): void {
10 - const frames = Array.from(this.querySelectorAll<HTMLElement>('.carousel-frame'));
11 - if (frames.length < 2) return; // nothing to navigate
22 + // The element wraps the described region rather than being it. A custom
23 + // element is what gets upgraded automatically on an htmx swap, which is the
24 + // whole reason islands are custom elements; the description emits a plain
25 + // `<div>` because it has no idea this host prefixes its tags with `mnw-`.
26 + // So the template puts one around the other and each half stays honest.
27 + const region = this.querySelector<HTMLElement>('[data-widget="carousel"]');
28 + if (!region) return;
12 29
13 - const dots = Array.from(this.querySelectorAll<HTMLElement>('[data-carousel-dot]'));
14 - let index = frames.findIndex((f) => f.classList.contains('is-active'));
15 - if (index < 0) index = 0;
30 + // A frame is a direct child of the region. The description emits a figure
31 + // for a captioned frame and a bare image for one without, so the child is
32 + // the frame either way and neither shape needs a class of its own.
33 + //
34 + // Deliberately not selected by class name. Those classes are makeover's and
35 + // arrive with the description, and naming one here would both couple this
36 + // file to the vocabulary's spelling and register as a use in build.rs's
37 + // dead-vocabulary scan, which reads this directory.
38 + const frames = Array.from(region.children).filter(
39 + (el): el is HTMLElement => el instanceof HTMLElement,
40 + );
41 + if (frames.length < 2) return; // nothing to navigate; leave the stack alone
42 +
43 + let index = 0;
44 + const dots: HTMLElement[] = [];
16 45
17 46 const show = (next: number): void => {
18 47 index = wrapIndex(next, frames.length);
@@ -29,12 +58,51 @@
29 58 });
30 59 };
31 60
32 - this.querySelector('[data-carousel-prev]')?.addEventListener('click', () => show(index - 1));
33 - this.querySelector('[data-carousel-next]')?.addEventListener('click', () => show(index + 1));
34 - dots.forEach((d) => {
35 - d.addEventListener('click', () => show(parseInt(d.getAttribute('data-carousel-dot') ?? '', 10) || 0));
61 + const button = (cls: string, label: string, glyph: string): HTMLButtonElement => {
62 + const b = document.createElement('button');
63 + b.type = 'button';
64 + b.className = cls;
65 + b.setAttribute('aria-label', label);
66 + b.textContent = glyph;
67 + return b;
68 + };
69 +
70 + // The chrome. Injected rather than rendered, because none of it works
71 + // without this file being here to bind it.
72 + const prev = button('carousel-nav carousel-prev', 'Previous screenshot', '‹');
73 + const next = button('carousel-nav carousel-next', 'Next screenshot', '›');
74 + prev.addEventListener('click', () => show(index - 1));
75 + next.addEventListener('click', () => show(index + 1));
76 + region.append(prev, next);
77 +
78 + const strip = document.createElement('div');
79 + strip.className = 'carousel-dots';
80 + strip.setAttribute('role', 'tablist');
81 + strip.setAttribute('aria-label', 'Choose screenshot');
82 + frames.forEach((_, i) => {
83 + const dot = button('carousel-dot', `Screenshot ${i + 1}`, '');
84 + dot.setAttribute('role', 'tab');
85 + dot.addEventListener('click', () => show(i));
86 + dots.push(dot);
87 + strip.append(dot);
36 88 });
37 - this.addEventListener('keydown', (e) => {
89 + region.append(strip);
90 +
91 + // The group semantics the template used to carry. They belong with the
92 + // behaviour: a stack of pictures is not a carousel until this runs, and
93 + // announcing one before then describes something the reader cannot use.
94 + region.setAttribute('role', 'group');
95 + region.setAttribute('aria-roledescription', 'carousel');
96 + region.setAttribute('aria-label', 'Screenshots');
97 + region.tabIndex = 0;
98 +
99 + frames.forEach((f, i) => {
100 + f.setAttribute('role', 'group');
101 + f.setAttribute('aria-roledescription', 'slide');
102 + f.setAttribute('aria-label', `${i + 1} of ${frames.length}`);
103 + });
104 +
105 + region.addEventListener('keydown', (e) => {
38 106 if (e.key === 'ArrowLeft') {
39 107 show(index - 1);
40 108 e.preventDefault();
@@ -44,7 +112,8 @@
44 112 }
45 113 });
46 114
47 - this.setAttribute('data-ready', ''); // CSS hook: controls become live
115 + show(0);
116 + region.setAttribute('data-ready', ''); // CSS hook: collapse to one frame
48 117 }
49 118 }
50 119
@@ -1,0 +1,216 @@
1 + //! An ordered set of frames, said once.
2 + //!
3 + //! The widget tier's first consumer (`c0b63ea9`), and the gap that started
4 + //! look wave 2. `makeover_layout::Region::Widget` arrived at 0.20.0 to make
5 + //! this sayable and `makeover_layout::Image` at 0.21.0 because the first
6 + //! attempt found nothing named a picture.
7 + //!
8 + //! # This is written to leave
9 + //!
10 + //! It lives here to be proved against three real pages before it becomes a
11 + //! published crate, and it is shaped so that becoming one is a move rather than
12 + //! a rewrite: [`frames`] and [`carousel`] name no MNW type and reach for
13 + //! nothing but `quasi_router` and `makeover_layout`. The only part that stays
14 + //! behind is [`html`], which is glue between an Askama template and a renderer.
15 + //!
16 + //! When `makeover-basics` exists, the two functions move and this module keeps
17 + //! [`html`] alone, calling them through the crate.
18 + //!
19 + //! # Why the body is the frames and nothing else
20 + //!
21 + //! A carousel is a set of frames, a position, prev/next and a strip of dots.
22 + //! Only the first of those is *content*; the rest is chrome, and chrome is what
23 + //! a renderer that recognises the name draws its own way. A webview draws
24 + //! buttons over the frame, a terminal draws a pager with a count, egui draws a
25 + //! selector, and none of them owes the others a carousel primitive.
26 + //!
27 + //! So the description says "an ordered set of pictures, called a carousel" and
28 + //! stops. That is the whole of what every host agrees on.
29 + //!
30 + //! # What a renderer that has never heard of a carousel does
31 + //!
32 + //! It walks the body and draws the pictures in order. Nothing is lost: every
33 + //! frame is content, in sequence, and the reader can see all of them.
34 + //!
35 + //! This is a **better** fallback than the one being replaced. The shipped
36 + //! partial shows the first frame and makes the other two unreachable without
37 + //! JS, because the controls that would reach them are the part that needs
38 + //! scripting. Here the unenhanced rendering is the whole gallery, and the
39 + //! script's job is to collapse it to one at a time rather than to unlock the
40 + //! rest. Progressive enhancement in the direction that degrades to *more*
41 + //! content instead of less.
42 +
43 + use makeover_layout::Fit;
44 + use quasi_router::{Node, Picture, Slot};
45 +
46 + /// What the recognising renderer keys on. Never interpreted by the description.
47 + pub const NAME: &str = "carousel";
48 +
49 + /// One frame: a picture and what it says.
50 + ///
51 + /// Deliberately not MNW's `CarouselFrame`. That type is an Askama concern and
52 + /// carries what the templates happened to need; this is what the widget needs,
53 + /// and keeping them apart is what lets this module leave without dragging the
54 + /// template layer with it.
55 + #[derive(Debug, Clone, PartialEq, Eq)]
56 + pub struct Frame {
57 + /// Where the picture is.
58 + pub src: String,
59 + /// What the picture says, for anything not showing it.
60 + pub alt: String,
61 + /// A visible line under it, where there is one.
62 + pub caption: Option<String>,
63 + }
64 +
65 + impl Frame {
66 + /// A frame at a source.
67 + pub fn new(src: impl Into<String>, alt: impl Into<String>) -> Self {
68 + Self {
69 + src: src.into(),
70 + alt: alt.into(),
71 + caption: None,
72 + }
73 + }
74 +
75 + /// A visible line under it.
76 + #[must_use]
77 + pub fn caption(mut self, caption: impl Into<String>) -> Self {
78 + self.caption = Some(caption.into());
79 + self
80 + }
81 + }
82 +
83 + /// The frames as description nodes.
84 + ///
85 + /// Split out from [`carousel`] because a set of pictures in order is worth
86 + /// having on its own: a gallery that does not page is this without the widget
87 + /// name around it, and that is the second consumer this module expects.
88 + pub fn frames(frames: impl IntoIterator<Item = Frame>) -> impl Iterator<Item = Node> {
89 + frames.into_iter().map(|frame| {
90 + // Natural, and it is the whole reason `Fit` has three members rather
91 + // than the one MNW uses at 15 of its 17 other sites. A screenshot
92 + // cropped to fill its box is a screenshot with its edges cut off, and
93 + // the edges of a screenshot of an interface are where the interface is.
94 + let picture = Picture::new(frame.src, frame.alt).fit(Fit::Natural);
95 + Node::Image(match frame.caption {
96 + Some(caption) => picture.caption(caption),
97 + None => picture,
98 + })
99 + })
100 + }
101 +
102 + /// A carousel under an address.
103 + ///
104 + /// The id is the region's, which is how a fragment finds its way back to the
105 + /// right place, so it has to be unique on the page the way every slot id does.
106 + pub fn carousel(id: &str, items: impl IntoIterator<Item = Frame>) -> Slot {
107 + Slot::widget(id, NAME).extend(frames(items))
108 + }
109 +
110 + /// The markup, for an Askama template to drop in.
111 + ///
112 + /// The glue, and the one part of this module that stays behind when the rest
113 + /// moves to `makeover-basics`. Called from `partials/carousel.html` so all
114 + /// three call sites keep the macro they already have.
115 + #[must_use]
116 + pub fn html(id: &str, items: &[crate::templates::CarouselFrame]) -> String {
117 + use quasi_axum::Serves as _;
118 +
119 + let node = Node::Region(carousel(
120 + id,
121 + items.iter().map(|frame| {
122 + let built = Frame::new(&frame.image, &frame.alt);
123 + match &frame.caption {
124 + Some(caption) => built.caption(caption),
125 + None => built,
126 + }
127 + }),
128 + ));
129 +
130 + // No shell: this is a fragment landing inside a document Askama already
131 + // built, which is exactly what `fragment` is for.
132 + quasi_webview::Webview::new().fragment(&node)
133 + }
134 +
135 + #[cfg(test)]
136 + mod tests {
137 + use super::*;
138 + use quasi_axum::Serves as _;
139 +
140 + fn render(id: &str, items: Vec<Frame>) -> String {
141 + quasi_webview::Webview::new().fragment(&Node::Region(carousel(id, items)))
142 + }
143 +
144 + fn three() -> Vec<Frame> {
145 + vec![
146 + Frame::new("/a.png", "The library, mid-import").caption("Library"),
147 + Frame::new("/b.png", "A project page with two items"),
148 + Frame::new("/c.png", "The payouts table"),
149 + ]
150 + }
151 +
152 + #[test]
153 + fn the_name_is_on_the_region_for_a_renderer_that_knows_it() {
154 + let html = render("landing-shots", three());
155 + assert!(html.contains(r#"data-widget="carousel""#), "{html}");
156 + assert!(html.contains(r#"id="landing-shots""#), "{html}");
157 + }
158 +
159 + #[test]
160 + fn every_frame_is_in_the_markup_and_not_only_the_first() {
161 + // The fallback this replaces showed frame one and made the rest
162 + // unreachable without JS. The whole gallery is here, in order.
163 + let html = render("g", three());
164 + for src in ["/a.png", "/b.png", "/c.png"] {
165 + assert!(html.contains(src), "{src} missing from {html}");
166 + }
167 + let first = html.find("/a.png").unwrap();
168 + let second = html.find("/b.png").unwrap();
169 + let third = html.find("/c.png").unwrap();
170 + assert!(first < second && second < third, "frames out of order");
171 + }
172 +
173 + #[test]
174 + fn a_frame_says_what_it_shows_to_someone_not_looking_at_it() {
175 + let html = render("g", three());
176 + assert!(html.contains(r#"alt="The library, mid-import""#), "{html}");
177 + assert!(html.contains(r#"alt="The payouts table""#), "{html}");
178 + }
179 +
180 + #[test]
181 + fn a_captioned_frame_is_a_figure_and_a_bare_one_is_not() {
182 + let html = render("g", three());
183 + // One caption across the three, so one figure element.
184 + assert_eq!(html.matches("<figure").count(), 1, "{html}");
185 + assert!(html.contains("Library</figcaption>"), "{html}");
186 + }
187 +
188 + #[test]
189 + fn a_screenshot_keeps_its_own_shape() {
190 + // Natural is the default and emits no attribute, so the assertion is
191 + // that nothing asked for a crop. A cropped screenshot loses its edges,
192 + // which is where the interface is.
193 + let html = render("g", three());
194 + assert!(!html.contains("data-fit"), "{html}");
195 + }
196 +
197 + #[test]
198 + fn a_hostile_source_cannot_break_out_of_the_attribute() {
199 + let html = render(
200 + "g",
201 + vec![Frame::new(r#"x" onerror="alert(1)"#, r#"</title><script>"#)],
202 + );
203 + assert!(!html.contains("onerror=\"alert"), "{html}");
204 + assert!(!html.contains("<script>"), "{html}");
205 + }
206 +
207 + #[test]
208 + fn the_empty_case_is_a_region_with_nothing_in_it() {
209 + // Three pages call this and one of them has an empty gallery whenever
210 + // the creator uploaded nothing. It has to be a carousel with no frames
211 + // rather than a panic or a stray element.
212 + let html = render("g", Vec::new());
213 + assert!(html.contains(r#"data-widget="carousel""#), "{html}");
214 + assert!(!html.contains("<img"), "{html}");
215 + }
216 + }
@@ -1,0 +1,22 @@
1 + //! Widgets: named assemblies of things the description already says.
2 + //!
3 + //! The third tier, between a primitive and `Region::Bespoke`. A primitive is
4 + //! drawn from scratch by every renderer and has to be something every host can
5 + //! honour; a bespoke region is one app's own and carries no contents the
6 + //! description knows. A widget is neither: a *name* over an assembly of members
7 + //! the vocabulary already has.
8 + //!
9 + //! Full argument: wiki `widget-tier`.
10 + //!
11 + //! # Why they are here and not in a crate yet
12 + //!
13 + //! `makeover-basics` is where these are going — MIT, ninth member of the suite.
14 + //! They are being proved against real pages first, because a widget that has
15 + //! never rendered anything is a guess about what the assembly needs, and the
16 + //! carousel already found one missing primitive on its way through
17 + //! (`makeover_layout::Image`, 0.21.0).
18 + //!
19 + //! Each module here is written to leave: the description half names no MNW
20 + //! type, and only the Askama glue stays behind.
21 +
22 + pub mod carousel;