/**
 * Heading Reveal
 *
 * The JavaScript only ever writes ONE custom property per element per frame
 * (--hr-p, the 0-1 timeline position). Every unit works out its own progress
 * from that shared value in CSS, which is what keeps long headings smooth.
 */

/* Registering the property lets the engine treat it as a real number instead of
   re-parsing a token stream every frame. Ignored where unsupported. */
@property --hr-p {
	syntax: '<number>';
	inherits: true;
	initial-value: 1;
}

.heading-reveal {
	--hr-p: 1;
	--hr-n: 1;
	--hr-step: 0;
	--hr-window: 0.3;
	--hr-o0: 0.18;
	--hr-y: 0.3em;
	--hr-blur: 6px;
	--hr-dur: 800ms;
	--hr-sd: 55ms;
	--hr-delay: 0ms;
	--hr-ease: cubic-bezier(0.22, 0.61, 0.36, 1);

	display: block;
}

.heading-reveal .hr-heading {
	margin-top: 0;
	text-wrap: pretty;
}

.heading-reveal .hr-link,
.heading-reveal .hr-link:hover,
.heading-reveal .hr-link:focus {
	color: inherit;
	text-decoration: none;
}

.hr-anchor {
	display: block;
	height: 0;
	overflow: hidden;
}

/* A word: never breaks mid-unit, and hosts the overlay used by colour mode. */
.heading-reveal .hr-w {
	display: inline-block;
}

.heading-reveal.hr-split-char .hr-w {
	white-space: nowrap;
}

/* Whole-heading mode: the single unit is block level so the text wraps and
   breaks exactly as ordinary text would, rather than being held on one line by
   the inline-block used for word and letter units. */
.heading-reveal.hr-split-whole .hr-u {
	display: block;
}

.heading-reveal.hr-split-whole.hr-wipe .hr-u,
.heading-reveal.hr-split-whole.hr-type-color .hr-u {
	display: grid;
}

.heading-reveal.hr-split-whole.hr-motion-clip .hr-t {
	display: block;
}

.heading-reveal .hr-u {
	display: inline-block;
	position: relative;
}

/* ---------------------------------------------------------------------------
 * Per-unit progress
 *
 * Each unit shifts the shared timeline by its own index, then normalises what
 * is left of the window so the final unit still lands exactly on 1.
 * ------------------------------------------------------------------------ */

.heading-reveal .hr-u {
	--hr-l: clamp(0, calc((var(--hr-p) - (var(--hr-i) * var(--hr-step))) / var(--hr-window)), 1);
}

/* --- Fade mode ---------------------------------------------------------- */

.hr-mode-scroll.hr-type-opacity:not(.hr-wipe) .hr-u {
	opacity: calc(var(--hr-o0) + (1 - var(--hr-o0)) * var(--hr-l));
}

/* --- Colour mode -------------------------------------------------------- */

.heading-reveal.hr-type-color .hr-t {
	color: var(--hr-c0, currentColor);
}

/* Both copies must be metrically identical, or the overlay sits fractionally off
   the base and the text looks doubled or crowded. The base copy is inline inside
   a text run while the overlay is absolutely positioned in its own line box, and
   browsers may apply kerning and ligature substitution differently to the two.
   Pinning both off removes that whole class of misalignment. */
.heading-reveal .hr-t,
.heading-reveal .hr-f {
	font-kerning: none;
	font-variant-ligatures: none;
}

/* Splitting into per-letter boxes already breaks kerning between letters, so
   match the rest of the heading to it and keep spacing consistent. */
.heading-reveal.hr-split-char .hr-heading {
	font-kerning: none;
	font-variant-ligatures: none;
}

/* The overlay copy has to sit exactly over the base copy.
 *
 * This uses a single-cell grid rather than absolute positioning. Absolute
 * positioning aligns the overlay to the parent's content-box origin, but the
 * inline base copy is painted on the line box's baseline — those two coincide
 * only when line-height, half-leading and baseline all happen to agree, and
 * when they don't the overlay sits slightly off and the heading looks doubled.
 *
 * Placing both copies in the same grid cell makes them share one box by
 * construction, so they cannot drift apart whatever the typography settings.
 * Paint order follows the DOM, so the overlay is on top. */
