/**
 * css/components/scroll-gallery.css — scroll-driven gallery component (#284)
 *
 * Sticky-pin scrollytelling, discrete not scrubbed: `.cg-scrollgal` is a
 * tall wrapper; `.cg-scrollgal__pin` sticks in place while the reader
 * scrolls through the wrapper's extra runway height; js/cg-scroll-gallery.js
 * maps how far the wrapper has scrolled past the top of the viewport onto a
 * slide index and toggles `.is-active`/`.is-before`/`.is-after` on
 * `.cg-scrollgal__slide` elements — the same three-class contract used
 * elsewhere in the 2.0 component library (e.g. worklist-slider.php's
 * `.is-active`), just with a horizontal-travel transition instead of a
 * plain swap. Reference: docs/2.0-system-guide.md component inventory entry
 * for this file; markup contract + all three non-negotiable rules below are
 * from issue #284.
 *
 * NEW NAMESPACE, ZERO BLAST RADIUS: every selector here is `.cg-scrollgal*`.
 * This file must never reference `.slider-items2` or anything Slick owns
 * (js/jquery.main.js `initSlick()`) — those stay entirely on the existing
 * Slick carousels (blocks/industry_content/gallery.php,
 * blocks/universal/gallery.php). blocks/work_type_content/gallery_with_video.php
 * was converted off Slick onto this component in #285 and is no longer one
 * of them.
 *
 * Progressive enhancement / no-JS: every pin + absolute-positioning rule is
 * scoped under `.cg-scrollgal.is-ready`, a class js/cg-scroll-gallery.js
 * adds only once it has successfully measured the pinned box's geometry.
 * Without that class (no JS, or the script's own try/catch failsafe fired),
 * the wrapper is `height: auto`, the pin is static, and slides stack in
 * normal document flow — a plain vertical list of all N images, nothing
 * hidden, nothing off-screen.
 *
 * NON-NEGOTIABLE RULE #1 — never `overflow: hidden` on `.cg-scrollgal-section`
 * or any ancestor of the pinned box. `hidden` makes that ancestor a scroll
 * container, which silently breaks CSS `position: sticky` for the pin (see
 * #283's `#wrapper`/`.main` fix in style.css). This section therefore uses
 * `overflow-x: clip` — same two-line Safari-<16-fallback pattern #283
 * established for `#wrapper`/`.main` (the `hidden` line first, `clip`
 * second so a browser that understands `clip` uses it, one that doesn't
 * drops that whole declaration and falls back to the `hidden` line —
 * suppresses the horizontal scrollbar with no worse a sticky outcome than a
 * browser that already can't do CSS sticky at all). This class is NOT
 * `.cg-film-section` (work-type-blocks.css:227-229) — that class is still
 * `overflow: hidden` and, as of #285, is owned only by video.php and
 * careers_content/video.php (gallery_with_video.php moved to this component's
 * own `.cg-scrollgal-section` and no longer uses it).
 *
 * NON-NEGOTIABLE RULE #2 — `.cg-scrollgal__stage` never gets a background
 * fill. Outgoing and incoming slides both travel leftward (outgoing
 * `0 -> -100vw`, incoming `+100vw -> 0`), so mid-transition there is a real
 * window where neither slide's own artwork covers the box. That gap is
 * intended to show the section's own band colour behind it — do not "fix"
 * it with a background-color/-image here.
 *
 * NON-NEGOTIABLE RULE #3 — new namespace, zero blast radius (see file
 * header above).
 *
 * Tokens only — no raw hex, no @layer, no !important.
 */

.cg-scrollgal-section {
	/*
	 * Same reasoning and same two-line fallback as #283's `#wrapper`/`.main`
	 * fix (style.css) — see NON-NEGOTIABLE RULE #1 above. Do not collapse to
	 * one line, and do not revert to `overflow: hidden`.
	 */
	overflow-x: hidden;
	overflow-x: clip;
	overflow-y: visible;
}

.cg-scrollgal {
	--cg-scrollgal-step: 72vh; /* was 60vh — +20% scroll distance per slide (2026-08-04 CD feedback: advance felt too quick) — see docblock */
	/* The parked view's rhythm, above AND below. Its ONLY consumer is the
	   stage's viewport cap, which subtracts it twice. Exactly `--section-pad`
	   on any window >= 640px tall; yields proportionally on shorter ones so
	   the cap stays positive at every reachable window (see the cap rule —
	   held flat at 96px it collapsed every stage to 0x0 below 284px of
	   viewport height).

	   The pin's sticky `top` does NOT read this. It did for one commit, and
	   that needed an `@supports` guard plus a plain fallback here, because a
	   custom property carrying an unsupported unit is valid at PARSE time and
	   only fails at the consumer — which for `top` means `auto`, i.e. sticky
	   with no offset, i.e. the pin stops pinning on Chrome <108 / Safari
	   <15.4 / Firefox <101. With `top` back on its own JS-measured px value
	   that hazard is gone and the guard with it: the cap is the only consumer,
	   the cap is `100svh`-based, so on those engines it is already lost to its
	   own unit and a fallback here would reinstate nothing. */
	--cg-scrollgal-pad: min( var( --section-pad ), 15svh );
	position: relative;
	width: 100%;
	/* Progressive-enhancement baseline: no runway until JS confirms it can
	   drive the pin (see file header). */
	height: auto;
}

