max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
9 files changed,
+496 insertions,
-201 deletions
| @@ -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. |
| @@ -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">‹</button> | |
| 34 | - | <button type="button" class="carousel-nav carousel-next" data-carousel-next aria-label="Next screenshot">›</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; |