.heading-reveal.hr-wipe .hr-u,
.heading-reveal.hr-type-color .hr-u {
	display: inline-grid;
	grid-template-areas: 'hr';
	justify-items: start;
	align-items: start;
}

.heading-reveal .hr-t,
.heading-reveal .hr-f {
	grid-area: hr;
	white-space: pre;
}

/* A CSS mask clips to the border box, and glyph ink routinely escapes it: any
 * descender when the line height is tight, and the diagonal terminals of letters
 * like k, x and y, whose side bearings can be negative. Masked-out ink vanishes
 * from the revealed copy while the dimmed copy underneath still shows it, leaving
 * faint fragments on exactly those letters.
 *
 * Padding grows the mask box past the ink; the matching negative margin cancels
 * it, so the grid cell is sized exactly as before and the layout does not move.
 * Both copies get identical values, so they stay aligned. */
.heading-reveal.hr-wipe .hr-t,
.heading-reveal.hr-wipe .hr-f {
	--hr-bleed-y: 0.4em;
	--hr-bleed-x: 0.18em;

	padding: var(--hr-bleed-y) var(--hr-bleed-x);
	margin: calc(-1 * var(--hr-bleed-y)) calc(-1 * var(--hr-bleed-x));
}

.heading-reveal .hr-f {
	color: inherit;
	pointer-events: none;
}

.hr-mode-scroll.hr-type-color:not(.hr-wipe) .hr-f {
	opacity: var(--hr-l);
}

/* --- Optional extras --------------------------------------------------- */

.hr-mode-scroll.hr-fx-slide .hr-u {
	transform: translate3d(0, calc((1 - var(--hr-l)) * var(--hr-y)), 0);
}

.hr-mode-scroll.hr-fx-blur .hr-u {
	filter: blur(calc((1 - var(--hr-l)) * var(--hr-blur)));
}


/* ---------------------------------------------------------------------------
 * Gradient wipe
 *
 * Instead of fading each unit as a block, a soft-edged mask sweeps across it, so
 * the transition passes continuously through the letterforms.
 *
 * This works on top of either reveal unit: per word, or per letter. Each unit
 * owns a slice of the timeline (--hr-a to --hr-b) sized by its character count,
 * so the leading edge travels at a constant speed across the whole heading
 * rather than pausing on short words.
 * ------------------------------------------------------------------------ */

/* Each unit's transition is widened by --hr-ov so neighbours overlap: several
   units are always part-revealed at once, which is what makes the band read as
   one continuous sweep instead of a run of separate ones. The timeline is
   scaled by (1 + overlap) so the last unit still lands exactly on 1. */
.heading-reveal.hr-wipe .hr-u {
	--hr-ov: 0;
	--hr-l: clamp(
		0,
		calc(
			(var(--hr-p) * (1 + var(--hr-ov)) - var(--hr-a))
			/ ((var(--hr-b) - var(--hr-a)) + var(--hr-ov))
		),
		1
	);
}

/* The base layer is the unrevealed state: dimmed, or the starting colour. */
.hr-wipe.hr-type-opacity .hr-t {
	opacity: var(--hr-o0);
}

/* The overlay carries the finished text and is revealed by the mask.
   The band travels from entirely off the left edge to entirely off the right,
   so the soft edge is never clipped at a word boundary. */
.heading-reveal.hr-wipe .hr-f {
	opacity: 1;
	-webkit-mask-image: linear-gradient(
		90deg,
		#000 0,
		#000 calc(var(--hr-l) * (100% + 2 * var(--hr-edge)) - 2 * var(--hr-edge)),
		transparent calc(var(--hr-l) * (100% + 2 * var(--hr-edge)) - var(--hr-edge)),
		transparent 100%
	);
	mask-image: linear-gradient(
		90deg,
		#000 0,
		#000 calc(var(--hr-l) * (100% + 2 * var(--hr-edge)) - 2 * var(--hr-edge)),
		transparent calc(var(--hr-l) * (100% + 2 * var(--hr-edge)) - var(--hr-edge)),
		transparent 100%
	);
	-webkit-mask-repeat: no-repeat;
	mask-repeat: no-repeat;
}