/*
 * THE RUNWAY, AND WHY IT IS AN EMPTY `::after` RATHER THAN A HEIGHT (#452).
 *
 * The runway height is a pure function of the slide count (printed
 * server-side via `--cg-scrollgal-slides`) and the step token above — no
 * measurement required to express it in CSS. js/cg-scroll-gallery.js reads
 * the wrapper box back via getBoundingClientRect() to derive the step it
 * actually uses for its scroll-index math, so CSS and JS can never disagree
 * about where the per-slide thresholds fall.
 *
 * It is `N * step`, not `(N - 1) * step`: dividing the scrollable distance by
 * `N - 1` put the very last threshold exactly at the pin's release point, so
 * the final slide got zero pinned dwell time (and for a 2-slide gallery, no
 * dwell was visible at all — see #284's post-review fix). With `N * step`,
 * every slide including the last gets a real dwell window before the section
 * unpins.
 *
 * It used to be `height: calc( 100svh + N * step )` on the wrapper, which
 * worked only because the pin was forced to exactly `100svh` -- the two
 * `100svh` terms cancelled inside the JS's `(wrapperHeight - pinHeight) / N`.
 * That forced height is what put a full screen of dead band above and below
 * the artwork, which is what #452 asked to close. With the pin at its natural
 * height the runway has to be expressed separately, and it has to be IN FLOW:
 * padding-bottom on the wrapper does NOT work, because padding is not part of
 * a sticky element's containing block (verified in Chromium), so the pin gets
 * zero slack and scrolls away with the page.
 *
 * An empty in-flow `::after` lengthens the content box, so
 * `wrapperHeight - pinHeight` still equals exactly `N * step` and CSS and JS
 * cannot disagree about where the thresholds fall.
 */
.cg-scrollgal.is-ready::after {
	content: "";
	display: block;
	width: 100%;
	height: calc( var( --cg-scrollgal-slides ) * var( --cg-scrollgal-step ) );
}

.cg-scrollgal__pin {
	position: static;
	width: 100%;
}

/*
 * THE PIN PARKS CENTRED IN THE WINDOW, NEVER CLOSER TO AN EDGE THAN THE
 * BAND'S OWN RHYTHM.
 *
 * READ THIS BEFORE "SIMPLIFYING" IT TO A FIXED OFFSET. Someone already did,
 * on 2026-09-04, and it had to be undone the same day.
 *
 * History: it was `min-height: 100svh` + flex-centring on the pin (a literally
 * full-screen box); #452 removed the forced height and kept the centring by
 * moving it into the sticky offset, with js/cg-scroll-gallery.js publishing
 * `--cg-scrollgal-pin-top` as `( viewportHeight - pinHeight ) / 2`. Asked for
 * "96px top and bottom, consistent with every other band", the obvious reading
 * was to delete the measurement and park at a flat `--cg-scrollgal-pad`.
 *
 * THAT READING IS WRONG, and the reason is worth keeping: `position: sticky`
 * does not affect layout, so the sticky `top` has NO effect on the band's
 * height. The band is `--section-pad + pin + runway + --section-pad` before
 * and after — measured 96 + 394 + 4140 + 96 = 4726px on `/work/hansens/`'s
 * 3.99:1 mural. The 96px rhythm was already there and was never what the
 * offset controlled.
 *
 * What the reader sees during the dwell is the RUNWAY in band colour, and it
 * fills the screen whatever the offset is — that is inherent to sticky-pin
 * scrollytelling, because the runway is the scroll distance the slides advance
 * over. The offset's only job is deciding whether the leftover is split evenly
 * around the artwork or dumped underneath it. Dumped underneath reads as a
 * mistake: the owner reported exactly that against the mural above, 96px of
 * band over the artwork and 660px under it.
 *
 * So the measurement stays: `Math.max( 0, ( viewportH - pinHeight ) / 2 )`,
 * see `measure()`. The 96px the owner asked for comes from the CAP below, not
 * from a floor here — the cap is `100svh - 2 * pad - counter`, so WHERE THE
 * CAP BINDS the centred value works out to `pad` on its own (98 above /
 * 97.1 below at 1440x789, measured). Where the cap does NOT bind the box is
 * width-limited by the 1314px content frame and 96 is geometrically
 * impossible: a 3.99:1 mural would need 3550px of width to stand 890px tall
 * in a 1150px window, so it parks at 378/378 and no offset rule changes that.
 * The owner was shown those numbers and declined the only lever that moves
 * them (full-bleed artwork), because the images were produced at fixed sizes.
 *
 * DO NOT ADD A `Math.max( pad, ... )` FLOOR. One shipped here for a single
 * commit. It was dead on every modern engine — `getPropertyValue` on an
 * unregistered custom property returns the literal string `"min( 96px,
 * 15svh )"`, not a length, so `parseFloat` gave `NaN` — and repairing it
 * changed no rendered pixel, because the cap already puts the centred value
 * at `pad + 1.55`. Worse, on a pre-`svh` engine (where the plain fallback
 * parses AND the cap is gone) it was the one place it executed, and it pushed
 * tall pins 96px further into permanently-unreachable overflow: 1366x657 g0
 * went 81.8 -> 177.8, and three galleries that fit on `origin/main` stopped
 * fitting.
 *
 * The CSS fallback `0px` is reached only on a pre-`svh` engine or the
 * `fail()` path. It is NOT the no-JS path: the sticky rule is scoped to
 * `.is-ready`, which no-JS never adds and `fail()` removes, so without JS the
 * pin is `position: static` and every slide sits in normal flow.
 *
 * WHY `min()` AND NOT A BARE `--section-pad`, which is the whole of the
 * adversarial review of this change. The cap below subtracts this value TWICE
 * plus the counter row. Held at a flat 96px that is a fixed 260px of
 * subtrahend, and on a window shorter than that the `calc()` goes negative,
 * CSS clamps `max-block-size` to 0, `max-inline-size` (a multiple of the same
 * property) goes to 0 with it, and every slide inside — `position: absolute;
 * inset: 0` — has nothing to paint into. Reproduced at 900x280 on
 * `/work/hansens/`: all seven pinned galleries measured 0x0 while the runway
 * still scrolled, so the reader got ~1.4k px of empty band and a counter
 * advancing over nothing. The single-slide gallery on the same page (never
 * `is-ready`, so never capped) rendered fine at 810x456 — the JS failing
 * showed the artwork and the JS working hid it.
 *
 * Making the rhythm proportional past a point fixes that structurally rather
 * than with a floor: the subtrahend becomes `0.3 * svh + counter`, so the cap
 * stays positive at every REACHABLE window and the box is always reachable
 * (`pad + box + counter` works out to `0.85 * svh`). Not "never negative" —
 * `0.7 * svh - 68` still crosses zero, but at 97px of viewport height, which
 * no browser window reaches, and the gate's collapse assertion fires from
 * 160px down (`0.7h - 68 >= MIN_STAGE` of 48 gives `h >= 165.7`; measured,
 * 900x160 FAILs on all seven and 900x200 passes). The threshold moved from
 * 284px to 97px; it was not abolished.
 * 15% is chosen so the
 * value is EXACTLY 96px for any window 640px tall or more — i.e. every
 * ordinary laptop, desktop and portrait phone gets the requested rhythm
 * unmodified, and only genuinely short windows (landscape phones, a browser
 * with devtools docked) trade padding for picture. That is the correct
 * priority: the artwork is the subject, the padding yields first.
 *
 * Applies at every width, phones included (#448 removed the mobile variant).
 * The property itself is declared with `--cg-scrollgal-step` at the top of
 * this file, so the pin and the cap read one value and cannot diverge.
 */
