/**
 * Eruda Toolkit - Scroll Story
 *
 * A column of text items on the left, a pinned panel on the right that
 * follows whichever item you are reading.
 *
 * Two rules govern everything here, both the same rule really: if the script
 * never runs, the section must still be complete and readable.
 *
 *   - Text is its lit colour by default. The dim colour is only applied under
 *     [data-estry-ready], which only the script sets. A stylesheet that dimmed
 *     text on its own would leave #ddd text on white for anyone whose
 *     JavaScript failed.
 *   - Every media slide is opaque by default and they simply stack, so the
 *     last one wins. The script gives slide 0 the top of the stack the moment
 *     it is ready, and moves the stack from there.
 *
 * The highlight is scrubbed by scroll position, not played on a timer. The
 * script decides how many characters are lit from how far the item has
 * travelled up the screen, so scrolling back up puts them out again.
 */

.estry {
	display: grid;
	grid-template-columns: var(--estry-text, 1fr) var(--estry-media, 1fr);
	gap: var(--estry-gap, 48px);
	align-items: start;
}

/* ------------------------------------------------------------- the text --- */

.estry__items {
	display: flex;
	flex-direction: column;
	gap: var(--estry-item-gap, 45vh);
	/* Room above and below so the first and last items can travel the whole
	   reading band that the sweep is measured against. */
	padding: 30vh 0 40vh;
}

/* ---------------------------------------------------------- the eyebrow --- */

/*
 * A pill: hairline border, a filled dot, then short uppercase text. It is
 * inline-flex so the pill is exactly as wide as its contents.
 */