/* Gradient mode is driven entirely by --hr-p, in both scroll and play-once
   modes, so it never uses the transition-based enter states below. */
.hr-wipe.hr-mode-enter .hr-u,
.hr-wipe.hr-mode-enter .hr-f {
	transition: none;
}

.hr-wipe.hr-mode-enter.hr-type-opacity .hr-u {
	opacity: 1;
}

.hr-wipe.hr-mode-enter.hr-type-color .hr-f,
.hr-wipe.hr-mode-enter.hr-fx-slide .hr-u {
	opacity: 1;
	transform: none;
}

/* ---------------------------------------------------------------------------
 * Animate-in-once mode
 *
 * Pure CSS transitions with a per-unit delay, so there is no per-frame work
 * at all once the element has been triggered.
 * ------------------------------------------------------------------------ */

.hr-mode-enter .hr-u,
.hr-mode-enter .hr-f {
	transition-property: opacity, transform, filter;
	transition-duration: var(--hr-dur);
	transition-timing-function: var(--hr-ease);
	transition-delay: calc(var(--hr-delay) + var(--hr-i) * var(--hr-sd));
}

.hr-mode-enter.hr-type-opacity .hr-u {
	opacity: var(--hr-o0);
}

.hr-mode-enter.hr-type-opacity.hr-in .hr-u {
	opacity: 1;
}

.hr-mode-enter.hr-type-color .hr-f {
	opacity: 0;
}

.hr-mode-enter.hr-type-color.hr-in .hr-f {
	opacity: 1;
}

.hr-mode-enter.hr-fx-slide .hr-u {
	transform: translate3d(0, var(--hr-y), 0);
}

.hr-mode-enter.hr-fx-slide.hr-in .hr-u {
	transform: translate3d(0, 0, 0);
}

.hr-mode-enter.hr-fx-blur .hr-u {
	filter: blur(var(--hr-blur));
}

.hr-mode-enter.hr-fx-blur.hr-in .hr-u {
	filter: blur(0);
}


/* ---------------------------------------------------------------------------
 * Bounce up
 *
 * Each unit springs up into place from below, overshooting its resting position
 * slightly before settling. The overshoot comes from the spring curve's control
 * point sitting above 1, not from extra keyframes, which keeps it smooth.
 *
 * Unlike the fade and the wipe this is a timed motion rather than a scrubbed
 * one: reversing an overshoot under the scrollbar looks wrong, so the element
 * always plays once on entering view. Everything below is pure CSS animation,
 * so there is no per-frame JavaScript at all.
 * ------------------------------------------------------------------------ */

@keyframes hr-bounce-move {
	from { transform: translate3d(0, var(--hr-bounce-y), 0); }
	to   { transform: translate3d(0, 0, 0); }
}

@keyframes hr-bounce-fade {
	from { opacity: var(--hr-o0); }
	to   { opacity: 1; }
}

@keyframes hr-bounce-fill {
	from { opacity: 0; }
	to   { opacity: 1; }
}