.cg-scrollgal.is-ready .cg-scrollgal__pin {
	position: sticky;
	top: var( --cg-scrollgal-pin-top, 0px );
}

.cg-scrollgal__stage {
	position: relative;
	width: 100%;
	/* No-JS fallback: a plain vertical list, one 16:9 slide per row. */
	display: flex;
	flex-direction: column;
	gap: var( --box-pad );
	/* NON-NEGOTIABLE RULE #2 — no background here, see file header. */
}

.cg-scrollgal.is-ready .cg-scrollgal__stage {
	display: block;
	/*
	 * The aspect-ratio lives here, not on `.cg-scrollgal__pin`: the pin also
	 * holds the counter/status rows below the stage, so its own height must
	 * stay auto (stage height + counter height). Giving the STAGE a definite
	 * aspect-ratio-derived height (from its own 100% width) means it never
	 * depends on a percentage-of-auto parent height — which is exactly the
	 * bug this replaced: `height:100%` here against the pin's auto height
	 * resolved to zero, collapsing the whole pinned box to just the counter
	 * row's height with every slide's `position:absolute` content spilling
	 * out past it uncontained.
	 *
	 * `--cg-scrollgal-ratio` (#462, widened by #498) is set server-side by
	 * `cg_gallery_stage_ratio()`: the films' agreed ratio if the gallery has
	 * any film, otherwise the TALLEST still. Films keep absolute precedence
	 * because a letterboxed still shows the band colour while a letterboxed
	 * film shows the player's black — the defect #462 exists to remove.
	 *
	 * Whichever wins, the box is computed ONCE and never changes while the
	 * reader scrolls. A ratio that changed per slide would resize the pinned
	 * box mid-scroll, so the per-slide shape is done with `object-fit: contain`
	 * on `.cg-scrollgal__img` instead (see there) rather than by re-sizing this.
	 * (The stranding hazard is REAL and this is the warning about it: the pin's
	 * sticky offset `--cg-scrollgal-pin-top` is measured from `pinHeight`, so a
	 * box that resized mid-scroll would leave it stale. `measure()` re-runs on
	 * `resize`, which covers window and rotation changes, but nothing re-runs it
	 * on a late per-slide ratio — which is why the ratio is computed once here,
	 * server-side, and never varies per slide. Verified 0px of drift across a
	 * font load on all seven galleries on `/work/hansens/`.)
	 *
	 * A gallery whose slides all measure 16:9, or none of which can be
	 * measured at all, omits the property and lands on the fallback here. The
	 * fallback is what keeps the definite height above unconditional — that
	 * part is still non-negotiable.
	 */
	aspect-ratio: var( --cg-scrollgal-ratio, 16 / 9 );
	/*
	 * THE PINNED BOX MAY NEVER EXCEED THE VIEWPORT (#498, found by the
	 * adversarial review of PR #499; made ratio-preserving after the review
	 * of PR #500 caught the first attempt harming films).
	 *
	 * The pin parks at a fixed `top` (see above), so anything past the fold
	 * falls off the BOTTOM. Because the pin is sticky for its whole dwell,
	 * scrolling advances the slide rather than revealing the rest of it: the
	 * overflow is permanently UNREACHABLE, not merely below the fold. That is
	 * the same harm #498 exists to fix, moved from `object-fit` to the
	 * viewport edge.
	 *
	 * BOTH AXES, which is the whole trick. Capping only the height while the
	 * width stays `100%` makes the box wider than its ratio, and then
	 * `object-fit: contain` pillarboxes the still — or, worse, the iframe
	 * pillarboxes the FILM, breaking #462's exact box. PR #500 shipped exactly
	 * that and had to be reverted. Bounding the inline axis to
	 * `height * ratio` shrinks the box in proportion instead, so the ratio is
	 * preserved and nothing is ever letterboxed by the cap.
	 *
	 * `var()` substitutes textually, so `--cg-scrollgal-ratio: 2560 / 642`
	 * lands inside `calc()` as a division and needs no second property. The
	 * `16 / 9` fallback is the same one `aspect-ratio` above uses, so the two
	 * cannot disagree.
	 *
	 * `margin-inline: auto` keeps the narrowed box centred — the stated
	 * requirement for this whole change.
	 *
	 * WHAT IS SUBTRACTED, and why it grew on 2026-09-04. Three things have to
	 * fit on screen alongside the box while the pin is parked:
	 *
	 *   - `--cg-scrollgal-pad` ABOVE it — the pin's own sticky `top` (see the
	 *     pin rule above). This is not optional arithmetic: the box starts
	 *     that far down the window, so that distance is unavailable to it.
	 *   - `--cg-scrollgal-pad` BELOW it — the band's own bottom rhythm, so the
	 *     pinned view shows the same breathing room every other band has
	 *     rather than running the counter into the bottom edge. This is the
	 *     term the owner's uniformity ask added.
	 *
	 *     HONEST SCOPE, because this term only does anything WHERE THE CAP
	 *     BINDS. When the box is width-bound instead — a wide mural, or any
	 *     gallery on a narrow viewport — the cap is not the constraint and
	 *     this term buys nothing. The parked view is always SYMMETRIC (the pin
	 *     is centred; see the pin rule above), but the gap is only 96 where
	 *     the cap binds. Measured mid-dwell on `/work/hansens/`, above/below:
	 *     1440x900 -> 98/97.1, 98/97.1, 258/258.1, 123/122.3, 258/258.1,
	 *     98/97.1, 179/179.1; 390x844 -> 289-347 on both sides, nothing
	 *     capped. So do not read the word "uniformity" here as "96 at every
	 *     viewport" — it is "96 wherever the artwork is tall enough to earn
	 *     it, and symmetric everywhere else".
	 *   - the counter row beneath the stage. MEASURED, not guessed: 44px of
	 *     `--box-pad` margin plus a 20.9px line box = 64.9px at 1440x900, so
	 *     `--box-pad + 1.5rem` (68px) covers it with 3px to spare. It was
	 *     `--box-pad + 3rem` (92px) — a value that predates this change and
	 *     over-subtracted by 27px, which is why the pinned view measured 96
	 *     above and 123 below instead of the symmetry this rule claims. That
	 *     27px was deliberate edge-slack back when there was no bottom pad
	 *     term at all; the bottom pad term replaces it.
	 *
	 * `svh` and not `vh` so a mobile URL bar cannot uncap it. Note the cap is
	 * `svh`-only, as it was before this change: an engine without `svh`
	 * (Chrome <108, Safari <15.4, Firefox <101) drops both declarations and
	 * loses the cap entirely, which is #499's unreachable overflow returning.
	 * The PIN is not in that tail — its `top` is a JS-measured px value with a
	 * `0px` fallback, so it keeps pinning on those engines and the box is
	 * merely uncapped, not lost. Do not "help" it by flooring that offset at
	 * `--cg-scrollgal-pad`: here, where the cap is gone and pins are tall, that
	 * floor pushes the box a further 96px into unreachable overflow — measured
	 * 1366x657 g0 at 81.8 -> 177.8px, with three galleries that fit no longer
	 * fitting. It shipped for one commit and adversarial review removed it.
	 * Left as-is deliberately — `svh` has been baseline since 2022 and adding
	 * an `@supports` fallback for a three-year tail costs more than it saves —
	 * but it is a known tail, not an oversight.
	 *
	 * THE COST, stated plainly because it is visible, and MEASURED rather than
	 * derived — an earlier revision of this comment quoted 1143px against
	 * 1314px at 1440x900 and both numbers were wrong (`1314` is the
	 * `--content-w` token, but `--pad-x` resolves to 84px at that viewport, so
	 * the rendered frame is 1272px). On a short window the cap binds where it
	 * did not before, and because the cap is ratio-preserving the box gets
	 * NARROWER as well as shorter. Measured on `/work/hansens/` g0, a 16:9
	 * film, against `origin/main`'s cap in the same browser:
	 *
	 *     1440x900   1272 -> 1138 wide   (-11%)
	 *     1440x789   1239 ->  940 wide   (-24%)
	 *     1512x830   1312 -> 1013 wide   (-23%)
	 *     1366x657   1004 ->  706 wide   (-30%)
	 *
	 * That is the price of "fully on screen with 96px above and below" on a
	 * laptop — the two cannot both hold at full width. Dropping the `2 *`
	 * below hands most of it back and costs the bottom breathing room; it is a
	 * one-character edit if that trade is ever preferred. It was put to the
	 * owner with these numbers rather than decided here.
	 *
	 * Kept as one local property rather than written out twice: the block and
	 * inline terms must never drift apart, or the cap stops preserving the
	 * ratio and starts letterboxing — the exact defect PR #500 shipped.
	 *
	 * ponytail: fixed counter subtrahend, not a measured one. If the counter
	 * ever grows, have js/cg-scroll-gallery.js's `measure()` publish its
	 * height as a custom property and subtract that instead.
	 */
	--cg-scrollgal-max-h: calc( 100svh - ( 2 * var( --cg-scrollgal-pad ) ) - var( --box-pad ) - 1.5rem );

	max-block-size: var( --cg-scrollgal-max-h );
	max-inline-size: calc( var( --cg-scrollgal-max-h ) * ( var( --cg-scrollgal-ratio, 16 / 9 ) ) );
	margin-inline: auto;
}