.estry__eyebrow {
	display: inline-flex;
	align-items: center;
	gap: var(--estry-eb-gap, 10px);
	margin-bottom: var(--estry-eb-space, 24px);
	padding: var(--estry-eb-pad-y, 10px) var(--estry-eb-pad-x, 20px);
	border: var(--estry-eb-bw, 1px) solid var(--estry-eb-border, #d5e3d9);
	border-radius: var(--estry-eb-radius, 999px);
	background: var(--estry-eb-bg, transparent);
	color: var(--estry-eb-colour, #2f7d4f);
	font-size: 0.8125rem;
	font-weight: 700;
	letter-spacing: 0.08em;
	line-height: 1;
	text-transform: uppercase;
}

.estry__dot {
	flex: 0 0 auto;
	width: var(--estry-eb-dot-size, 10px);
	height: var(--estry-eb-dot-size, 10px);
	border-radius: 50%;
	background: var(--estry-eb-dot, currentColor);
}

/* The old numeric label, kept for stories that were built with it. */
.estry__num {
	display: block;
	margin-bottom: 1.2em;
	font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
	font-size: 0.75rem;
	letter-spacing: 0.08em;
	color: var(--estry-num, #9aa4a4);
}

.estry__title {
	margin: 0 0 var(--estry-title-space, 0.6em);
	/* Heading and description each own a full set of sweep colours. The
	   keyframes read these three names, and var() inside a keyframe resolves
	   against the element being animated, so one animation serves both. */
	--estry-dim: var(--estry-h-dim, #dddddd);
	--estry-flash: var(--estry-h-flash, #abff04);
	--estry-lit: var(--estry-h-lit, #052424);
	color: var(--estry-h-lit, #052424);
}

.estry__body {
	margin: 0;
	--estry-dim: var(--estry-b-dim, #dddddd);
	--estry-flash: var(--estry-b-flash, #abff04);
	--estry-lit: var(--estry-b-lit, #052424);
	color: var(--estry-b-lit, #052424);
}

/* ------------------------------------------------------------ the sweep --- */

.estry-c {
	display: inline;
	color: inherit;
}

/*
 * Lit is the default; dim needs the script. The transition is what reverses
 * the sweep: when the script takes `is-on` off a character -- which is what
 * scrolling back up does -- it falls back to the dim colour over this time,
 * with no flash on the way out.
 */
[data-estry-ready] .estry-c {
	color: var(--estry-dim, #dddddd);
	transition: color var(--estry-unsweep, 400ms) ease;
}

[data-estry-ready] .estry-c.is-on {
	animation: estry-sweep var(--estry-sweep, 500ms) ease forwards;
}

/*
 * The three-stop journey, which is what makes this read as expensive rather
 * than as a plain fade: a character passes through the flash colour before it
 * settles. Because the sweep is scrubbed, the stagger between one character
 * and the next is your scroll speed rather than a fixed delay.
 */
@keyframes estry-sweep {
	0% {
		color: var(--estry-dim, #dddddd);
	}

	30% {
		color: var(--estry-flash, #abff04);
	}

	100% {
		color: var(--estry-lit, #052424);
	}
}

/* ------------------------------------------------------------ the panel --- */

/*
 * The panel sits in the middle of the screen, always. Its sticky offset is
 * derived from its own height rather than set directly, so changing the panel
 * height, the gap or the spacing between items cannot push it off centre.
 * --estry-top is a nudge on top of that, for a tall sticky site header.
 */
.estry__media {
	position: sticky;
	top: calc((100vh - var(--estry-height, 88vh)) / 2 + var(--estry-top, 0px));
	height: var(--estry-height, 88vh);
}

.estry__frame {
	position: relative;
	width: 100%;
	height: 100%;
	overflow: hidden;
	border-radius: var(--estry-radius, 16px);
	background: var(--estry-frame-bg, transparent);
}

/*
 * A notched panel keeps its corner radius. clip-path and border-radius clip
 * the same box, so the radius cannot come from CSS here -- it is built into
 * the generated path instead, which rounds all four corners and keeps the
 * notch band clear of them. Zeroing it here stops the two fighting.
 */
.estry__frame[data-estry-notch] {
	border-radius: 0;
}

/*
 * A plain CSS border can only be used when nothing is clipping the frame.
 * Under a notch it would be cut away with everything else outside the path,
 * so the notched panel is outlined by a stroked copy of the same path
 * instead -- see .estry__outline.
 */
.estry__frame:not([data-estry-notch]) {
	border: var(--estry-media-bw, 0px) solid var(--estry-media-border, #052424);
}

/*
 * The notch cuts into the panel's left edge: flush top and bottom, stepping
 * inwards across a band with rounded corners and a diagonal run, travelling
 * down as the section scrolls.
 *
 * The path itself is generated by the script, because a curve in a clip path
 * is in user units and has to be rebuilt whenever the panel resizes. These two
 * properties are what it reads.
 *
 * The corner radius is built into the same path, so a notched panel is rounded
 * like any other.
 */
/*
 * The notch's three numbers are declared on .estry, not on the frame.
 *
 * Every control writes to `{{WRAPPER}} .estry`, and a custom property declared
 * on the frame itself beats one inherited from an ancestor no matter how
 * specific the ancestor's selector is. Declaring them here once killed the
 * notch depth, length and travel controls outright: they wrote a value the
 * frame then shadowed, and the panel never changed.
 *
 *   --estry-notch         how far the notch cuts in
 *   --estry-band-size     the straight length of the inset section
 *   --estry-notch-travel  how much of the panel's height the band crosses,
 *                         the rest being split evenly above and below it
 */
.estry {
	--estry-notch: 30px;
	--estry-band-size: 280px;
	--estry-notch-travel: 40;
}

/* The stroked outline that stands in for a border on a notched panel. It is
   drawn at twice the asked-for width and clipped by the same path, so exactly
   the asked-for width survives on the inside of the edge. */
.estry__outline {
	position: absolute;
	inset: 0;
	width: 100%;
	height: 100%;
	pointer-events: none;
	z-index: 30;
}

/* ------------------------------------------------------------ the slides --- */

/*
 * Every slide is opaque and fills the frame. They stack, and the script
 * raises the incoming one above the rest, so a change is always something
 * arriving over a solid picture. Nothing ever fades out to reveal the frame
 * behind it, which is what used to flash the panel's background mid-change.
 */
/*
 * Three layers, because three things move at once and a single element cannot
 * hold two transforms or two clip paths:
 *
 *   .estry__slide  the entrance edge -- clip path, offset, opacity
 *   .estry__inner  the entrance parallax -- transform and blur
 *   the media      the drift -- a keyframe animation, which would otherwise
 *                  win outright over any transform transitioned on the same
 *                  element and swallow the parallax whole.
 */
.estry__slide {
	position: absolute;
	inset: 0;
	overflow: hidden;
	z-index: 0;
	/*
	 * An explicit full-frame inset, not `none`. A clip path only animates
	 * between two paths of the same kind, so an entrance that wiped from
	 * inset() to none would simply snap at the end of its duration.
	 */
	clip-path: inset(0 0 0 0);
	transform: translateY(0);
	opacity: 1;
}

.estry__inner {
	position: absolute;
	inset: 0;
	transform: scale(1) translateY(0);
	filter: blur(0);
}

/*
 * Cover, always, and not negotiable.
 *
 * The panel is a fixed height and the media is whatever shape and whatever
 * size someone uploads; anything but a filled box leaves the panel's own
 * background showing through as a band. `object-fit: cover` scales a small
 * picture up as happily as it crops a large one, so size is not the problem --
 * being overruled is. Nearly every theme ships `img { height: auto }` and many
 * Elementor themes reach further than that, at a specificity this cannot beat
 * from a plugin stylesheet. These four are therefore forced.
 *
 * The transition is for the hand-off out of the drift: when a slide stops
 * being the active one its animation is removed and the computed transform
 * jumps back, which is visible through a feathered entrance unless it is
 * eased.
 */
.estry .estry__img,
.estry .estry__vid {
	display: block;
	position: absolute;
	inset: 0;
	width: 100% !important;
	height: 100% !important;
	min-width: 100%;
	min-height: 100%;
	max-width: none;
	max-height: none;
	object-fit: cover !important;
	object-position: var(--estry-obj-pos, 50% 50%) !important;
	transition: transform var(--estry-fade, 900ms) var(--estry-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/*
 * A slow drift on whatever is showing, so a held panel is never quite still.
 * It runs on the media, leaving the slide's own transform free for the
 * entrance.
 */
/*
 * will-change goes here rather than on every picture in the stack.
 *
 * It asks the browser for a compositing layer, and a layer costs memory
 * whether anything is moving on it or not. One panel has as many slides as the
 * story has items, and only ever one of them is drifting.
 */
.estry__slide[data-estry-on] .estry__img,
.estry__slide[data-estry-on] .estry__vid {
	animation: estry-drift var(--estry-drift, 14s) ease-out forwards;
	will-change: transform;
}

/* ------------------------------------------------------ what moves where --- */

.estry__slide {
	transition:
		clip-path var(--estry-fade, 900ms) var(--estry-ease, cubic-bezier(0.16, 1, 0.3, 1)),
		-webkit-mask-position var(--estry-fade, 900ms) var(--estry-ease, cubic-bezier(0.16, 1, 0.3, 1)),
		mask-position var(--estry-fade, 900ms) var(--estry-ease, cubic-bezier(0.16, 1, 0.3, 1)),
		transform var(--estry-fade, 900ms) var(--estry-ease, cubic-bezier(0.16, 1, 0.3, 1)),
		opacity var(--estry-fade, 900ms) var(--estry-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

.estry__inner {
	transition:
		transform var(--estry-fade, 900ms) var(--estry-ease, cubic-bezier(0.16, 1, 0.3, 1)),
		filter var(--estry-fade, 900ms) var(--estry-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

@keyframes estry-drift {
	from {
		transform: scale(1);
	}

	to {
		transform: scale(var(--estry-drift-to, 1.06));
	}
}

/* --------------------------------------------------------- the entrance --- */

/*
 * Four ways in, all of them the same shape: the incoming slide starts in an
 * "enter" state, the script clears it on the next frame, and it settles over
 * the picture below. Direction comes from data-estry-dir, so scrolling back
 * up is the mirror of scrolling down rather than a repeat of it.
 *
 * The easing is a hard expo-out: most of the distance is covered immediately
 * and the last of it is a long settle. That difference in pacing is most of
 * what separates this from a linear cross-fade.
 */
/*
 * Putting a slide into its "enter" state must not itself be animated, or the
 * slide would travel *out* to the start of the entrance before playing it.
 * The script sets this for one frame while it arms the slide.
 */
.estry__slide.is-instant,
.estry__slide.is-instant .estry__inner {
	transition: none;
}

/* Dissolve: the plain one, kept because sometimes a section wants calm. */
[data-estry-transition="dissolve"] .estry__slide.is-entering {
	opacity: 0;
}

/*
 * Wipe: a hard edge travelling across the panel in the direction of travel,
 * with the picture behind that edge already oversized and relaxing into
 * place. The offset parallax between edge and picture is the cinematic part.
 */
[data-estry-transition="wipe"] .estry__slide.is-entering {
	clip-path: inset(100% 0 0 0);
}

[data-estry-transition="wipe"][data-estry-dir="up"] .estry__slide.is-entering {
	clip-path: inset(0 0 100% 0);
}

[data-estry-transition="wipe"] .estry__slide.is-entering .estry__inner {
	transform: scale(1.18) translateY(-4%);
}

[data-estry-transition="wipe"][data-estry-dir="up"] .estry__slide.is-entering .estry__inner {
	transform: scale(1.18) translateY(4%);
}

/* Push: the incoming panel slides over the one below it. */
[data-estry-transition="push"] .estry__slide.is-entering {
	transform: translateY(100%);
}

[data-estry-transition="push"][data-estry-dir="up"] .estry__slide.is-entering {
	transform: translateY(-100%);
}

[data-estry-transition="push"] .estry__slide.is-entering .estry__inner {
	transform: scale(1.12);
}

/* Zoom: arrives out of focus and oversized, resolves as it lands. */
[data-estry-transition="zoom"] .estry__slide.is-entering {
	opacity: 0;
}

[data-estry-transition="zoom"] .estry__slide.is-entering .estry__inner {
	transform: scale(1.22);
	filter: blur(18px);
}

/*
 * Reveal: a soft-edged sweep, and the default.
 *
 * A hard wipe announces itself as an effect. This is the same move with the
 * edge feathered, done with a mask three times the panel's height that slides
 * across it. Only mask-position animates, which is cheap and, unlike a
 * gradient whose stops move, actually interpolates everywhere.
 *
 * The stops are not free choices. The panel is one third of the mask, so the
 * settled window is the mask's last third and the hidden window is its first:
 * anything other than solid across 66.7%-100% leaves a permanent veil over
 * part of a settled picture, and anything other than clear across 0%-33.3%
 * means the entrance starts already half visible. The feather therefore has
 * to live strictly between them -- here 45% to 60%, which is 45% of the
 * panel's height of soft edge.
 *
 * Because the edge is soft you see both pictures at once through it, which is
 * what makes the counter-move below worth having.
 */
[data-estry-transition="reveal"] .estry__slide {
	--estry-mask-dir: to bottom;
	--estry-mask-hidden: 0% 0%;
	--estry-mask-shown: 0% 100%;
	-webkit-mask-image: linear-gradient(var(--estry-mask-dir), transparent 0%, transparent 45%, #000 60%, #000 100%);
	mask-image: linear-gradient(var(--estry-mask-dir), transparent 0%, transparent 45%, #000 60%, #000 100%);
	-webkit-mask-size: 100% 300%;
	mask-size: 100% 300%;
	-webkit-mask-repeat: no-repeat;
	mask-repeat: no-repeat;
	-webkit-mask-position: var(--estry-mask-shown);
	mask-position: var(--estry-mask-shown);
}

[data-estry-transition="reveal"][data-estry-dir="up"] .estry__slide {
	--estry-mask-dir: to top;
	--estry-mask-hidden: 0% 100%;
	--estry-mask-shown: 0% 0%;
}

[data-estry-transition="reveal"] .estry__slide.is-entering {
	-webkit-mask-position: var(--estry-mask-hidden);
	mask-position: var(--estry-mask-hidden);
}

[data-estry-transition="reveal"] .estry__slide.is-entering .estry__inner {
	transform: scale(1.12) translateY(-3%);
}

[data-estry-transition="reveal"][data-estry-dir="up"] .estry__slide.is-entering .estry__inner {
	transform: scale(1.12) translateY(3%);
}

/*
 * The counter-move. The picture being replaced eases back and away while the
 * new one arrives over it, so for the length of the change the panel has two
 * things moving at different speeds rather than one thing moving and one thing
 * sitting still. Under a feathered entrance you can see it happen; under a
 * hard-edged one it is doing no harm.
 *
 * The drift is held at its end value here rather than released, because
 * dropping a finished animation snaps the transform back, and the snap is
 * exactly what this is trying to avoid.
 */
.estry__slide.is-leaving .estry__inner {
	transform: scale(0.94);
}

.estry__slide.is-leaving .estry__img,
.estry__slide.is-leaving .estry__vid {
	transform: scale(var(--estry-drift-to, 1.06));
}

/*
 * Without the script the slides are a plain stack, so the last one in the
 * markup would win. Showing the first is the sensible no-JavaScript state.
 */
.estry__frame:not([data-estry-ready]) .estry__slide:first-child {
	z-index: 1;
}

/* ------------------------------------------------------------- narrow --- */

/*
 * A phone gets the story taken apart and put back together as itself.
 *
 * A pinned panel beside a column of text has nowhere to go on a 390px screen:
 * stacked, the panel scrolls away long before the text it belongs to, and the
 * pairing between the two -- which is the entire widget -- is lost. So the
 * script moves each picture out of the pinned frame and in under the item it
 * belongs to, and the section becomes what it always was underneath: text,
 * then its picture, one after another.
 *
 * The highlight still scrubs. It is the part that works at any width.
 */
@media (max-width: 1024px) {
	.estry {
		grid-template-columns: 1fr;
	}

	.estry__items {
		/* Nothing is pinned any more, so the breathing room that existed to
		   let items travel the reading band is just a long gap. */
		gap: var(--estry-item-gap-mobile, 64px);
		padding: 0;
	}

	/* Emptied by the script, and nothing to show even if it were not. */
	.estry[data-estry-stacked] .estry__media {
		display: none;
	}

	/*
	 * A picture inside an item is an ordinary block, not one of a stack of
	 * absolutely positioned slides, so every one of those properties has to be
	 * put back -- including the clip path, which would otherwise still be
	 * holding an entrance shape it will never be released from.
	 */
	.estry[data-estry-stacked] .estry__item .estry__slide {
		position: relative;
		inset: auto;
		z-index: auto;
		height: auto;
		aspect-ratio: var(--estry-ratio-mobile, 1.33);
		margin-top: var(--estry-media-space-mobile, 24px);
		/* The corner radius is in the generated path when a notch is on, and
		   this when it is not. The script's inline clip path wins over the
		   `none` below, which is only here to clear an entrance shape the
		   slide would otherwise still be carrying. */
		border-radius: var(--estry-radius, 16px);
		clip-path: none;
		transform: none;
		opacity: 1;
		transition: none;
	}

	/*
	 * The panel's border, kept.
	 *
	 * Wide, the edge belongs to the frame. Stacked, the frame is empty and
	 * hidden, so without this the pictures lose the outline the panel had and
	 * the section changes character on a phone for no reason anyone asked for.
	 *
	 * Only when there is no notch: a clipped box cannot carry a border, for
	 * the same reason the pinned panel cannot, and a notched picture is
	 * outlined by a stroked copy of its own path instead -- see
	 * .estry__outline and buildAcross() in the script.
	 *
	 * box-sizing is not assumed. The slide is sized by an aspect ratio, and
	 * under content-box a border would be added to the height it works out,
	 * leaving each picture a couple of pixels taller than the one above it.
	 */
	.estry[data-estry-stacked]:not([data-estry-notched]) .estry__item .estry__slide {
		box-sizing: border-box;
		border: var(--estry-media-bw, 0px) solid var(--estry-media-border, #052424);
	}

	.estry[data-estry-stacked] .estry__item .estry__inner {
		position: absolute;
		inset: 0;
		transform: none;
		filter: none;
		transition: none;
	}

	/* No drift either: it exists to keep a held panel from being still, and
	   nothing here is held. */
	.estry[data-estry-stacked] .estry__item .estry__img,
	.estry[data-estry-stacked] .estry__item .estry__vid {
		animation: none;
		transform: none;
	}
}

@media (prefers-reduced-motion: reduce) {
	/* Everything is simply read. The script skips the scrub entirely, so
	   nothing here has to undo a half-finished sweep. */
	[data-estry-ready] .estry-c {
		color: inherit;
		transition: none;
		animation: none;
	}

	.estry__slide,
	.estry__inner {
		transition: none;
	}

	[data-estry-transition="reveal"] .estry__slide {
		-webkit-mask-image: none;
		mask-image: none;
	}

	.estry__slide[data-estry-on] .estry__img,
	.estry__slide[data-estry-on] .estry__vid {
		animation: none;
	}
}
