Skip to main content

max / makenotwork

The carousel's chrome is described now, so the dots and the overlaid arrows go quasi-basics says Showing::One and quasi-webview derives prev/position/next from it, once, for every widget. So this host stops drawing a carousel by hand: .carousel-nav, .carousel-prev, .carousel-next, .carousel-dots and .carousel-dot are 60 lines of stylesheet deleted, and the island stopped injecting buttons and building a strip -- it binds what is already on the page and holds which frame the reader moved to. The arrows were absolutely positioned over the frame, which Max called cluttered and which a terminal cannot honestly do; the dots had no form past a handful of frames, and two of the three galleries here are creator uploads of any length. Both halves of that were the same half. What stays local is the no-shift default, and it is host policy rather than a gap: makeover collapses the stack on [data-ready], which is the only honest default for a renderer that cannot know whether a page has script, and this site collapses it from first paint because the alternative was a measured 141px jump every JS visitor paid. no-js.css still opens it for the few without. Three gates caught the same pairing from three directions, which is the part to trust: the drift check, the frontend lint, and the drift check again noticing that the picture-img entry this replaces had stopped colliding.
Author: Max Johnson <me@maxj.phd> · 2026-08-14 22:53 UTC
Signed with PGP, not checked
Commit: 7fd48ec85c0002a8bc06f0242d81b20166773a60
Parent: 2707c7a
7 files changed, +165 insertions, -217 deletions
M server/Cargo.lock +37 -37
@@ -5317,9 +5317,7 @@
5317 5317
5318 5318 [[package]]
5319 5319 name = "makeover-build"
5320 - version = "0.31.0"
5321 - source = "registry+https://github.com/rust-lang/crates.io-index"
5322 - checksum = "02f630f22ab60b1f73e45956fc6ff26c31277edbee2aad523200ec96974e5a5a"
5320 + version = "0.32.0"
5323 5321 dependencies = [
5324 5322 "makeover",
5325 5323 "makeover-geometry",
@@ -5334,15 +5332,11 @@
5334 5332
5335 5333 [[package]]
5336 5334 name = "makeover-layout"
5337 - version = "0.22.0"
5338 - source = "registry+https://github.com/rust-lang/crates.io-index"
5339 - checksum = "58dad575602dd23c8f69297e9ef774be84e4c6e4c58a54892448175cb6a096da"
5335 + version = "0.23.0"
5340 5336
5341 5337 [[package]]
5342 5338 name = "makeover-touch"
5343 - version = "0.14.0"
5344 - source = "registry+https://github.com/rust-lang/crates.io-index"
5345 - checksum = "2d6241ec48fe2dea0d1051b1bfa3c6213bf0e7a75731153c20ca91c501953f24"
5339 + version = "0.15.0"
5346 5340 dependencies = [
5347 5341 "makeover-geometry",
5348 5342 "makeover-layout",
@@ -5350,9 +5344,7 @@
5350 5344
5351 5345 [[package]]
5352 5346 name = "makeover-webview"
5353 - version = "0.38.0"
5354 - source = "registry+https://github.com/rust-lang/crates.io-index"
5355 - checksum = "cf73eb60125c7ae695cfce2678b2338f22101ae5c835a080a599a9d52b595f18"
5347 + version = "0.39.0"
5356 5348 dependencies = [
5357 5349 "makeover-geometry",
5358 5350 "makeover-layout",
@@ -6550,7 +6542,7 @@
6550 6542
6551 6543 [[package]]
6552 6544 name = "quasi-axum"
6553 - version = "0.4.0"
6545 + version = "0.5.0"
6554 6546 dependencies = [
6555 6547 "axum",
6556 6548 "http 1.4.2",
@@ -6561,7 +6553,7 @@
6561 6553
6562 6554 [[package]]
6563 6555 name = "quasi-basics"
6564 - version = "0.4.0"
6556 + version = "0.5.0"
6565 6557 dependencies = [
6566 6558 "makeover-layout",
6567 6559 "quasi-router",
@@ -6569,7 +6561,7 @@
6569 6561
6570 6562 [[package]]
6571 6563 name = "quasi-http"
6572 - version = "0.4.0"
6564 + version = "0.5.0"
6573 6565 dependencies = [
6574 6566 "form_urlencoded",
6575 6567 "http 1.4.2",
@@ -6578,14 +6570,14 @@
6578 6570
6579 6571 [[package]]
6580 6572 name = "quasi-router"
6581 - version = "0.4.0"
6573 + version = "0.5.0"
6582 6574 dependencies = [
6583 6575 "makeover-layout",
6584 6576 ]
6585 6577
6586 6578 [[package]]
6587 6579 name = "quasi-webview"
6588 - version = "0.4.0"
6580 + version = "0.5.0"
6589 6581 dependencies = [
6590 6582 "docengine",
6591 6583 "makeover-layout",
@@ -10696,26 +10688,6 @@
10696 10688 "pkg-config",
10697 10689 ]
10698 10690
10699 - [[patch.unused]]
10700 - name = "quasi-immediate"
10701 - version = "0.4.0"
10702 -
10703 - [[patch.unused]]
10704 - name = "quasi-store"
10705 - version = "0.1.0"
10706 -
10707 - [[patch.unused]]
10708 - name = "quasi-tauri"
10709 - version = "0.4.0"
10710 -
10711 - [[patch.unused]]
10712 - name = "synckit-client"
10713 - version = "0.8.0"
10714 -
10715 - [[patch.unused]]
10716 - name = "synckit-config"
10717 - version = "0.2.0"
10718 -
10719 10691 [[patch.unused]]
10720 10692 name = "kberg"
10721 10693 version = "0.1.0"
@@ -10727,3 +10699,31 @@
10727 10699 [[patch.unused]]
10728 10700 name = "painhours"
10729 10701 version = "0.1.0"
10702 +
10703 + [[patch.unused]]
10704 + name = "synckit-client"
10705 + version = "0.8.0"
10706 +
10707 + [[patch.unused]]
10708 + name = "synckit-config"
10709 + version = "0.2.0"
10710 +
10711 + [[patch.unused]]
10712 + name = "quasi-immediate"
10713 + version = "0.5.0"
10714 +
10715 + [[patch.unused]]
10716 + name = "quasi-store"
10717 + version = "0.1.0"
10718 +
10719 + [[patch.unused]]
10720 + name = "quasi-tauri"
10721 + version = "0.5.0"
10722 +
10723 + [[patch.unused]]
10724 + name = "makeover-immediate"
10725 + version = "0.21.0"
10726 +
10727 + [[patch.unused]]
10728 + name = "makeover-tui"
10729 + version = "0.21.0"
@@ -158,22 +158,22 @@
158 158 # through quasi beside its Askama equivalent, so the cost of describing a screen
159 159 # here is measured rather than argued. Not load-bearing for any shipped route.
160 160 # See wiki look-wave-2, tier G.
161 - quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" }
161 + quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.5" }
162 162 # The description vocabulary quasi's screen types are built from. Pinned here
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.22.0"
166 + makeover-layout = "0.23.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"
170 - quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" }
171 - quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" }
170 + quasi-axum = { git = "https://makenot.work/git/max/quasi.git", version = "0.5" }
171 + quasi-webview = { git = "https://makenot.work/git/max/quasi.git", version = "0.5" }
172 172 # The first-party widget set. The carousel was proved here against three pages
173 173 # and then moved there; what is left in `quasi/widgets/carousel.rs` is the
174 174 # Askama glue. Not `makeover-basics`: a widget is an assembly of `Node`s, so it
175 175 # sits above quasi-router rather than inside the description suite.
176 - quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.4" }
176 + quasi-basics = { git = "https://makenot.work/git/max/quasi.git", version = "0.5" }
177 177
178 178 # Git source browser
179 179 gix = { version = "0.86", default-features = false, features = ["blame", "mailmap", "revision", "sha1", "max-performance-safe"] }
@@ -245,7 +245,7 @@
245 245 # Emits static/geometry.css (makeover-geometry) and static/layout.css
246 246 # (makeover-webview). The same generator GO and BB run; only the output paths
247 247 # differ, since the server serves its stylesheets rather than bundling them.
248 - makeover-build = "0.31.0"
248 + makeover-build = "0.32.0"
249 249 # Read directly for the embeds' own copy of the spacing layer, which they need
250 250 # because an iframe cannot link a stylesheet. makeover-build does not re-export
251 251 # it, so the pin lives here and has to track the one makeover-build resolves.
M server/build.rs +15 -12
@@ -454,19 +454,22 @@
454 454 /// what it describes.
455 455 const REVIEWED_OVERLAPS: &[(&str, &str)] = &[
456 456 ("badge", "color"),
457 - // A carousel showing one frame at a time, 2026-08-14. Makeover says a
458 - // picture is `display: block`, which is what a picture is; the widget says
459 - // the frames that are not current are not shown, which is what a carousel
460 - // is. Two different claims that happen to land on one property, and the
461 - // selector says so -- the override is
462 - // `[data-widget="carousel"][data-ready] > .picture-img`, so it reaches a
463 - // picture only while it is a frame in an enhanced carousel and never a
464 - // picture anywhere else.
457 + // A carousel showing one frame at a time, 2026-08-14. This was
458 + // `("picture-img", "display")` until makeover-layout 0.23.0 described the
459 + // showing itself; the frames are wrapped in `.showing-frame` now and the old
460 + // entry stopped colliding, which this list is built to notice.
465 461 //
466 - // `[hidden]` was the alternative and does not work: makeover's rule is an
467 - // author rule, layered or not, so `display: block` beats the UA stylesheet
468 - // and the attribute does nothing.
469 - ("picture-img", "display"),
462 + // The two arms are the same rule at two moments, and that is the whole of
463 + // why the overlap is kept. Makeover collapses the stack on `[data-ready]`,
464 + // which is the only honest default for a renderer that cannot know whether
465 + // a page has script: ship every child, take them away once something binds
466 + // them. This site collapses it from first paint instead, because rendering
467 + // three frames and collapsing them was a measured 141px -> 58px jump that
468 + // every visitor with JS paid, and `no-js.css` from a <noscript> opens the
469 + // stack for the few without. That trade is this landing page's to make and
470 + // a generated stylesheet has no way to reach <noscript>.
471 + ("showing-frame", "display"),
472 + ("current", "display"),
470 473 ("card", "color"),
471 474 ("chosen", "box-shadow"),
472 475 ("progress-fill", "background"),
@@ -11,11 +11,14 @@
11 11 first paint; with JS off they arrive and open the stack out, because the
12 12 controls that would page through it are never going to run.
13 13
14 + Only the frames. The control row needs nothing said about it here: makeover
15 + hides `.showing` until something sets [data-ready], and with scripting off
16 + nothing ever will.
17 +
14 18 A separate file rather than an inline <style> because the CSP is
15 19 `style-src 'self'`. Report-only today, but writing markup that depends on a
16 20 policy staying lax is how it stops being lax later and quietly breaks. */
17 21
18 - [data-widget="carousel"] > .picture,
19 - [data-widget="carousel"] > .picture-img {
22 + [data-widget="carousel"] > .showing-frame {
20 23 display: block;
21 24 }
@@ -11522,11 +11522,18 @@
11522 11522 THE FALLBACK RUNS THE OTHER WAY NOW. This used to render frame one and hide
11523 11523 the rest, because the controls that reach them are the scripted part -- so
11524 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.
11525 + page is the whole gallery, stacked in order.
11526 +
11527 + THE CHROME IS DESCRIBED TOO, as of makeover-layout 0.23.0. The description
11528 + says `Showing::One` -- an ordered set of pictures with one showing -- and
11529 + quasi-webview derives prev/position/next from that, once, for every widget
11530 + there will ever be. Nothing here or in any renderer matches on the name
11531 + "carousel" to get it. The island stopped injecting chrome and now only binds
11532 + what is already on the page; the dot strip and the overlaid arrows are gone.
11533 +
11534 + The controls are still not shipped live to a reader with no script: makeover
11535 + keeps `.showing` hidden until something sets [data-ready], so the markup no
11536 + longer has to be absent to avoid lying.
11530 11537 ========================================================================= */
11531 11538
11532 11539 /* Public gallery carousel sections (item + project pages). */
@@ -11549,35 +11556,28 @@
11549 11556 everyone with JS paid; `no-js.css`, loaded from a <noscript>, is what opens
11550 11557 the stack out for a visitor without scripting.
11551 11558
11552 - The bottom padding reserves the dots strip. The island injects it after the
11553 - frames, so without a held place its arrival would grow the widget and push
11554 - the page down -- a small shift, but the same kind, and the point of this pass
11555 - is that there are none. Its height: the strip's own 0.6rem plus the gap above
11556 - it. The nav buttons need no reservation; they are absolutely positioned. */
11557 - [data-widget="carousel"] {
11558 - position: relative;
11559 - padding-bottom: calc(0.6rem + var(--gap-group));
11560 - }
11559 + This is the one half of the carousel that stays local, and it is host policy
11560 + rather than a gap. makeover's generated sheet collapses the stack on
11561 + [data-ready], which is the honest default for a renderer that cannot know
11562 + whether this page has script: it ships every child and takes them away once
11563 + something binds them. Trading a load-time jump for that is a decision about
11564 + this site's landing page, and <noscript> is a mechanism a generated
11565 + stylesheet has no way to reach.
11561 11566
11562 - [data-widget="carousel"] > .picture,
11563 - [data-widget="carousel"] > .picture-img {
11564 - /* respec-ok: makeover says a picture is display:block, which is what a
11565 - picture is; this says the frames that are not current are not shown, which
11566 - is what a carousel is. Nothing in the vocabulary knows that one frame of a
11567 - set is current, so the generated sheet cannot express it. The selector
11568 - keeps the claim narrow: it reaches a picture only while it is a frame in a
11569 - carousel. [hidden] is not the way out either, because makeover's rule is an
11570 - author rule and beats the UA stylesheet. */
11567 + No padding-bottom and no position: relative any more. Both were for chrome
11568 + that is gone -- the strip that had to be reserved and the arrows that were
11569 + absolutely positioned over the frame. */
11570 + [data-widget="carousel"] > .showing-frame:not(.current) {
11571 + /* respec-ok: the generated rule is the same claim gated on [data-ready], and
11572 + what it cannot express is <noscript>. A renderer that cannot know whether a
11573 + page has script has to ship every child and take them away once something
11574 + binds them; this page collapses from first paint instead and lets no-js.css
11575 + open it back, which is a trade about this landing page rather than about
11576 + the vocabulary. */
11571 11577 display: none;
11572 11578 }
11573 11579
11574 - [data-widget="carousel"] > .picture:first-child,
11575 - [data-widget="carousel"] > .picture-img:first-child,
11576 - [data-widget="carousel"] > .is-active {
11577 - display: block;
11578 - }
11579 -
11580 - [data-widget="carousel"] > .picture {
11580 + [data-widget="carousel"] .picture {
11581 11581 margin: 0;
11582 11582 }
11583 11583
@@ -11627,7 +11627,7 @@
11627 11627
11628 11628 /* ---------------------------------------------------------------------------
11629 11629 Enhanced. Everything below needs the island, and the island sets
11630 - [data-ready] once it has upgraded and injected the chrome.
11630 + [data-ready] once it has upgraded and bound the controls.
11631 11631 --------------------------------------------------------------------------- */
11632 11632
11633 11633 [data-widget="carousel"][data-ready]:focus-visible {
@@ -11635,71 +11635,18 @@
11635 11635 outline-offset: 3px;
11636 11636 }
11637 11637
11638 - /* Prev / next: brutalist offset buttons, vertically centered over the frame. */
11639 - .carousel-nav {
11640 - position: absolute;
11641 - top: 40%;
11642 - transform: translateY(-50%);
11643 - width: 2.5rem;
11644 - height: 2.5rem;
11645 - display: flex;
11646 - align-items: center;
11647 - justify-content: center;
11648 - font-size: var(--text-head);
11649 - line-height: 1;
11650 - cursor: pointer;
11651 - color: var(--content);
11652 - background: var(--surface-raised);
11653 - border: 1px solid var(--border-strong);
11654 - border-radius: var(--radius-control);
11655 - }
11638 + /* The control row is makeover's `.showing`, rendered by the description rather
11639 + than injected here, and it needs no rules: it is two `.button`s and a muted
11640 + readout, all three of which the generated sheet already styles. What used to
11641 + sit here was `.carousel-nav`, `.carousel-prev`, `.carousel-next`,
11642 + `.carousel-dots` and `.carousel-dot` -- 60 lines for chrome this host drew by
11643 + hand because nothing could describe it.
11656 11644
11657 - .carousel-nav:hover {
11658 - background: var(--surface-sunken);
11659 - }
11645 + The arrows were absolutely positioned over the frame, which Max called
11646 + cluttered and which a terminal cannot honestly do at all; the dots had no
11647 + form past a handful of frames, and two of the three galleries here are
11648 + creator uploads of arbitrary length. Both halves of that were the same half. */
11660 11649
11661 - .carousel-nav:active {
11662 - background: var(--surface-sunken);
11663 - transform: translateY(-50%) translateY(1px);
11664 - }
11665 -
11666 - .carousel-nav:focus-visible {
11667 - outline: 2px solid var(--focus-ring);
11668 - outline-offset: 2px;
11669 - }
11670 -
11671 - .carousel-prev { left: 0.6rem; }
11672 - .carousel-next { right: 0.6rem; }
11673 -
11674 - .carousel-dots {
11675 - display: flex;
11676 - justify-content: center;
11677 - gap: var(--gap-peer);
11678 - margin-top: var(--gap-group);
11679 - }
11680 -
11681 - .carousel-dot {
11682 - width: 0.6rem;
11683 - height: 0.6rem;
11684 - padding: 0;
11685 - cursor: pointer;
11686 - border: 1px solid var(--border-strong);
11687 - border-radius: 50%;
11688 - background: transparent;
11689 - }
11690 -
11691 - .carousel-dot.is-active {
11692 - background: var(--content);
11693 - }
11694 -
11695 - .carousel-dot:focus-visible {
11696 - outline: 2px solid var(--focus-ring);
11697 - outline-offset: 2px;
11698 - }
11699 -
11700 - @media (max-width: 599px) {
11701 - .carousel-nav { width: 2rem; height: 2rem; font-size: var(--text-subhead); }
11702 - }
11703 11650
11704 11651 /* ---------------------------------------------------------------------------
11705 11652 Custom page editor (dashboard/custom_page_editor.html)
@@ -6,8 +6,14 @@
6 6 needs more, extend the macro.
7 7
8 8 Click-through, not animated: the viewer advances frames with the prev/next
9 - controls, the dots, or the arrow keys. No autoplay, no auto-advance, no
10 - slide animation, frames swap instantly.
9 + controls or the arrow keys. No autoplay, no auto-advance, no slide animation,
10 + frames swap instantly.
11 +
12 + The dots are gone as of 2026-08-14. A dot strip has no terminal form and no
13 + good webview form past a handful of frames, and only the landing gallery is a
14 + fixed three -- the project and item galleries are creator uploads of any
15 + length. The arrows came off the picture in the same pass; nothing overlays a
16 + frame now.
11 17
12 18 Each frame is a `CarouselFrame { image, alt, caption: Option<_> }`
13 19 (see `src/templates/mod.rs`). Usage is unchanged:
@@ -35,6 +41,11 @@
35 41 the stack out for a visitor without it. Both properties, no layout shift:
36 42 collapsing three frames to one after load was a visible jump that everyone
37 43 paid so that a few could reach frames two and three.
44 +
45 + - The chrome is described too, as of makeover-layout 0.23.0. The description
46 + says one frame is showing and quasi-webview derives prev/position/next from
47 + that, for every widget at once rather than for this one. The island stopped
48 + injecting buttons and now only binds what is already there.
38 49 #}
39 50
40 51 {% macro carousel(id, frames) -%}
@@ -1,18 +1,23 @@
1 - // <div data-widget="carousel">, click-through carousel over a described gallery.
1 + // <div data-showing="one">, click-through carousel over a described gallery.
2 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.
3 + // The widget tier's browser half, and it is smaller than it was. The server no
4 + // longer renders a carousel and no longer renders its chrome either: it renders
5 + // an ordered set of pictures that says one of them is showing, and quasi-webview
6 + // derives prev/position/next from that fact alone. So this file stopped
7 + // injecting buttons and stopped building a dot strip. What is left is the one
8 + // thing a description cannot carry: which frame the reader has moved to since
9 + // the page arrived.
8 10 //
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.
11 + // NOTHING HERE KNOWS WHAT A CAROUSEL IS. The hook is `data-showing`, not
12 + // `data-widget="carousel"`, and that is deliberate: a script that matched on the
13 + // widget's name would have to be written again for the next assembly showing one
14 + // child at a time. This binds every one of them, including ones that do not
15 + // exist yet.
16 + //
17 + // PROGRESSIVE ENHANCEMENT. The controls are on the page but makeover keeps
18 + // `.showing` hidden until `data-ready` is set, so a reader with no script is
19 + // never offered a button that does nothing. `no-js.css`, loaded from a
20 + // <noscript> in the partial, opens the frame stack out for them instead.
16 21
17 22 import { MnwElement, define } from './base.ts';
18 23 import { wrapIndex } from './carousel.logic.ts';
@@ -24,69 +29,48 @@
24 29 // whole reason islands are custom elements; the description emits a plain
25 30 // `<div>` because it has no idea this host prefixes its tags with `mnw-`.
26 31 // So the template puts one around the other and each half stays honest.
27 - const region = this.querySelector<HTMLElement>('[data-widget="carousel"]');
32 + const region = this.querySelector<HTMLElement>('[data-showing]');
28 33 if (!region) return;
29 34
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,
35 + // The wrappers the renderer puts around each child, which are what carry
36 + // `current`. Selected by class because this one IS the renderer's own
37 + // structural name rather than a piece of makeover's vocabulary, and the
38 + // description has no other way to mark which child is which.
39 + const frames = Array.from(
40 + region.querySelectorAll<HTMLElement>(':scope > .showing-frame'),
40 41 );
41 42 if (frames.length < 2) return; // nothing to navigate; leave the stack alone
42 43
43 - let index = 0;
44 - const dots: HTMLElement[] = [];
44 + let index = Math.max(
45 + frames.findIndex((f) => f.classList.contains('current')),
46 + 0,
47 + );
48 +
49 + const position = region.querySelector<HTMLElement>('.showing-position');
45 50
46 51 const show = (next: number): void => {
47 52 index = wrapIndex(next, frames.length);
48 53 frames.forEach((f, i) => {
49 54 const active = i === index;
50 - f.classList.toggle('is-active', active);
55 + f.classList.toggle('current', active);
51 56 if (active) f.removeAttribute('aria-hidden');
52 57 else f.setAttribute('aria-hidden', 'true');
53 58 });
54 - dots.forEach((d, i) => {
55 - const active = i === index;
56 - d.classList.toggle('is-active', active);
57 - d.setAttribute('aria-selected', active ? 'true' : 'false');
59 + if (position) position.textContent = `${index + 1} / ${frames.length}`;
60 + };
61 +
62 + // Bind what is already there. `data-shows` is the renderer's word for what
63 + // a control does to the region it sits in, and it takes an index as well as
64 + // the two steps, so a derived tab strip binds through this same loop.
65 + for (const control of region.querySelectorAll<HTMLElement>('[data-shows]')) {
66 + const shows = control.dataset.shows ?? '';
67 + control.addEventListener('click', () => {
68 + if (shows === 'previous') return show(index - 1);
69 + if (shows === 'next') return show(index + 1);
70 + const at = Number.parseInt(shows, 10);
71 + if (Number.isInteger(at)) show(at);
58 72 });
59 - };
60 -
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);
88 - });
89 - region.append(strip);
73 + }
90 74
91 75 // The group semantics the template used to carry. They belong with the
92 76 // behaviour: a stack of pictures is not a carousel until this runs, and
@@ -112,8 +96,8 @@
112 96 }
113 97 });
114 98
115 - show(0);
116 - region.setAttribute('data-ready', ''); // CSS hook: collapse to one frame
99 + show(index);
100 + region.setAttribute('data-ready', ''); // reveals the row, collapses the stack
117 101 }
118 102 }
119 103