/*
 * The un-pinned state: the no-JS stacked list, and every SINGLE-slide gallery
 * (js/cg-scroll-gallery.js never adds `is-ready` when there is one slide). Here
 * each slide is a plain in-flow box and owns its own ratio, so `--cg-film-ratio`
 * (#462) applies per slide with no pin to keep in step — this is the path that
 * fixes 17 of the 19 affected work pages.
 *
 * As of #498 IMAGE slides carry that property too, from their attachment's own
 * dimensions, so a lone 3.99:1 mural is a 3.99:1 box here rather than a 16:9
 * one. Only slides whose ratio genuinely can't be measured keep 16:9.
 */
.cg-scrollgal__slide {
	position: static;
	display: block;
	width: 100%;
	aspect-ratio: var( --cg-film-ratio, 16 / 9 );
}

.cg-scrollgal.is-ready .cg-scrollgal__slide {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	aspect-ratio: auto;
	transition: transform var( --fade-out ) var( --fade-ease );
}

.cg-scrollgal.is-ready .cg-scrollgal__slide.is-active {
	transform: translateX( 0 );
	z-index: 2;
}

.cg-scrollgal.is-ready .cg-scrollgal__slide.is-before {
	transform: translateX( -100vw );
	z-index: 1;
}

.cg-scrollgal.is-ready .cg-scrollgal__slide.is-after {
	transform: translateX( 100vw );
	z-index: 1;
}

