A media gallery built from up to three rows — top, middle and bottom — each optional and each holding up to four items. Clicking anything opens a full-screen lightbox with swipe, keyboard navigation and captions. Photos and videos mix freely; YouTube and Vimeo both work from an ordinary link.
<div class="tcu-grid-gallery tcu-component">
<div class="tcu-grid-gallery__row">
<figure class="tcu-grid-gallery__item">
<button type="button" class="tcu-grid-gallery__trigger" data-gallery-src="https://placehold.co/900x600">
<picture>
<source media="(min-width: 768px)" srcset="https://placehold.co/900x600" />
<img class="tcu-grid-gallery__media" loading="lazy" src="https://placehold.co/768x512" alt="Placeholder Image Description" />
</picture>
</button>
<figcaption class="tcu-grid-gallery__caption">Caption test</figcaption>
</figure>
<figure class="tcu-grid-gallery__item">
<button type="button" class="tcu-grid-gallery__trigger" data-gallery-embed="https://vimeo.com/1108228083">
<picture>
<img class="tcu-grid-gallery__media" loading="lazy" alt="Video: a clinician demonstrating equipment" />
</picture>
<span class="tcu-grid-gallery__badge"></span>
</button>
</figure>
<figure class="tcu-grid-gallery__item">
<button type="button" class="tcu-grid-gallery__trigger" data-gallery-embed="https://www.youtube.com/watch?v=SXeTPnRXACc">
<picture>
<img class="tcu-grid-gallery__media" loading="lazy" alt="Video: inside the speech and hearing clinic" />
</picture>
<span class="tcu-grid-gallery__badge"></span>
</button>
<figcaption class="tcu-grid-gallery__caption">A tour of the clinic's teaching spaces.</figcaption>
</figure>
<figure class="tcu-grid-gallery__item">
<button type="button" class="tcu-grid-gallery__trigger" data-gallery-src="https://placehold.co/700x1050">
<picture>
<source media="(min-width: 768px)" srcset="https://placehold.co/700x1050" />
<img class="tcu-grid-gallery__media" loading="lazy" src="https://placehold.co/768x1152" alt="Placeholder Image Description" />
</picture>
</button>
</figure>
</div>
</div>
**Contents**
The JavaScript: [Call map](#call-map) · [How it runs](#how-it-runs) ·
[State: what lives where](#state-what-lives-where) ·
[What the platform provides, so we don't](#what-the-platform-provides-so-we-dont)
Reference: [Things to keep in mind](#things-to-keep-in-mind) ·
[Failure modes](#failure-modes) · [Video](#video)
Authoring: [Item Context](#item-context) · [SCSS Config](#scss-config) ·
[Markup, layout and sizing](#markup-layout-and-sizing) ·
[Accessibility](#accessibility)
---
## Call map
Twenty-six functions in `_src/js/tcu-grid-gallery.js`, in the order they actually run.
`▶` marks an asynchronous hop — an event, an observer callback or a promise.
```
module load
SELECTORS · CONFIGURATION · dialogUI · session
youTubeApiPromise · currentOpenId
readyState guard ──▶ init
init on DOMContentLoaded
├─ posterObserver (rootMargin 200px)
│ ──▶ loadMissingPosters
│ ├─ parseVideoUrl
│ └─ loadPoster
└─ one click listener per gallery
──▶ handleTriggerClick
└─ openLightbox
├─ createDialog first open only
│ ├─ createDialogControls
│ │ └─ createIconButton x3
│ ├─ 'close' ──▶ handleDialogClose
│ └─ 'keydown' ──▶ handleDialogKeydown
├─ createEmptySlides
├─ showModal()
├─ scrollToSlide
├─ setActiveSlide
│ ├─ readTrigger
│ ├─ fillSlide x3, at i, i-1, i+1
│ │ ├─ readTrigger
│ │ ├─ parseVideoUrl
│ │ ├─ createYouTubePlayer ─ loadYouTubeApi ──▶ YT.Player
│ │ ├─ createVimeoFrame ──── createEmbedFrame
│ │ ├─ createEmbedFrame unrecognised URL
│ │ └─ createSlideImage plain image
│ └─ pauseInactiveVideos
└─ watchActiveSlide
──▶ setActiveSlide loops back, on every scroll
buttons / arrow keys ──▶ stepSlide
├─ wrapToSlide ──▶ scrollToSlide
└─ scrollToSlide
└─ prefersReducedMotion
```
---
## How it runs
### 1. Page load
`init` runs from a `readyState` guard at the very bottom of the file rather than a bare
`DOMContentLoaded` listener, because that listener never fires if the event has already
happened — which it usually has, since the script is deferred.
It queries for galleries and returns immediately if there are none, so a page without one
pays nothing. For each gallery it does exactly two things: attaches one delegated `click`
listener, and hands the gallery to a poster observer.
That observer is built inside `init` and lives for the life of the page, which is why it
is a local rather than something on `session` — `session` is wiped on every close, and
this needs to outlast that. It is also deliberately separate from the slide observer built
later. With `rootMargin: '200px'` it fires just before a gallery scrolls into view, then
`unobserve`s that gallery so the work happens once.
When it fires, `loadMissingPosters` walks the gallery's triggers and picks out only those
that are videos (`data-gallery-embed`) whose `<img>` has no `src` — an author-supplied
poster is left alone, costing no request. For each, `parseVideoUrl` normalises the link
and `loadPoster` fetches a thumbnail. That function forks by provider:
- **YouTube needs no request.** Every thumbnail URL is derivable from the video id, so it
sets `maxresdefault` directly. The catch is that old uploads do not have one, and a miss
answers 404 _with a valid 120x90 placeholder image in the body_ — so the `<img>` fires
`load`, not `error`, and an `onerror` fallback would never run. It listens for `load`
and re-tests `naturalWidth` instead, falling back once to `mqdefault`.
- **Vimeo needs a request**, because its thumbnail URL cannot be derived. It builds a
canonical `vimeo.com/ID` URL for the oEmbed call — not `player.vimeo.com/video/ID`,
which 404s at that endpoint even though it is the correct URL for the iframe itself —
and appends `width`, without which the response silently carries a 295px thumbnail.
### 2. The click
`handleTriggerClick` is delegated, bound once per gallery, so it works no matter when the
markup arrived. It walks up from the event target to find the trigger, then up again to
find the gallery, and bails if either is missing.
It then collects **that gallery's** triggers, which is the whole mechanism keeping two
galleries on one page independent — gallery two counts its own slides, not every image on
the page.
The `trigger.focus()` line looks redundant next to `showModal()`, which restores focus on
close by itself. It is there because Safari does not focus a button when you click it, so
without this there would be nothing to restore to.
### 3. Opening
`openLightbox` takes the trigger list and the index that was clicked.
On the **first** open only, `createDialog` builds the dialog, the track, the counter, the
announcer, and — via `createDialogControls` and three calls to `createIconButton` — the
prev, next and close buttons. It also wires the two handlers that everything later depends
on: `close` to `handleDialogClose` and `keydown` to `handleDialogKeydown`. The dialog is
appended to `<body>` and reused by every gallery on the page from then on.
Then, every open:
- `currentOpenId += 1`. This is the token an in-flight YouTube player checks to discover
that the dialog closed while it was still loading.
- `createEmptySlides` adds one empty `<figure>` per trigger. The boxes go in up front so
the track has its scroll-snap points immediately; the media comes later.
- The lock class goes on `<html>`, then `showModal()`.
- `scrollToSlide(index, 'instant')` — **after** `showModal()`, which is the first moment
the slides have a layout and therefore an `offsetLeft` to scroll to.
- `setActiveSlide(index)` is called directly rather than left to the observer, whose first
callback is asynchronous and would briefly show slide 1's caption on the way.
- `watchActiveSlide` builds the slide observer last, once everything is in place.
### 4. When a slide becomes active
`setActiveSlide` is the single funnel. The observer calls it on every scroll, and
`openLightbox` calls it once directly, so there is one place where "the active slide
changed" is handled.
Two guards up front. Returning when the index has not changed makes it cheap to call
repeatedly. Returning when there is no trigger at that index catches a stale observer
entry arriving after a close, where `indexOf` gives `-1`.
Then it records the index, reads the item with `readTrigger`, and writes both readouts:
the visible counter (`3 / 9`) and the `aria-live` announcer (`Slide 3 of 9: <alt>`).
Finally it calls `fillSlide` three times — the active slide and both neighbours — and
`pauseInactiveVideos`, which walks the player registry and pauses everything that is not
the active slide. Because each provider registered its own `pause` when its slide was
built, that function never has to ask which provider it is dealing with.
### 5. Filling a slide
`fillSlide` is guarded to run once per slide: if the slide already has a first child, it
returns. That is what makes it safe for `setActiveSlide` to call it on three indexes every
time the active slide changes. Out-of-range indexes at either end are also fine.
It marks the slide loading, then branches four ways:
- **YouTube** → `createYouTubePlayer`, which appends a placeholder `<div>` and calls
`loadYouTubeApi`. That injects the API script once, chaining any existing
`window.onYouTubeIframeAPIReady` rather than overwriting it, and caches the promise so
later slides reuse it. When it resolves, the captured `openId` is compared against
`currentOpenId`: if the dialog closed in the meantime, it stops, because building a
player on a detached div would leak one nothing can destroy. Otherwise `YT.Player`
replaces the placeholder with an iframe it owns, and `onReady` does three things —
reapplies the class (the API replaced our element), registers `pause` and `destroy`, and
clears the spinner, since there is no element of ours whose `load` event could.
- **Vimeo** → `createVimeoFrame`, which builds the URL (keeping the `h=` hash so unlisted
videos still play), delegates the element to `createEmbedFrame`, and registers a `pause`
that posts a message straight to the iframe. No SDK.
- **An unrecognised embed URL** → `createEmbedFrame` with the author's string used
verbatim. This is not an error path; it is what keeps hand-written embed URLs working.
- **A plain image** → `createSlideImage`, with no width or height set, because the CSS
caps do the fitting.
For everything except the YouTube path, `load` and `error` both clear the spinner, and
`media.complete` is checked immediately afterwards — a cached image can finish decoding
before the listener is attached, which would otherwise strand the spinner over a picture
that is already painted.
A caption, if the item has one, is appended last as a `<figcaption>` inside the slide.
### 6. Navigating
The prev and next buttons and `handleDialogKeydown` all land in the same place:
`stepSlide(±1)`. Escape is deliberately left unmatched in the key handler so the dialog's
own close behaviour runs.
`stepSlide` adds `total` before taking the modulo, which is what turns a delta of `-1` at
slide 0 into the last slide rather than a negative index. Wrapping means neither button is
ever inert.
It then decides how to move. A target more than one slide away can only be a wrap, and
scrolling smoothly across the whole track would fly past every slide in between — slow,
disorienting, and the observer would announce each one on the way. So a wrap goes to
`wrapToSlide`, which fades the track out, repositions instantly while nothing can be seen,
and fades back in. It bails to a plain instant jump under `prefers-reduced-motion`, where
there is no transition and therefore no `transitionend` to wait on, and ignores a second
press while a fade is already running.
An ordinary step goes straight to `scrollToSlide`, which scrolls to the slide's own
`offsetLeft` rather than computing a delta — `scrollBy` would accumulate sub-pixel error
on every press and assumes the slide is exactly as wide as the track.
Either way the track moves, the observer notices, and we are back at step 4.
### 7. Closing
`handleDialogClose` is bound to the dialog's own `close` event, not to the close button.
That is why Esc, the close button and a programmatic `close()` all converge on one
teardown path.
Order matters in one place: the players are destroyed **before** the track is emptied,
because a `YT.Player` holds its own listeners and leaks if its iframe is pulled out from
under it first.
After that it disconnects and drops the observer, clears the player registry, empties the
track, and resets the four `session` fields. The announcer is blanked deliberately — so
that reopening on the same slide still counts as a change worth announcing rather than a
no-op. Last, the lock class comes off `<html>`.
What it does **not** reset is as deliberate: `dialogUI` keeps its elements for reuse,
`youTubeApiPromise` keeps the API from being downloaded twice, and `currentOpenId` only
ever increments, since its whole job is to be different from the value a stale callback
captured.
---
## State: what lives where
The lifetime split is the part to preserve, because it is what makes the teardown
verifiable at a glance:
```
dialogUI DOM refs — built once on first open, never reset
session per-interaction data — cleared on every close
youTubeApiPromise module scope — survives close, or the SDK reloads each time
currentOpenId module scope — lets an in-flight player abandon itself if the
dialog closed while the API was still downloading
```
Anything added to `session` is cleared on close. Anything that has to outlive a close
belongs beside those last two, never in `session`. Relatedly, nothing caches a
`{src, alt, caption}` record — `readTrigger` re-reads the DOM at the point of use, so the
markup stays the single source of truth.
---
## What the platform provides, so we don't
`showModal()` gives us the top layer — which is why **nothing in this component sets a
z-index** — plus `::backdrop`, a focus trap, Esc to close, focus returned to the
triggering button, and the rest of the page marked inert for assistive tech.
CSS `scroll-snap` gives us the swipe, with native momentum and rubber-banding on every
touch device. Real `<button>` triggers give us Enter, Space and focusability.
So the JS contains **no** focus trap, Esc handler, touch or drag handler, Enter/Space
handler, or tabindex/role management.
The one gap `showModal()` leaves is background scroll. That's what the
`tcu-grid-gallery-lock` class on `<html>` is for.
---
## Things to keep in mind
A few spots here look like they could be simplified or tidied, but each one is carrying
weight that isn't obvious from a glance.
- The `null` fallback in `parseVideoUrl` → `fillSlide` is what keeps pre-existing
authored embed URLs working.
- `YT.Player` replaces the element it's given, so `onReady` reapplies the class —
otherwise the player loses all styling.
- `pauseVideo` doesn't exist until the player is ready, which is why it's registered
inside `onReady` rather than at construction.
- A local variable named `session` would shadow the module state object, and
`session.players.set()` inside `onReady` would then throw. This happened once: the
spinner never cleared and pause stopped working, while the class assertion above it
still passed.
- Players are destroyed before the track is emptied, in `handleDialogClose`, because a
`YT.Player` leaks its listeners if its iframe is pulled out from under it first.
- The Twig's conditional `src` avoids `src=""`, which would make the browser re-request
the page.
- `youTubeApiPromise` and `currentOpenId` live outside `session` on purpose — `session`
is cleared on every close, and these two need to outlive that.
- `scrollToSlide` uses `offsetLeft` rather than a computed delta.
- `loadPoster` tests `naturalWidth` rather than `onerror`, since a missing thumbnail
404s with a valid placeholder image in the body — the `<img>` still fires `load`.
Switching to `onerror` would look tidier and would silently leave every old upload
showing a 120x90 grey square.
- The caption and announcer are hidden by clipping, never by `display: none` or
`visibility: hidden` — both drop the element from the accessibility tree, which would
leave the announcer holding perfectly correct text that no screen reader ever reads.
Asserting `textContent` wouldn't catch that; the accessibility tree has to be read.
- `wrapToSlide` guards for reduced motion because with no transition there's no
`transitionend`, so without the guard the wrap never completes and the lightbox
freezes on the last slide.
- The height chain described under
[Markup, layout and sizing](#markup-layout-and-sizing) needs to stay definite end to
end.
---
## Failure modes
**Playing a video logs third-party console errors** — by design, from inside the
providers' own iframes. Not a signal of anything wrong; see [Video](#video).
**A Vimeo poster fetch fails, or the video was deleted** — silent. The `<img>` keeps no
`src` and renders its alt text.
**A YouTube video is deleted** — every size answers 404 with the 120x90 grey placeholder,
and the fallback stops after one step, so the grid shows that grey square stretched
across the slot. A deliberate trade for a shorter fallback.
**Link format not recognised** — falls back to a raw iframe with the author's string.
Intended, not a bug.
**JS disabled** — the grid still works and author-supplied posters still show.
Auto-thumbnails show alt text only, and the lightbox doesn't open at all.
**YouTube API blocked** — the slide keeps its spinner. Nothing throws.
**Vimeo video is domain-restricted** — may never become ready off an allow-listed host.
Looks like a hang rather than an error.
**Keyboard focus inside a playing video** — once focus enters a video iframe, arrow keys
no longer reach the dialog, because cross-origin iframes capture keyboard events. Shift-Tab
back to the arrow controls to navigate; Esc and the close button still work. Non-active
slides are `inert`, so Tab cannot reach a slide that is off screen — without that, focus
landing in an off-screen slide made the browser scroll it into view, which tripped the
observer and swapped the active slide out from under you. When the slide holding focus
becomes inert, focus falls back to the document body and the next Tab starts from the
dialog's first control.
**Arrow keys pressed faster than ~250ms apart** — presses are lost. `stepSlide` reads
`activeIndex`, which the observer only updates once a slide is 60% in view. Known, not
fixed.
---
## Video
### Accepted links
```
youtube.com/watch?v=ID with any extra params
youtu.be/ID youtu.be/ID?t=30
youtube.com/embed/ID /shorts/ID, /live/ID
m.youtube.com/… youtube-nocookie.com/embed/ID
vimeo.com/ID
vimeo.com/ID/HASH unlisted, the hash is kept
player.vimeo.com/video/ID ?h=HASH also works
```
Anything else — `vimeo.com/channels/…`, a bare numeric id — returns `null`. That is **not
an error**: the URL is used verbatim as an iframe `src`, which is what keeps older
hand-written embed URLs working. Note a bare numeric id does not work here, unlike
`@video`; this component takes links.
### Thumbnail sizes
Vimeo has four real widths, and a request in between snaps down to the one below.
`maxheight` and `height` are both ignored, so width is the only lever — which is why a
portrait video asked for at 1280 comes back 2276 tall. 640 is what is set: a strip slot
renders at 285–435px, so that covers it at 2x, and it lands near YouTube's 68KB rather
than three times it.
| width= | Returns | Weight |
| ------ | --------- | -------- |
| 295 | 295x524 | 9KB |
| 640 | 640x1138 | **72KB** |
| 960 | 960x1707 | 134KB |
| 1280 | 1280x2276 | 203KB |
For YouTube, only three sizes are guaranteed, and only `mqdefault` and `maxresdefault`
are true 16:9 — the 4:3 ones have **black letterbox bars baked into the image**. oEmbed is
no help in choosing: it ignores `maxwidth` and always reports `hqdefault`.
| File | Size | Shape | Always there |
| ------------------- | -------- | --------- | ------------ |
| `default.jpg` | 120x90 | 4:3, bars | yes |
| `mqdefault.jpg` | 320x180 | true 16:9 | yes |
| `hqdefault.jpg` | 480x360 | 4:3, bars | yes |
| `sddefault.jpg` | 640x480 | 4:3, bars | no |
| `maxresdefault.jpg` | 1280x720 | true 16:9 | no |
### Why YouTube needs the API and Vimeo doesn't
`enablejsapi=1` on a hand-built iframe looks like it should be enough, but the player
silently ignores every command until the parent completes a `listening` handshake, and
only the API does that. It is the component's one dependency, injected lazily the first
time someone reaches a YouTube slide — a lighter cost than it sounds, the loader plus
`www-widgetapi.js` running about 10KB rather than the 60KB often quoted.
Vimeo accepts `postMessage('{"method":"pause"}')` with no handshake, so its SDK would be
a second dependency for nothing.
Hand-built iframes carry one `allow` string covering both providers. The legacy
`allowfullscreen` attribute is deliberately absent: `allow` supersedes it, and setting
both logs a console warning.
### Console noise from the players
Playing either provider logs third-party console errors, and none of them come from this
component. The CORS failure against `googleads.g.doubleclick.net` is YouTube's
ad-conversion beacon, fired inside its own iframe — the console line names
`www.youtube.com` as the origin, which is how to tell. The CORB blocks on
`vimeo.play_video` and `vimeo.watch_video_heartbeat` are Vimeo's analytics heartbeat. The
`unload` deprecation warning is `f.vimeocdn.com/…/vendor.module.js`, Vimeo's player
bundle; nothing here registers an unload handler. Measured on a deployed page with both
videos playing, zero console messages and zero failed requests originated in the page's
own frame. Cloudflare Turnstile spam and a `player.vimeo.com` 401 appear only under
browser automation.
---
## Item Context
Every item in `top`, `middle` and `bottom` takes the same five keys, and
`@grid-gallery-item` renders one.
```json
{ "image_mobile": "/img/photo-400.jpg", "image_desktop": "/img/photo-800.jpg", "alt": "Three students at a table" }
{ "embed": "https://youtu.be/abc123", "alt": "Play video: a tour of the clinic" }
```
- `image_mobile` / `image_desktop` — two renditions of one photo, or a video's poster.
`image_desktop` takes over at 768px and is what the lightbox opens. Both are optional on
a video item; omit them and the poster fills itself in from the provider.
- `alt` — required. Use `""` for a decorative image; the caption still shows.
- `caption` — optional. Appears in the lightbox and is read by screen readers on the page
even when the lightbox never opens. Write an ampersand as `&`.
- `embed` — any YouTube or Vimeo link. **Its presence is what makes an item a video.**
There is no `type` flag.
## SCSS Config
All nine SCSS variables are `!default`. The ones worth reaching for:
`$grid-gallery-single-item-height`, `$grid-gallery-gap` and `$grid-gallery-radius`.
At medium and up a row becomes a flex row, and shape comes from how many items share that
row rather than from which row it is. An item that shares its row gets a 4:3 media and
crops to it; an item alone in its row keeps the image's own ratio, capped at
`$grid-gallery-single-item-height`. Nothing declares a width, and no row has a style of
its own — a one-item `top` renders exactly like a one-item `middle`.
**That cap sits on the media, never on the row.** `max-height` on a row clamps the row's
own box while the image inside keeps rendering, so it hides nothing and the overflow paints
over the row below. On the media the `object-fit: cover` already there crops it instead. A
row is therefore free to grow to whatever its items need — which is what lets a shared row
exceed `$grid-gallery-single-item-height` on a very wide container, as a 4:3 media legitimately
does past about 3300px.
The play badge and the three lightbox icons are data-URI variables painted as background
images, so no SVG markup lives in the template or the JS.
---
## Markup, layout and sizing
Most of this is commented at the rule or template that does it. What is not:
Three regions — `top`, `middle`, `bottom` — each a `.tcu-grid-gallery__row` holding
`<figure>` items that `@grid-gallery-item` renders. The three are interchangeable: same
element, no modifier between them, each a list of 0–4 items, and an empty one renders
nothing at all. What a row looks like follows from how many items it holds, never from
which of the three it is. The default context fills them 4–1–4, which is the arrangement
the component was drawn for, but nothing in the markup or the CSS enforces it.
**Order is DOM order**, which is also the lightbox slide order, so what a user sees and
what they swipe through agree. Nothing reorders with `order:` or `row-reverse`.
```
MOBILE (base) MEDIUM AND UP (768px+)
┌──────────────┐ ┌────┬────┬────┬────┐ top, 0–4 items
│ item 1 │ ├────┴────┴────┴────┤
├──────────────┤ │ │
│ item 2 │ │ MIDDLE │ middle, 0–4 items
├──────────────┤ │ │
│ MIDDLE │ ├────┬────┬────┬────┤
├──────────────┤ └────┴────┴────┴────┘ bottom, 0–4 items
│ item 3 │
└──────────────┘
everything full width, rows become flex rows,
natural aspect ratios, shared rows crop to 4:3,
nothing cropped a lone item keeps its ratio
```
`grid-gallery.twig` is the source of truth for DOM shape; the eventual XSL template has
to match it. Four classes are what the JS binds to, via `SELECTORS`:
`.tcu-grid-gallery`, `__trigger`, `__media`, `__caption`. Four decisions in that template
are invisible from reading it:
- **A real `<button>`, not a div with `role="button"`.** Already focusable, already fires
on Enter and Space — which is why the JS adds no `tabindex`, `role` or keydown handler.
- **No `aria-label` on the trigger.** The `<img alt>` is its accessible name; a label
repeating it makes screen readers say it twice. Put any needed verb in the alt.
- **Caption text lives in element content, not a data attribute**, so an `&` escapes once
instead of twice. The `<figcaption>` is also a sibling of the button, since it is not
part of the clickable target.
- **No dimension attributes anywhere**, for the reason below.
### Image Sizing
The CMS gives an image path and no dimensions, so the component sizes images with CSS caps
rather than `width`/`height` attributes. Nothing here depends on knowing an image's size,
and nothing measures one — the browser already knows the intrinsic size once the file
arrives, since it needs that to paint.
The slide's padding is the single statement of how much room the image gets, and the
image's `max-width` and `max-height` resolve against whatever it leaves. Portrait hits the
height cap, landscape hits the width cap, a small thumbnail hits neither. No JS, no
measurement, no timing.
**No viewport units**, so mobile browser chrome hiding and showing cannot resize the image
mid-swipe. The percentage `max-height` only resolves because the height chain is definite:
```
viewport → dialog (inset: 0) → track (height: 100%) → slide (flex item, stretches)
```
Give the track an auto height and it goes circular: the browser drops the max-height and a
2400px image renders at full size.
The 96px clearance isn't arbitrary — it has to exceed the caption bar's tallest measured
state, about 91px when a long caption wraps to three lines on a phone.
**Iframes are the inverse of images.** An `<img>` has intrinsic dimensions, so capping
both axes fits it; an `<iframe>` has none, so it gets a fixed box and the provider
letterboxes inside it.
---
## Accessibility
Most of this is commented at the code that does it — the counter's `aria-hidden` and the
track's `role="group"` in `createDialog`, the icon buttons' labels in
`createIconButton`, and the visually-hidden caption rule in the SCSS. What is not
recorded anywhere else:
**The dialog's `aria-label`.** Without it screen readers announce a nameless dialog.
**What the announcer reads** — `Slide 3 of 9: <alt>`, not just the position. The number
alone doesn't tell a screen reader user _what_ they are on.
**The lightbox caption is a `<figcaption>` inside the slide `<figure>`**, so it is
associated with its image and reads with it rather than after the buttons.