.heading-reveal.hr-motion-bounce {
	--hr-bounce-y: 0.9em;
	--hr-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* The animations replace the transition-based enter states entirely. */
.hr-motion-bounce .hr-u,
.hr-motion-bounce .hr-f {
	transition: none;
}

/* Resting state, before the heading has been reached. */
.hr-motion-bounce .hr-u {
	transform: translate3d(0, var(--hr-bounce-y), 0);
}

.hr-motion-bounce.hr-type-opacity .hr-u {
	opacity: var(--hr-o0);
}

.hr-motion-bounce.hr-type-color .hr-f {
	opacity: 0;
}

/* Playing. The fade runs shorter than the move so a unit is fully visible while
   it is still settling, rather than fading in during the overshoot. */
.hr-motion-bounce.hr-in .hr-u {
	animation: hr-bounce-move var(--hr-dur) var(--hr-spring) both;
	animation-delay: calc(var(--hr-delay) + var(--hr-i) * var(--hr-sd));
}

.hr-motion-bounce.hr-in.hr-type-opacity .hr-u {
	animation:
		hr-bounce-move var(--hr-dur) var(--hr-spring) both,
		hr-bounce-fade calc(var(--hr-dur) * 0.55) ease-out both;
	animation-delay: calc(var(--hr-delay) + var(--hr-i) * var(--hr-sd));
}

.hr-motion-bounce.hr-in.hr-type-color .hr-f {
	animation: hr-bounce-fill calc(var(--hr-dur) * 0.55) ease-out both;
	animation-delay: calc(var(--hr-delay) + var(--hr-i) * var(--hr-sd));
}


/* ---------------------------------------------------------------------------
 * Clip slide up
 *
 * Each unit sits behind a hidden edge and rises into view, so the text appears
 * to arrive from nowhere rather than sliding through empty space.
 *
 * The edge is a mask rather than overflow: hidden. An inline-block with hidden
 * overflow takes its baseline from its bottom margin edge instead of from its
 * text, which would drop every unit relative to the rest of the line. A solid
 * mask clips to the box identically without touching layout.
 *
 * The box is padded on all four sides so ink stays inside it, with a matching
 * negative margin so nothing moves. Vertical padding keeps ascenders and
 * descenders whole; horizontal padding matters just as much, because the mask
 * clips left and right too and letters like k, x and y overhang their advance
 * width. The clip edge is the foot of the padded box, safely below any
 * descender, and the resting text is pushed past it by its own height plus the
 * vertical allowance.
 *
 * mask-repeat must be no-repeat: the default tiles the mask beyond the box,
 * which would leave the resting text visible outside the clip.
 * ------------------------------------------------------------------------ */

@keyframes hr-clip-up {
	from { transform: translateY(calc(var(--hr-clip-travel) + var(--hr-clip-bleed))); }
	to   { transform: translateY(0); }
}

.heading-reveal.hr-motion-clip {
	--hr-clip-bleed: 0.45em;
	--hr-clip-bleed-x: 0.2em;
	--hr-clip-travel: 100%;
}

.hr-motion-clip .hr-u {
	padding: var(--hr-clip-bleed) var(--hr-clip-bleed-x);
	margin: calc(-1 * var(--hr-clip-bleed)) calc(-1 * var(--hr-clip-bleed-x));

	-webkit-mask-image: linear-gradient(#000, #000);
	mask-image: linear-gradient(#000, #000);
	-webkit-mask-repeat: no-repeat;
	mask-repeat: no-repeat;
}

.hr-motion-clip .hr-t {
	display: inline-block;
	transform: translateY(calc(var(--hr-clip-travel) + var(--hr-clip-bleed)));
}

/* The reveal is the movement alone, so nothing is dimmed at rest. */
.hr-motion-clip.hr-mode-enter .hr-u,
.hr-motion-clip.hr-mode-enter .hr-t {
	opacity: 1;
	transition: none;
}

.hr-motion-clip.hr-in .hr-t {
	animation: hr-clip-up var(--hr-dur) var(--hr-ease) both;
	animation-delay: calc(var(--hr-delay) + var(--hr-i) * var(--hr-sd));
}


/* ---------------------------------------------------------------------------
 * Blur and fade
 *
 * Each unit resolves out of a soft blur while fading up to full, with no
 * movement at all. The stillness is the point, so nothing here touches
 * transform.
 *
 * Like bounce and clip this is a timed motion rather than a scrubbed one.
 * Blur is by far the most expensive thing to recalculate, and re-resolving it
 * per unit on every scroll frame is the one case genuinely worth avoiding. As
 * CSS animations these are handed to the compositor once and left alone.
 * ------------------------------------------------------------------------ */

@keyframes hr-blur-in {
	from { opacity: var(--hr-o0); filter: blur(var(--hr-blur)); }
	to   { opacity: 1; filter: blur(0); }
}

@keyframes hr-blur-sharpen {
	from { filter: blur(var(--hr-blur)); }
	to   { filter: blur(0); }
}

@keyframes hr-blur-fill {
	from { opacity: 0; }
	to   { opacity: 1; }
}

.hr-motion-blur .hr-u,
.hr-motion-blur .hr-f {
	transition: none;
}

/* Resting state. */
.hr-motion-blur .hr-u {
	filter: blur(var(--hr-blur));
}

.hr-motion-blur.hr-type-opacity .hr-u {
	opacity: var(--hr-o0);
}

.hr-motion-blur.hr-type-color .hr-f {
	opacity: 0;
}

/* Playing. Fade mode does both properties on the one element; colour mode
   sharpens the unit while the overlay copy fades in over the base. */
.hr-motion-blur.hr-in.hr-type-opacity .hr-u {
	animation: hr-blur-in var(--hr-dur) var(--hr-ease) both;
	animation-delay: calc(var(--hr-delay) + var(--hr-i) * var(--hr-sd));
}

.hr-motion-blur.hr-in.hr-type-color .hr-u {
	animation: hr-blur-sharpen var(--hr-dur) var(--hr-ease) both;
	animation-delay: calc(var(--hr-delay) + var(--hr-i) * var(--hr-sd));
}

.hr-motion-blur.hr-in.hr-type-color .hr-f {
	animation: hr-blur-fill calc(var(--hr-dur) * 0.7) ease-out both;
	animation-delay: calc(var(--hr-delay) + var(--hr-i) * var(--hr-sd));
}

/* ---------------------------------------------------------------------------
 * Safety nets
 * ------------------------------------------------------------------------ */

/* No JavaScript, or our script failed to load: show the finished state.
   Keyed off a data attribute, not a class: <html> classes get overwritten
   wholesale by other scripts, which would disable the reveal entirely. */
html:not([data-hr-js]) .heading-reveal {
	--hr-p: 1 !important;
}

html:not([data-hr-js]) .heading-reveal .hr-u,
html:not([data-hr-js]) .heading-reveal .hr-f,
.heading-reveal.hr-editor .hr-u,
.heading-reveal.hr-editor .hr-f,
.heading-reveal.hr-off .hr-u,
.heading-reveal.hr-off .hr-f,
html:not([data-hr-js]) .heading-reveal .hr-t,
.heading-reveal.hr-editor .hr-t,
.heading-reveal.hr-off .hr-t {
	opacity: 1 !important;
	transform: none !important;
	filter: none !important;
	transition: none !important;
	animation: none !important;
	-webkit-mask-image: none !important;
	mask-image: none !important;
}

/* Page builder editors and manually disabled instances. */
.heading-reveal.hr-editor,
.heading-reveal.hr-off {
	--hr-p: 1 !important;
}

/* Reveal switched off for this element on phones. */
@media only screen and (max-width: 690px) {

	.heading-reveal.hr-no-mobile {
		--hr-p: 1 !important;
	}

	.heading-reveal.hr-no-mobile .hr-u,
	.heading-reveal.hr-no-mobile .hr-f,
	.heading-reveal.hr-no-mobile .hr-t {
		opacity: 1 !important;
		transform: none !important;
		filter: none !important;
		transition: none !important;
		animation: none !important;
		-webkit-mask-image: none !important;
		mask-image: none !important;
	}
}

/* Reduced motion: drop the movement, keep the text readable. */
@media (prefers-reduced-motion: reduce) {

	.heading-reveal.hr-respect-motion {
		--hr-p: 1 !important;
	}

	.heading-reveal.hr-respect-motion .hr-u,
	.heading-reveal.hr-respect-motion .hr-f,
	.heading-reveal.hr-respect-motion .hr-t {
		opacity: 1 !important;
		transform: none !important;
		filter: none !important;
		transition: none !important;
		animation: none !important;
		-webkit-mask-image: none !important;
		mask-image: none !important;
	}
}