/*
 * `contain`, NOT `cover` — this one word is the whole of #498.
 *
 * The stage is the TALLEST slide's ratio (blocks/cg/scroll-gallery.php), so
 * the box guarantees only that nothing overflows; `contain` is what makes the
 * gallery per-IMAGE rather than per-gallery, painting each still at its own
 * aspect ratio, centred in the box, with the band colour showing around it
 * where it is shorter than the tallest slide.
 *
 * It was `cover`, which filled the box by cropping — and since the box was
 * 16:9 unless a film said otherwise, every non-16:9 still lost the overhang.
 * Measured across all 42 work posts on Dev: 97 of the 116 image-bearing
 * galleries hold at least one non-16:9 still, 387 slides, median 7.5% of the
 * artwork cut off, 55.5% at the worst (the 3.99:1 murals on hansens r5/r7 —
 * more than half of each mural was simply not on the page).
 *
 * This restores what the 1.0 site does. There a still has no box at all —
 * `.gallery-section .item img { width: 100% }`, intrinsic height — and
 * measures at its native ratio to three decimals at both 1440 and 390.
 *
 * ONE DELIBERATE DEVIATION FROM 1.0: it top-aligns a short slide
 * (`.slider-items2 .slick-track { align-items: flex-start }`); `contain`
 * centres. Centred is the owner's ask, and it only shows in the 11 galleries
 * whose images disagree with each other — 34px on hansens r9.
 *
 * NOT a background (rule #2 above still holds): the space around a contained
 * image is the section's own band colour, the same colour the slide
 * transition already shows through.
 */
.cg-scrollgal__img {
	display: block;
	width: 100%;
	height: 100%;
	object-fit: contain;
}

/*
 * Video slides (#285) — the play-pill markup reuses the sitewide
 * `.cg-film__video`/`.bg-video[data-video]`/`.btn-play` mechanism verbatim
 * (js/jquery.main.js `initVideo()`/`BgVideo()`, css/components/
 * work-type-blocks.css:273-280), so the player-fill sizing itself
 * (`position:absolute; inset:0; width/height:100%`) already comes free from
 * that file's `.cg-film__video`/`.bg-video`/`.fluid-video` rule — nothing to
 * duplicate here.
 *
 * What that shared rule needs and doesn't itself provide is a POSITIONED
 * ancestor to fill, plus (post-review fix) a visible placeholder background
 * for whenever the player itself hasn't painted anything yet: no-JS (no
 * iframe is ever injected without js/jquery.main.js's `BgVideo()`) or the
 * player still loading. Pre-#285, `.cg-film { background: var(--cg-ink) }`
 * (work-type-blocks.css) supplied that for the old Slick markup; this
 * component's markup doesn't carry the `.cg-film` class, so without this the
 * slide would be a fully invisible void instead of a dark box — this rule
 * restores that same solid placeholder.
 *
 * In the `.is-ready` (desktop, pinned) state `.cg-scrollgal__slide` is
 * already `position: absolute` (above), so the player fills the slide
 * correctly with zero extra positioning help — this rule's `position` half
 * is a no-op there (its 0,1,0 loses outright to the 0,3,0 `.is-ready
 * .cg-scrollgal__slide` rule above it, so `position: absolute` is
 * untouched; `background` isn't contested at all, since nothing else sets
 * it). But in the two `position: static` states — the no-JS stacked-list
 * fallback (base `.cg-scrollgal__slide` rule above, also 0,1,0, earlier in
 * source, so this rule being later wins) — the only positioned ancestor
 * left would be `.cg-scrollgal__stage` itself, pinning every video slide's
 * play-pill to the top of the whole list instead of its own row.
 *
 * This rule selects by TYPE (`.cg-scrollgal__slide--video`) rather than by
 * mirroring this component's state-class list, so it stays correct no
 * matter what state classes a later issue adds. It used to have a partner
 * inside the `<=767px` snap-strip query doing the same job for that
 * variant; #448 deleted the strip, so this rule now covers every static
 * case on its own — see "WHY THIS FILE HAS NO MOBILE QUERY" at the foot of
 * the file.
 */
.cg-scrollgal__slide--video {
	position: relative;
	background: var( --cg-ink );
}

/* The counter is always "N / M" (written by js/cg-scroll-gallery.js, never
   editorial text), so it takes the numeral face unconditionally — no
   `cg-num` companion needed the way the ACF-fed numerals have one.
   Tungsten Narrow Semibold per the CD's numerals note (#323, following
   #304). letter-spacing stays positive here: it was tracking *open*, not
   tight, so the condensed face doesn't close up. */
.cg-scrollgal__counter {
	margin: var( --box-pad ) 0 0;
	font-family: var( --font-condensed );
	font-weight: 600;
	font-size: 12px;
	letter-spacing: 0.04em;
	color: inherit;
	text-align: right;
}

.cg-scrollgal__counter-cur {
	font-variant-numeric: tabular-nums;
}

/*
 * `.cg-scrollgal__status` also carries the theme's `.screen-reader-text`
 * class (markup contract — do not change, issue #285 depends on this exact
 * class combination). That class is `display: none` (theme.css:31), which
 * axe-core and real assistive tech drop from the accessibility tree
 * entirely — see css/base.css's `.visually-hidden` docblock. This overrides
 * just this component's status region with that same clip-based technique
 * so the aria-live announcement can actually reach a screen reader while
 * staying visually hidden.
 *
 * The compound selector (both classes, not `.cg-scrollgal__status` alone)
 * is required, not stylistic: this `css/components/*.css` layer loads
 * BEFORE theme.css in the enqueue order (inc/scripts.php), so a
 * same-specificity single-class rule here would still lose the cascade to
 * theme.css's later `.screen-reader-text{display:none}` — the project's
 * "no `!important`" rule means the only way to win is a strictly higher
 * specificity than that lone class selector.
 */
.cg-scrollgal__status.screen-reader-text {
	display:     block;
	position:    absolute;
	width:       1px;
	height:      1px;
	margin:      -1px;
	padding:     0;
	overflow:    hidden;
	clip:        rect(0, 0, 0, 0);
	clip-path:   inset(50%);
	white-space: nowrap;
	border:      0;
}

/* prefers-reduced-motion: cross-fade on opacity, no horizontal travel — the
   box still pins and still advances one slide per threshold.

   No longer scoped to min-width:768px (#448). That scope existed only so this
   block could not fight the mobile snap-strip, which had no transform to
   reduce in the first place. With that variant gone, phones run the same
   horizontal travel as desktops, so a reader on a phone who has asked for
   reduced motion must get the same cross-fade instead. */
@media ( prefers-reduced-motion: reduce ) {
	/*
	 * THE CROSS-FADE DURATION IS COMPONENT-LOCAL, AND 500ms IS TOO SLOW.
	 *
	 * The sitewide `--fade-out` is 500ms, tuned for hover and reveal, and a
	 * 500ms cross-fade cannot keep up with a gallery whose step is 72vh: the
	 * reader can reach the next threshold before the current fade has
	 * finished, so fades stack and the artwork never reaches full opacity.
	 * Measured on the 8-slide gallery of `/work/friday-health-plans/` at
	 * 390x844, reduced motion, peak opacity per slide per animation frame:
	 * at 1600px/s slides peaked at 89%, at 2400px/s at 47-50%, at 3200px/s
	 * at 26-28% — a washed-out, band-coloured 16:9 box. ~1300px/s is the
	 * onset, which is inside ordinary iOS fling momentum. With 150ms: 100%
	 * at every speed up to 12000px/s.
	 *
	 * This is the same failure #452's fourth adversarial pass found and the
	 * same 150ms that fixed it. It was lost when these files were reverted
	 * to restore the sticky pin, re-found by adversarial review of the #456
	 * follow-up, and is restored here as a scoped override rather than a
	 * second token so that BOTH rules below stay coupled to one value — the
	 * fade duration and the outgoing slide's hold delay must never drift
	 * apart, or the hold either releases early (wash) or late (a visible
	 * freeze).
	 */
	.cg-scrollgal {
		--fade-out: 150ms;
	}

	.cg-scrollgal.is-ready .cg-scrollgal__slide {
		transition: opacity var( --fade-out ) var( --fade-ease );
	}

	.cg-scrollgal.is-ready .cg-scrollgal__slide.is-active {
		transform: none;
		opacity: 1;
	}

	/*
	 * THE OUTGOING SLIDE HOLDS UNTIL THE INCOMING ONE HAS COVERED IT, and
	 * that delay is the whole rule: `0s` duration, `--fade-out` delay, so the
	 * drop to 0 is instantaneous but does not start until the fade above has
	 * finished.
	 *
	 * A symmetrical cross-fade cannot keep this stage opaque. Layers
	 * composite by `1 - PROD( 1 - a )`, so a 50/50 crossing is 75% -- a
	 * quarter of the band colour showing through the artwork, on every slide
	 * change, because rule #2 forbids a background on the stage. Measured
	 * per animation frame on the 8-slide gallery of
	 * `/work/friday-health-plans/` at 800px/s: minimum coverage 75.0%,
	 * identical at 1440x900 and 390x844. With the delay: 100%.
	 *
	 * BOTH halves are needed. The delay alone, against the sitewide 500ms
	 * `--fade-out`, holds 100% only up to ~1300px/s and falls to 68.8% at
	 * 2500px/s; it is the 150ms override above that makes the 100% hold at
	 * every speed. The delay is still worth having on its own -- it beats no
	 * delay at every speed measured (1800px/s: 82.1% vs 56.9%; 4000px/s:
	 * 43.3% vs 2.2%) -- but do not remove the duration override and expect
	 * this comment's number to survive.
	 *
	 * This is #452-era work that was measured, landed, and then lost when
	 * these two files were reverted to restore the sticky pin. #448 removed
	 * this block's `min-width: 768px` scope, so it now covers phones too;
	 * the defect is not phone-specific (it measures the same at both
	 * viewports at 800px/s, and phones lead by only 5-6 points at 1600px/s),
	 * but phones are where fling speeds get high enough to matter.
	 */
	.cg-scrollgal.is-ready .cg-scrollgal__slide.is-before,
	.cg-scrollgal.is-ready .cg-scrollgal__slide.is-after {
		transform: none;
		opacity: 0;
		transition: opacity 0s var( --fade-out ) linear;
	}
}

/*
 * WHY THIS FILE HAS NO MOBILE QUERY (#448).
 *
 * There is no rule under this comment. It is here because its ABSENCE is the
 * change, and a reader looking for the phone variant should find out where it
 * went rather than conclude it was lost.
 *
 * Until #448 a `@media ( max-width: 767px )` block replaced everything above
 * with a native horizontal `scroll-snap` strip: pin off, stage a flex row,
 * `overflow-x: auto`, slides `flex: 0 0 100%`, no transform. On a phone that
 * meant the slides only advanced if the reader happened to swipe sideways
 * INSIDE the strip -- scrolling the page vertically showed slide 1 and moved
 * on, so on a 4-6 slide case study three to five slides were invisible. The
 * owner's ask was explicit that the fix was not buttons: make the same
 * scroll-driven advance the desktop has work on mobile.
 *
 * So the pinned, scroll-driven variant now applies at EVERY width and this
 * file has one media query left, the reduced-motion one. Three consequences
 * worth knowing before adding a width query back:
 *
 * - The step is `72vh`, a viewport-relative token, so it is already
 *   proportional on a phone rather than a desktop distance imposed on one.
 * - Video slides are `position: relative` at every width now; the `<=767px`
 *   `position: relative` re-assertion that used to serve the snap-strip is
 *   gone with it, and there is no hand-mirrored mobile copy to keep in step.
 * - `js/cg-scroll-gallery.js`'s mobile stage-scroll counter listener went
 *   with the strip; the counter is driven by the same index math at every
 *   width.
 */


/* ==========================================================================
 * MANUAL MODE — the phone path (#487)
 *
 * Below 768px the pinned, scroll-driven gallery produced two defects the
 * owner caught on an iPhone, neither of which is tunable:
 *
 *  1. A VISIBLE SHAKE. The pin's sticky `top` is measured in JS from
 *     `documentElement.clientHeight`, which CHANGES as mobile Safari's URL bar
 *     collapses — during the very scroll that drives the gallery. `resize`
 *     fires, `measure()` re-runs, the offset moves, the parked box jumps.
 *     js/cg-scroll-gallery.js documented this as the lesser evil back when the
 *     alternative was mis-centring; with the pin gone there is no third term
 *     to trade off, because there is nothing left to centre.
 *
 *  2. A TINY IMAGE IN A SEA OF WHITE. The pin reserves a viewport of height
 *     and the cap fits the stage inside it. At 390px wide the 3.99:1 mural is
 *     ~98px tall, marooned mid-screen. Correct behaviour by the desktop rule,
 *     unusable on a phone.
 *
 * So on phones the pin, the runway and the measured centring are all switched
 * OFF, and the index is driven by the two buttons in blocks/cg/scroll-gallery.php
 * instead. The slides, their crossfade, the counter, the aria-live status and
 * the video hooks are all untouched and shared with the desktop path.
 *
 * STATE CLASS, NOT A BARE MEDIA QUERY. `is-manual` is added by
 * js/cg-scroll-gallery.js from the SAME `matchMedia` breakpoint it uses for
 * autoplay, so CSS and JS cannot disagree about where the path switches — the
 * failure mode that produced the dead snap-strip #448 removed (CSS said one
 * thing below 767px, JS another). It also keeps these rules at a specificity
 * that beats the `.is-ready` block they override without `!important`: every
 * selector here is `.cg-scrollgal.is-ready.is-manual …` (0,3,0 or 0,4,0)
 * against the 0,3,0 it is undoing, winning on source order.
 *
 * JS-COUPLED: `is-manual`, `[data-cg-scrollgal-nav]`,
 * `[data-cg-scrollgal-prev]`, `[data-cg-scrollgal-next]`.
 * ========================================================================== */

/*
 * Runway off. `content: none` removes the ::after box entirely rather than
 * zeroing its height — a 0px in-flow box would still be a box, and would still
 * collapse-guard the wrapper's bottom margin.
 */
.cg-scrollgal.is-ready.is-manual::after {
	content: none;
}

/*
 * Pin off. Back to static, so the wrapper is exactly as tall as its content
 * and nothing sticks, offsets, or re-measures. `top` is reset too: an unused
 * `top` on a static box is inert, but leaving the custom property wired here
 * invites someone to "restore" the sticky rule without noticing the JS no
 * longer publishes a value for it.
 */
.cg-scrollgal.is-ready.is-manual .cg-scrollgal__pin {
	position: static;
	top: auto;
}

/*
 * Cap off — this is what fixes the marooned mural. The desktop cap exists to
 * fit the stage inside one viewport HEIGHT; on a phone the binding constraint
 * is WIDTH, and honouring the height cap is what shrinks a 3.99:1 image to a
 * ~98px strip. Width goes to 100% and `aspect-ratio` (still inherited from the
 * `.is-ready` block above) sets the height from it.
 *
 * The artwork's own ratio is preserved exactly — this scales the box, it does
 * not crop or re-frame the image. Full-bleed cropping was considered and
 * rejected by the owner on 2026-09-04 ("it would change the pixel density of
 * what we're doing"); this is not that.
 */
.cg-scrollgal.is-ready.is-manual .cg-scrollgal__stage {
	max-block-size: none;
	max-inline-size: none;

	/*
	 * ONE SLIDE VISIBLE AT A TIME (owner, 2026-09-04, from a phone screenshot).
	 *
	 * The slides are absolutely positioned and thrown aside by `translateX`;
	 * nothing was clipping them. On the pinned desktop path the throw is
	 * `100vw` and the stage is near viewport-width, so the neighbour lands off
	 * the screen edge and the missing clip never shows. Here the stage is
	 * container-width inside the band's gutters, so a `100%` throw parks the
	 * outgoing slide exactly against the stage edge — visible as a sliver in
	 * the gutter, which is what the owner saw.
	 *
	 * CORRECTION (adversarial review): an earlier version of this comment said
	 * a `100vw` throw "would leave a visible sliver". That is backwards --
	 * review measured the `100vw` counterfactual at 0px visible, and the
	 * `100%` throw this file ships at a 24px strip of BOTH neighbours showing
	 * in the gutters, at full opacity. `100%` is the option that created the
	 * defect. It is kept only because `overflow: hidden` is the stronger fix:
	 * 100vw hides the neighbour by the gutter happening to be wider than the
	 * overhang, whereas clipping makes "one slide at a time" a property of the
	 * stage. The clip also fixes what the throw distance never could -- review
	 * found `elementFromPoint()` near the stage edge returning the OFF-SLIDE's
	 * Vimeo iframe, so a thumb was hitting a hidden video player.
	 */
	overflow: hidden;
}

/*
 * Slides travel by 100% of the stage, not 100vw. On the pinned path the stage
 * is viewport-width so the two are near enough the same; here the stage is
 * container-width inside the band's gutters, and a 100vw throw would leave a
 * visible sliver of the outgoing slide parked in the gutter.
 */
.cg-scrollgal.is-ready.is-manual .cg-scrollgal__slide.is-before {
	transform: translateX( -100% );
}

.cg-scrollgal.is-ready.is-manual .cg-scrollgal__slide.is-after {
	transform: translateX( 100% );
}

/*
 * THE CONTROLS ROW (owner spacing pass, 2026-09-04).
 *
 * Counter and arrows share ONE row under the stage, rather than stacking as
 * two. Stacked, the run from the buttons to the next section measured 160.9px
 * — 44px to the counter, the counter itself, then 96px of band padding — and
 * the owner asked for that closed. One row makes the whole control block 48px
 * tall and puts the band's (now 48px, see tokens.css) mobile padding directly
 * under the buttons.
 *
 * Grid, not flex, because the stage has to span the full width above the row
 * while the counter and nav split it below. `.cg-scrollgal__status` is
 * `display: none` (theme.css) so it never claims a cell.
 */
.cg-scrollgal.is-ready.is-manual .cg-scrollgal__pin {
	display: grid;
	grid-template-columns: 1fr auto;
	align-items: center;
	/* 20px below the gallery image — owner's number. */
	row-gap: 20px;
}

.cg-scrollgal.is-ready.is-manual .cg-scrollgal__stage {
	grid-column: 1 / -1;
}

.cg-scrollgal.is-ready.is-manual .cg-scrollgal__counter {
	/*
	 * Row 2 EXPLICITLY, not by auto-placement. The counter follows the nav in
	 * source order, so with only a column named, grid places the nav at row 2
	 * column 2 and then cannot move backwards to fill column 1 — the counter
	 * lands on a row 3 of its own and the control block is two rows tall again
	 * (measured: 88.9px to the next section instead of 48px).
	 */
	grid-row: 2;
	grid-column: 1;
	margin: 0;
	text-align: left;
}

.cg-scrollgal.is-ready.is-manual .cg-scrollgal__nav {
	grid-row: 2;
	grid-column: 2;
	display: flex;
	/* 40px between the two buttons — owner's number. */
	gap: 40px;
	justify-content: flex-end;
	margin: 0;
}

.cg-scrollgal.is-ready.is-manual .cg-scrollgal__navbtn {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	/*
	 * 48px, not the icon's size: this is the WCAG 2.5.8 minimum target and the
	 * thing is thumb-operated by definition. The SVG inside is 24px.
	 */
	inline-size: 48px;
	block-size: 48px;
	padding: 0;
	border: 1px solid currentColor;
	border-radius: 0;
	background: transparent;
	color: inherit;
	cursor: pointer;
}

.cg-scrollgal.is-ready.is-manual .cg-scrollgal__navbtn[disabled] {
	opacity: 0.35;
	cursor: default;
}

.cg-scrollgal.is-ready.is-manual .cg-scrollgal__navicon {
	inline-size: 24px;
	block-size: 24px;
}

/*
 * FOCUS RING for the manual buttons.
 *
 * style.css:1401-1406 strips outlines globally; adversarial review confirmed
 * these buttons shipped with no visible focus indicator. They are the only way
 * to reach slides 2..N on a phone, so a keyboard user losing track of focus
 * loses the gallery. `:focus-visible`, so a thumb tap does not draw it.
 */
.cg-scrollgal.is-ready.is-manual .cg-scrollgal__navbtn:focus-visible {
	outline: 2px solid currentColor;
	outline-offset: 3px;
}

/*
 * REDUCED MOTION, MANUAL MODE — must come after the `.is-manual` throw rules
 * above, and after the reduced-motion block further up the file.
 *
 * The bug this fixes, found by adversarial review: the manual throws are
 * `.cg-scrollgal.is-ready.is-manual .cg-scrollgal__slide.is-before` (0,4,0),
 * and the reduced-motion block's `transform: none` is (0,3,0). The manual
 * rules therefore won on BOTH specificity and source order, silently undoing
 * the one thing that block exists to do — measured at 0% stage coverage for
 * 150ms on every button press, i.e. the outgoing slide teleports away and the
 * reader looks at an empty box. Controls: 100% at 1440 patched, 100% on main
 * at 390, 0% only in manual mode. That is worse than the 75% the block was
 * originally written against.
 *
 * Re-asserting `transform: none` here at (0,4,0) inside the query restores the
 * crossfade-only behaviour on the button path.
 */
@media ( prefers-reduced-motion: reduce ) {

	.cg-scrollgal.is-ready.is-manual .cg-scrollgal__slide.is-before,
	.cg-scrollgal.is-ready.is-manual .cg-scrollgal__slide.is-after {
		transform: none;
	}
}
