/*
Theme Name: Ecom Optimization
Theme URI: https://ecomoptimization.com
Author: Ecom Optimization
Description: Custom block theme (Full Site Editing) for the Ecom Optimization marketing site. Design tokens live in theme.json; see CLAUDE.md for the full spec.
Version: 0.1.1
Requires at least: 6.6
Requires PHP: 7.4
Text Domain: ecomopt
*/

/* Everything token-driven (color, type, spacing, radius) lives in theme.json. This file only
   holds the handful of rules theme.json can't express: min-height/touch targets, focus
   states, reduced-motion fallbacks, and the sticky header shell. */

/* Without this, an element's padding/border adds to its declared width instead of being
   contained within it — the bug behind the hero carousel cards overflowing their column. */
*,
*::before,
*::after {
	box-sizing: border-box;
}

/* alignfull blocks break out to the true viewport edge with margin: calc(50% - 50vw) — but
   100vw includes the width a scrollbar would occupy, while the block's own (percentage-based)
   containing block doesn't. On any page tall enough to scroll, that mismatch pulls every
   full-bleed section about half a scrollbar's width short of the right edge, showing as a thin
   strip of the page background running down the right side.
   scrollbar-gutter: stable reserves that space consistently so the vw/% math resolves to the
   same answer either way — fixing the mismatch at its source. Deliberately NOT overflow-x on
   html/body (an earlier version of this fix used that): overflow-x and overflow-y interact —
   setting one to non-visible computes the other to `auto`, which can turn body/html into a
   distinct scroll container and silently break position: sticky for descendants (the sticky
   header) in some browsers. scrollbar-gutter doesn't touch overflow at all, so it carries none
   of that risk. */
html {
	scrollbar-gutter: stable;
}

/* Live-site migration (2026-10-16): theme.json's own `body { color: var(--wp--preset--color--
   charcoal); }` isn't `!important`, so on the production install it was itself losing to a
   competing, directly-matched `body` color rule from Elementor/the old theme's leftover CSS —
   the same direct-beats-inherited conflict as the heading/paragraph fix just below, one level
   higher up. Anything with no textColor class at all anywhere in its ancestor chain (relying
   purely on the page's own default, not an explicit soft-white/charcoal group) was inheriting
   that wrong color from body itself. `!important` restores the real default; anything with its
   own more specific class (has-X-color, on itself or a closer ancestor) still correctly wins
   over this since class selectors beat a bare element selector regardless. */
body {
	color: var(--wp--preset--color--charcoal) !important;
	/* Added 2026-10-16: same gap, one property over, caught live via DevTools after the p-level
	   line-height fix (below) correctly won on the <p> itself but still rendered wrong — because
	   `inherit` only pulls whatever the *parent's* own computed line-height is, and body's own
	   line-height wasn't protected the same way its color was, so it was itself still losing to
	   a competing direct rule higher up (Elementor). Hardcoded to 1.6 to match theme.json's own
	   body line-height exactly (that file sets it as a literal, not a custom property). */
	line-height: 1.6 !important;
}

/* Live-site migration (2026-10-16): same conflict again, now for links and buttons — both of
   theme.json's own rules for these wrap their selector in :where(...), a WordPress core
   convention that deliberately zeroes out everything inside it for specificity purposes (so
   individual block-level color overrides can always win without needing !important of their
   own). That's correct in isolation, but it also means these are the easiest possible rules for
   any outside CSS with real, non-zero specificity (Elementor's leftover kit styles) to beat,
   even without !important on either side — specificity alone decides a tie between two
   non-important rules, and :where() rules always lose that comparison. Re-declaring both with
   !important and the theme's own selectors restores the intended default (plain orange links,
   soft-white button text); a link or button with its own more specific has-X-color class still
   correctly wins over this, same as the body/heading fixes above. */
a:where(:not(.wp-element-button)) {
	color: var(--wp--preset--color--orange) !important;
}

:root :where(.wp-element-button, .wp-block-button__link) {
	color: var(--wp--preset--color--soft-white) !important;
}

/* Live-site migration (2026-10-16): the Elementor kit still active on the production install
   injects its own global CSS directly on bare heading tags (e.g. `.elementor-kit-8 h1 { color:
   var(--e-global-color-secondary); }`), site-wide, regardless of whether a given page was built
   with Elementor. Every heading in this theme gets its color from an ancestor's textColor class
   (e.g. `has-soft-white-color`, itself `!important` — theme.json's own convention) rather than a
   class on the heading element itself, and a value only inherited from a parent always loses to
   *any* rule that matches the element directly, even a non-important one, regardless of the
   inherited rule's own importance — so Elementor's unscoped tag selector was silently winning
   and showing dark grey headings on dark backgrounds meant to be soft-white. `!important` here
   restores plain inheritance (this theme's actual intent) and wins outright, since Elementor's
   conflicting rule isn't itself `!important`. */
/* :where(:not([class*="-color"])) excludes elements (many eyebrow paragraphs, parts/footer.html's
   h4s, every eo-* component class like eo-stat__value, etc.) that set their own color directly on
   the block itself — those already correctly beat Elementor's rule on their own (a direct match
   with !important beats a direct, non-important match regardless), so forcing them to inherit too
   would discard their intended color instead of fixing anything. Covers p too, not just headings
   — same conflict, same fix. Deliberately NOT `a`: this theme's own theme.json gives every link a
   default orange color via a bare `a` selector (not inheritance) — forcing links to inherit
   instead would break that default sitewide rather than fix anything, since links were never
   relying on inheritance here in the first place. The :not() is wrapped in :where() specifically
   so it contributes zero specificity of its own — :not() on its own counts as a real selector for
   specificity purposes (same weight as a class), which would make this whole rule *more specific
   than a plain single-class component rule* like `.eo-stat__value` and wrongly keep winning
   against it even after that component's own color gets !important too (caught live: every
   eo-*__value-style class across the custom blocks needed !important the same way, and without
   :where() here this rule kept steamrolling all of them right back). */
h1:where(:not([class*="-color"])),
h2:where(:not([class*="-color"])),
h3:where(:not([class*="-color"])),
h4:where(:not([class*="-color"])),
h5:where(:not([class*="-color"])),
h6:where(:not([class*="-color"])),
p:where(:not([class*="-color"])) {
	color: inherit !important;
}

/* Live-site migration (2026-10-16): same conflict again, now for line-height on plain <p> tags —
   confirmed live on a paragraph styled at heading-2 size (`has-heading-2-font-size`, used for a
   large standalone line like "Trusted by ecommerce brands doing real revenue" rather than an
   actual <h2>): with no direct line-height of its own, it only ever gets one by inheriting 1.6
   from `body`'s own (non-important) rule — and loses that inheritance to Elementor's own direct,
   tighter `p` line-height, the two lines overlapping instead of stacking. Real h1–h6 tags aren't
   covered here: unlike color, they get their line-height from theme.json's own *direct* rule on
   h1–h6 (not inherited from body), so they aren't exposed to this same inherited-vs-direct gap —
   no evidence yet that they need the same fix, only plain paragraphs relying on body's cascade.
   :where() here for the same specificity reason as the color rule above. */
p:where(:not([class*="-color"])) {
	line-height: inherit !important;
}

body {
	margin: 0;
}

:root {
	--eo-button-height: var(--wp--custom--button--height, 60px);
	--eo-button-height-mobile: var(--wp--custom--button--height-mobile, 56px);
}

/* WordPress inserts a default ~2em gap between top-level blocks (header template part, main,
   footer template part) via .wp-site-blocks > * + *, so full-bleed sections that are meant to
   read as one seamless module — the sticky header sitting flush against the hero, section
   backgrounds butting up against each other and the footer — showed a strip of the page
   background between them instead. Every section already carries its own vertical padding
   (see patterns/*.php), so nothing needs that implicit gap; scoped to .wp-site-blocks only,
   not theme.json's global blockGap, so nested block spacing elsewhere is untouched.
   Same problem, one level deeper: <main> itself is just one child of .wp-site-blocks (so the
   rule above only closes the gap *around* it), but every page's patterns/sections stack as
   *main's own* direct children — front-page.html alone has 10 of them — and each of those
   boundaries got the identical ~2em gap, via the same default blockGap mechanism, one layer in.
   Most visible between graphite and charcoal sections (e.g. the logo marquee sitting between two
   charcoal modules) since the page's white background behind the gap reads as a stark bar
   between two dark sections, but it was happening at every section boundary on every template,
   not just there. Same fix, same reasoning, scoped one selector deeper. */
.wp-site-blocks > * + *,
main.wp-block-group > * + * {
	margin-block-start: 0;
}

/* Side gutter for every full-bleed section, below the viewport width its own centered content
   stops needing one. theme.json sets `useRootPaddingAwareAlignments: true` plus a matching
   `styles.spacing.padding`, which is WordPress's own built-in mechanism for exactly this — but
   that mechanism only works by giving each `alignfull` block a `has-global-padding` class,
   which the block editor's own JS adds automatically when a block is authored *through* the
   editor. Every section on this site is hand-written HTML in patterns/*.php (by design — see
   CLAUDE.md §7, `Inserter: no`), never actually run through that editor save() step, so the
   class is never there and the built-in mechanism silently does nothing; reproducing it by hand
   would mean adding that class to every pattern's own outer wrapper instead of fixing it once,
   here. Padding, not margin: it insets this element's own *content* without touching its
   background, which still fills the full padding box edge-to-edge exactly as alignfull intends —
   confirmed by the client reporting a real-world symptom of this gap: text sitting flush against
   the browser edge below ~900px, e.g. the hero's own H1. spacing-80 ("Section X") is the token
   CLAUDE.md §4 already documents for exactly this purpose (`clamp(20px, 8vw, 120px)`, i.e. the
   same "120px at 1440, 20px on mobile" side padding spec) — it just was never actually wired up
   anywhere before this. */
.alignfull {
	padding-inline: var(--wp--preset--spacing--80);
}

/* Buttons: fixed height + comfortable touch target, per §4. */
.wp-block-button__link {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	min-height: var(--eo-button-height-mobile);
	min-width: 44px;
}

@media (min-width: 782px) {
	.wp-block-button__link {
		min-height: var(--eo-button-height);
	}
}

/* Visible focus states in orange, per §9 accessibility rules. */
a:focus-visible,
button:focus-visible,
.wp-block-button__link:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
	outline: 2px solid var(--wp--preset--color--orange);
	outline-offset: 2px;
}

/* Accent utility: highlights part of a string in brand orange. Produced by eo_parse_accents()
   from *asterisk* markup entered in admin fields (e.g. the case study metabox) — not meant to
   be typed directly into block content. */
.eo-accent {
	color: var(--wp--preset--color--orange);
}

/* Eyebrow utility: 700 weight, uppercase, 0.12em tracking, orange dot before. Applied via the
   "Additional CSS class(es)" field as `eo-eyebrow` on a paragraph/heading block. */
.eo-eyebrow {
	display: inline-flex;
	align-items: center;
	gap: 0.5em;
	font-size: var(--wp--preset--font-size--eyebrow);
	font-weight: 700;
	text-transform: uppercase;
	letter-spacing: 0.12em;
}

.eo-eyebrow::before {
	content: "";
	width: 6px;
	height: 6px;
	border-radius: 50%;
	background: var(--wp--preset--color--orange);
	flex: none;
}

/* Sticky header shell (behavior/measurement in src/js/motion/sticky-header.js). */
/* Solid charcoal from the start, not transparent-until-scroll: the header sits in normal
   document flow directly above the hero (sticky only changes how it behaves once you scroll
   past it, not its appearance beforehand), so "transparent by default" never actually showed
   the hero through it — it just revealed the plain page background above the hero, i.e. a
   white bar. Solid charcoal reads as one seamless module with the (also charcoal) hero
   immediately below it, on page load, with no scrolling required.
   --eo-header-bg is set inline by sticky-header.js to whichever section's color token the
   header is currently sitting over (falls back to charcoal here if JS hasn't run yet, or on
   a page with no color-tagged sections). */
.eo-sticky-header {
	position: sticky;
	top: 0;
	z-index: 100;
	background-color: var(--eo-header-bg, var(--wp--preset--color--charcoal));
	transition: background-color 300ms ease, color 300ms ease, box-shadow 300ms ease, padding 300ms ease;
	/* The condensed state shrinks this element ~110px while it's pinned; since a sticky element
	   still occupies its current size in flow, that shift can trigger the browser's scroll
	   anchoring to nudge scrollY to compensate. Left on, that nudge can cross back over the
	   condense/expand threshold and re-trigger the opposite state — see the CONDENSE_AT/EXPAND_AT
	   comment in sticky-header.js for the full loop. Belt-and-suspenders with the JS-side
	   hysteresis fix, not a substitute for it (Safari doesn't implement scroll anchoring at all,
	   so this alone wouldn't help there). */
	overflow-anchor: none;
}

/* Soft fade instead of a hard edge where the header meets whatever's scrolling underneath it.
   A pseudo-element rather than e.g. a box-shadow blur so it can carry the same dynamic color as
   the header itself (fading to transparent) instead of a fixed shadow tone that would mismatch
   once --eo-header-bg changes. Positioned absolutely against the header's own box (sticky still
   establishes a positioning context) and hung off its bottom edge with top:100%, so it doesn't
   add to the header's own height — same reasoning as the is-condensed height bug fixed earlier
   (padding directly on .eo-sticky-header changes the sticky box itself, not a harmless overlay).
   pointer-events:none so it never blocks clicks/hover on the content it's fading over.
   Eased, not a flat two-stop linear-to-transparent: a plain linear alpha ramp reads as a hard
   smudge because the eye is much more sensitive to the transparent end of a fade than the opaque
   end — the fix is more, closer-together stops near the transparent tail (the "easing gradient"
   technique) rather than evenly-spaced ones. color-mix() computes each stop's opacity from
   --eo-header-bg at render time, so the curve still tracks whatever color the header is
   currently showing instead of needing a fixed rgba() per token. Also taller (64px, was 28px) —
   short + linear was the other half of why it read as harsh rather than luxurious. */
.eo-sticky-header::after {
	content: "";
	position: absolute;
	top: 100%;
	left: 0;
	right: 0;
	height: 64px;
	background: linear-gradient(
		to bottom,
		var(--eo-header-bg, var(--wp--preset--color--charcoal)) 0%,
		color-mix(in srgb, var(--eo-header-bg, var(--wp--preset--color--charcoal)) 68%, transparent) 19%,
		color-mix(in srgb, var(--eo-header-bg, var(--wp--preset--color--charcoal)) 42%, transparent) 34%,
		color-mix(in srgb, var(--eo-header-bg, var(--wp--preset--color--charcoal)) 24%, transparent) 47%,
		color-mix(in srgb, var(--eo-header-bg, var(--wp--preset--color--charcoal)) 12%, transparent) 60%,
		color-mix(in srgb, var(--eo-header-bg, var(--wp--preset--color--charcoal)) 5%, transparent) 73%,
		color-mix(in srgb, var(--eo-header-bg, var(--wp--preset--color--charcoal)) 1%, transparent) 86%,
		transparent 100%
	);
	/* sticky-header.js sets this to 0 while a section boundary is passing through the fade's own
	   span (detected by probing both the top and bottom of that span and finding they disagree).
	   The fade is a painted approximation of whatever's underneath, not a view of it, so showing
	   it then would mean painting the *old* section's color over ground that's already become the
	   *new* section for most of that height — the "black fade sitting over a white card" artifact.
	   Hiding it for that one brief window (a flush solid edge instead) and letting it reappear
	   once both ends agree again keeps it from ever being visibly wrong, at the cost of it being
	   briefly absent right at a boundary. */
	opacity: var(--eo-fade-opacity, 1);
	pointer-events: none;
	transition: background 300ms ease, opacity 300ms ease;
}

/* Nav/logo copy is set to soft-white in parts/header.html (via WP's has-soft-white-color
   utility, which ships !important) so it reads on the header's charcoal default. Once
   --eo-header-fg starts tracking a light section (white/soft-white), that fixed soft-white
   text would vanish into it — so this repoints the same utility class, scoped to the header,
   to whatever sticky-header.js determined is readable on the current --eo-header-bg. Needs
   !important and the extra class-level specificity to actually beat the utility class it's
   overriding; a plain descendant rule targeting an ancestor doesn't win against a rule that
   targets the colored element directly. */
.eo-sticky-header .has-soft-white-color {
	color: var(--eo-header-fg, var(--wp--preset--color--soft-white)) !important;
	transition: color 300ms ease;
}

/* This hairline predates the bottom fade (originally a standalone "you've scrolled" cue) and is
   independent of --eo-fade-opacity, so when the fade suppresses itself for a section boundary
   (see the ::after comment above), this 1px highlight was the one thing left showing — a stray
   white line sitting right on the solid edge, breaking the "flush, matched" illusion the
   suppression is there to create. Scaling its own alpha by the same --eo-fade-opacity makes it
   disappear in exactly the same window the fade does, rather than being the one part of this
   that didn't get the memo. rgb()-with-calc() (not rgba()) because calc() inside an alpha
   channel needs the space-separated syntax. */
.eo-sticky-header.is-condensed {
	box-shadow: 0 1px 0 rgb(255 255 255 / calc(0.12 * var(--eo-fade-opacity, 1)));
}

/* Condensed: shrink the logo and the button, collapse the nav out of view, and shrink the row's
   own vertical padding. Targets the elements' own default classes (site-logo, core navigation,
   core button) rather than adding new ones, since none of these need a distinct identity outside
   this one state change. The nav stays in the DOM and out of display:none/visibility:hidden on
   purpose — collapsing it that way would also remove it from the tab order, so a keyboard user
   tabbing toward it would never trigger the reveal below (sticky-header.js expands on focus same
   as hover).
   The row's actual vertical padding lives on the alignwide group in parts/header.html as an
   inline style (spacing-30), not on .eo-sticky-header itself (which has none by default) — an
   earlier version of this rule set padding-block on .eo-sticky-header, which only added padding
   where there was none, making the condensed header *taller* while its contents shrank inside
   it. !important is required here since inline styles otherwise beat any stylesheet rule
   regardless of specificity. */
.eo-sticky-header .alignwide {
	transition: padding 300ms ease;
}

.eo-sticky-header.is-condensed .alignwide {
	padding-top: var(--wp--preset--spacing--10) !important;
	padding-bottom: var(--wp--preset--spacing--10) !important;
}

.eo-sticky-header .wp-block-site-logo img {
	transition: width 300ms ease, opacity 300ms ease;
}

.eo-sticky-header.is-condensed .wp-block-site-logo img {
	width: 96px;
}

.eo-sticky-header .wp-block-button__link {
	transition: min-height 300ms ease, padding 300ms ease, font-size 300ms ease, background-color 300ms ease, color 300ms ease;
}

.eo-sticky-header.is-condensed .wp-block-button__link {
	min-height: 40px;
	padding-inline: var(--wp--preset--spacing--30);
	font-size: var(--wp--preset--font-size--eyebrow);
}

/* Orange header state: the logo's own baked-in colors (orange icon, charcoal wordmark — see
   ecom-logo_full.svg) and the CTA button's default orange fill (theme.json's global button
   style) both clash with or disappear into an orange header background. sticky-header.js sets
   data-header-color="orange" specifically for this case (every other token just swaps the two
   --eo-header-bg/-fg custom properties above, no extra overrides needed).
   The logo itself is recolored via a separate masked overlay rather than anything applied
   directly to the <img> — an <img>'s own pixels can't be recolored by CSS mask-image on itself,
   only clipped by it, so a solid-fill element shaped by the logo (as a mask) sits on top instead,
   crossfading with the real logo via opacity. --eo-logo-mask-url is set in wp_head
   (inc/enqueue.php) from the real Site Logo file, not hardcoded. */
.custom-logo-link {
	position: relative;
	display: inline-block;
}

.custom-logo-link::after {
	content: "";
	position: absolute;
	inset: 0;
	background-color: var(--wp--preset--color--soft-white);
	-webkit-mask-image: var(--eo-logo-mask-url);
	mask-image: var(--eo-logo-mask-url);
	-webkit-mask-size: contain;
	mask-size: contain;
	-webkit-mask-repeat: no-repeat;
	mask-repeat: no-repeat;
	-webkit-mask-position: left center;
	mask-position: left center;
	opacity: 0;
	pointer-events: none;
	transition: opacity 300ms ease;
}

.eo-sticky-header[data-header-color="orange"] .custom-logo {
	opacity: 0;
}

.eo-sticky-header[data-header-color="orange"] .custom-logo-link::after {
	opacity: 1;
}

.eo-sticky-header[data-header-color="orange"] .wp-block-button__link {
	background-color: var(--wp--preset--color--charcoal);
	color: var(--wp--preset--color--soft-white);
}

/* max-height, not just max-width: the nav's own internal layout is flex-wrap (for genuine
   responsive wrapping at real-world widths), so squeezing it to max-width:0 alone makes its 4
   links wrap onto 4 stacked lines instead of disappearing — invisible (opacity:0) but still
   tall, inflating the header's height right back up. Capping height too (both clipped via the
   existing overflow:hidden) collapses it for real. */
.eo-sticky-header nav.wp-block-navigation {
	overflow: hidden;
	max-width: 600px;
	max-height: 200px;
	opacity: 1;
	transition: max-width 300ms ease, max-height 300ms ease, opacity 200ms ease;
}

.eo-sticky-header.is-condensed nav.wp-block-navigation {
	max-width: 0;
	max-height: 0;
	opacity: 0;
	pointer-events: none;
}

/* Reduced motion: kill loops/drifts/marquees, keep simple opacity fades only (§5). */
@media (prefers-reduced-motion: reduce) {
	.eo-marquee,
	.eo-carousel,
	.eo-rotate > * ,
	.eo-float,
	.eo-pulse {
		animation: none !important;
		transition: opacity 200ms ease !important;
		transform: none !important;
	}

	.eo-sticky-header,
	.eo-sticky-header::after,
	.eo-sticky-header .alignwide,
	.eo-sticky-header .wp-block-site-logo img,
	.eo-sticky-header .wp-block-button__link,
	.eo-sticky-header nav.wp-block-navigation,
	.eo-sticky-header .has-soft-white-color {
		transition: none;
	}
}

/* Motion starting states: elements JS will animate in are hidden via a class it toggles, not
   via CSS alone, so content stays visible with JS disabled (§5). */
.eo-reveal.eo-reveal-init,
.eo-reveal-stagger.eo-reveal-init > * {
	opacity: 0;
	transform: translateY(16px);
}

/* eo-shine: JS (src/js/motion/shine.js) injects the sweeping span; this just styles it. */
.eo-shine--active {
	position: relative;
	overflow: hidden;
}

.eo-shine__sweep {
	position: absolute;
	top: 0;
	left: 0;
	width: 30%;
	height: 100%;
	background: linear-gradient(
		100deg,
		transparent,
		rgba(255, 255, 255, 0.16),
		transparent
	);
	pointer-events: none;
}

/* eo-pulse: JS (src/js/motion/pulse.js) injects the expanding ring for the `--loop` variant. */
.eo-pulse--active {
	position: relative;
}

.eo-pulse__ring {
	position: absolute;
	inset: 0;
	border-radius: inherit;
	border: 2px solid currentColor;
	pointer-events: none;
}

/* eo-approach (the one eo-pin-steps section, CLAUDE.md §5 Showpiece budget: max 1/page).
   Reworked 2026-10-02 — see Decisions Log for what was broken before (pinned flush under the
   sticky header; every step crossfaded in the same grid cell instead of really stepping
   through).

   Default/no-JS/reduced-motion state: every rule below renders the *complete* state (every
   step is-done, fill full width, hook fully visible/undashed) with no media query or JS
   needed — src/js/motion/pin-steps.js is what resets to the upcoming state on init and drives
   the real scroll-based reveal from there, never the reverse. This is the same direction
   eo-reveal already uses (CSS visible by default; JS adds the hiding state), chosen for the
   same reason: content must be correct with JS disabled, and the only way to guarantee that is
   to make it the actual default rather than something to remember to fall back to. */
.eo-approach__stage {
	position: relative;
}

.eo-approach__steps {
	margin: 0;
	padding: 0;
	list-style: none;
}

.eo-approach__step {
	display: flex;
	flex-direction: column;
	align-items: flex-start;
	gap: var(--wp--preset--spacing--10);
}

.eo-step__dot {
	width: 16px;
	height: 16px;
	border-radius: 50%;
	flex: none;
	box-sizing: border-box;
	/* Wider than a typical dot-to-label gap on purpose: the hook loop (eo-approach__hook) needs
	   vertical room to curve back without crowding step 5's own number/heading underneath it —
	   this is the room it has to work with. Applied to all 5 dots uniformly rather than special-
	   casing the last one, so the row still reads as one consistent grid. */
	margin-bottom: var(--wp--preset--spacing--50);
	background-color: var(--wp--preset--color--orange);
	border: 2px solid var(--wp--preset--color--orange);
	transition: background-color 300ms ease, border-color 300ms ease, box-shadow 300ms ease;
}

.eo-approach__number {
	color: var(--wp--preset--color--orange);
	font-size: var(--wp--preset--font-size--eyebrow);
	font-weight: 700;
	letter-spacing: 0.08em;
}

.eo-approach__title,
.eo-approach__body {
	margin: 0;
	transition: color 300ms ease;
}

.eo-approach__title {
	font-size: var(--wp--preset--font-size--heading-3);
	color: var(--wp--preset--color--white);
}

.eo-approach__body {
	color: var(--wp--preset--color--steel);
}

/* pin-steps.js splits the body paragraph into one span per character once motion is confirmed
   allowed (no-JS/reduced-motion both keep the plain paragraph, no spans, nothing to style here).
   Each starts at opacity:0 inline, revealed in sequence to read as the step's content "typing
   out" once the timeline reaches it; the short transition softens each character's pop-in rather
   than an abrupt snap. */
.eo-approach__body span {
	transition: opacity 80ms linear;
}

/* Upcoming is the only state that visually differs from the shared default above (which already
   matches is-active/is-done) — only this one needs its own rules. */
.eo-approach__step.is-upcoming .eo-step__dot {
	/* Not transparent: the rail/fill line passes directly underneath each dot, and a truly
	   transparent center let it show through the ring's hollow middle. Matches the section's
	   own background (not a generic "transparent-looking" value) so it actually occludes it. */
	background-color: var(--wp--preset--color--charcoal);
	border-color: var(--wp--preset--color--iron);
	box-shadow: none;
}

.eo-approach__step.is-upcoming .eo-approach__title,
.eo-approach__step.is-upcoming .eo-approach__body {
	color: var(--wp--preset--color--slate);
}

/* The glow ring is a separate pseudo-element (not the box-shadow itself) animating only
   transform/opacity, per CLAUDE.md §5's motion rule — pulsing the box-shadow's own spread
   instead would mean animating a paint-only property outside that rule, where this reuses the
   same transform+opacity approach eo-pulse already uses elsewhere (just CSS-driven here, not
   GSAP, since it's a plain ambient loop with no scroll/interaction dependency). position:relative
   on the dot itself (not scoped to is-active) so it's already the ring's positioning context
   before the class transition even starts. */
.eo-step__dot {
	position: relative;
}

.eo-approach__step.is-active .eo-step__dot::after {
	content: "";
	position: absolute;
	inset: -6px;
	border-radius: 50%;
	background: color-mix(in srgb, var(--wp--preset--color--orange) 20%, transparent);
}

@media (prefers-reduced-motion: no-preference) {
	.eo-approach__step.is-active .eo-step__dot::after {
		animation: eo-approach-pulse 1.8s ease-in-out infinite;
	}
}

@keyframes eo-approach-pulse {
	0%,
	100% {
		transform: scale(1);
		opacity: 1;
	}

	50% {
		transform: scale(1.3);
		opacity: 0.4;
	}
}

/* Rail + fill: two divs (4px thick), not a stretched SVG (which would distort the stroke).
   Positioned in px by pin-steps.js from each dot's measured center, so they stay correct at any
   viewport width; width:100% here is only the no-JS/reduced-motion fallback ("fill at full
   width"). */
.eo-approach__track,
.eo-approach__fill {
	position: absolute;
	top: 8px; /* matches .eo-step__dot's own vertical center as a reasonable default; JS overrides precisely once it measures the real dots */
	height: 4px;
	left: 0;
}

.eo-approach__track {
	width: 100%;
	background: rgba(255, 255, 255, 0.12);
}

.eo-approach__fill {
	width: 100%;
	background: var(--wp--preset--color--orange);
	/* No width transition: pin-steps.js now sets this every scroll frame to track progress 1:1 —
	   a transition would make the bar perpetually ease toward a target that's already moved on,
	   reading as laggy rather than scroll-locked. (It used to jump once per discrete step, where
	   easing the jump looked intentional; that stepped model is gone.) */
}

/* The hook: visible and fully drawn (no dasharray set = a normal solid stroke) by default.
   pin-steps.js sets stroke-dasharray/-dashoffset from the path's own measured length and adds
   is-hidden once it takes over, only then driving the dash-offset reveal.
   left/top approximate dot 5's center without any measurement — a rough placement next to the
   right dot, not pixel-perfect centering (only matters with no JS or motion disabled;
   pin-steps.js overrides both with the real measured position whenever it runs). .eo-approach__
   steps is a narrower, centered strip within the stage (see the 1024px block below), so this
   isn't simply "4.5/5 columns = 90%" of the *stage* anymore — approximated for the ~84%-wide
   centered grid that leaves room for the hook to extend past it on the right, minus the path's
   own start-point offset within its 44x48 box (8,4 — see the `d` attribute in
   eo-approach.php) so that point, not the SVG's corner, lands there. */
.eo-approach__hook {
	position: absolute;
	left: calc(84% - 8px);
	top: 4px;
	transition: opacity 150ms ease-out;
}

.eo-approach__hook path {
	transition: stroke-dashoffset 500ms ease-out;
}

.eo-approach__hook.is-hidden {
	opacity: 0;
}

@media (max-width: 1023px) {
	/* Rebuilt 2026-10-15 — this used to be a plain horizontal scroll-snap row (no pin, no rail),
	   relying on the visitor noticing it was swipeable; client: "I am just scrolling right past
	   it" — a normal vertical scroll past the section did nothing, since nothing here responded
	   to vertical scroll at all. Now a vertical timeline, pinned while scrolling through it, same
	   core idea as desktop (a step goes active once the fill genuinely reaches its dot) just
	   without desktop's dot-row-centering/hook/end-of-pin-collapse tricks, none of which apply to
	   a vertical list that already fits a phone's own reading direction naturally. No hook, no
	   "Back to 01" chip either — those existed for the old horizontal row specifically. */
	.eo-approach__stage {
		position: relative;
	}

	.eo-approach__steps {
		display: flex;
		flex-direction: column;
		gap: var(--wp--preset--spacing--60);
	}

	/* Dot in its own column, spanning all 3 text rows, with the track/fill line (below) running
	   through its horizontal center — number/title/body stack in the second column, same content
	   as the desktop version, just read top-to-bottom next to the line instead of below a dot. */
	.eo-approach__step {
		display: grid;
		grid-template-columns: 16px 1fr;
		column-gap: var(--wp--preset--spacing--20);
		row-gap: var(--wp--preset--spacing--10);
	}

	.eo-step__dot {
		grid-column: 1;
		grid-row: 1 / span 3;
		margin-bottom: 0;
	}

	.eo-approach__number {
		grid-column: 2;
		grid-row: 1;
	}

	.eo-approach__title {
		grid-column: 2;
		grid-row: 2;
	}

	.eo-approach__body {
		grid-column: 2;
		grid-row: 3;
	}

	/* Vertical instead of horizontal — width/height swap roles from the shared base rule above.
	   left/top/height here are only the no-JS/reduced-motion fallback (full height, roughly
	   centered on the dot column); pin-steps.js overrides all three with the real measured
	   position once it runs, same as the desktop rail. */
	.eo-approach__track,
	.eo-approach__fill {
		left: 8px;
		top: 0;
		width: 4px;
		height: 100%;
	}

	.eo-approach__hook {
		display: none;
	}
}

@media (min-width: 1024px) {
	/* Fills the full viewport while pinned, so a taller window never shows a gap of the page's
	   own (lighter) background underneath the section once its content is shorter than the
	   viewport — the section's own charcoal fills that space instead. */
	.eo-pin-steps {
		min-height: 100vh;
	}

	.eo-approach__steps {
		display: grid;
		grid-template-columns: repeat(5, 1fr);
		gap: var(--wp--preset--spacing--40);
		position: relative;
		/* Narrower than the stage and centered, not edge-to-edge: the 5-dot row itself (what's
		   actually on screen nearly all the time) is what should read as centered, with room
		   held in reserve to its right for the hook to extend into once it appears — without
		   this, "centered" would have to account for the hook's extra width too, even though
		   it's only ever visible for a moment at the very end. */
		max-width: 85%;
		margin-inline: auto;
	}
}

/* eo-results-grid: 5 stats at a fixed columnCount:5 (not minimumColumnWidth, so WP's core grid
   support doesn't auto-collapse this on its own) would squeeze 5 columns into a phone-width
   screen. 2-up reads fine with an odd one left on its own row. !important to beat core's
   block-supports grid-template-columns rule, which carries the same (single-class) specificity
   and isn't guaranteed to load before this stylesheet. */
@media (max-width: 781px) {
	.eo-results-grid {
		grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
	}
}

/* eo-calendar: the booking scheduler iframe. src/js/motion/calendar-embed.js lazy-loads the
   real src once this scrolls into view (CLAUDE.md §9) — min-height is reserved up front so
   nothing shifts when it does. soft-white while loading (a neutral placeholder surface behind
   the "Loading calendar…" text); switched to pure white once .is-loaded — the real LeadConnector
   widget's own <body>/<html> are transparent and its actual white card doesn't always fill the
   iframe's full reserved height, so soft-white was visibly bleeding through as grey bands above
   and below the card once loaded (reported live, confirmed by inspecting the iframe's own
   computed background directly: transparent, not grey — the grey was ours, showing through). */
.eo-calendar {
	position: relative;
	min-height: 700px;
	border-radius: 20px;
	overflow: hidden;
	background-color: var(--wp--preset--color--soft-white);
}

.eo-calendar.is-loaded {
	background-color: var(--wp--preset--color--white);
}

.eo-calendar__frame {
	display: block;
	width: 100%;
	height: 700px;
	border: none;
}

.eo-calendar__loading {
	position: absolute;
	inset: 0;
	display: flex;
	align-items: center;
	justify-content: center;
	color: var(--wp--preset--color--slate);
	font-size: var(--wp--preset--font-size--eyebrow);
}

.eo-calendar.is-loaded .eo-calendar__loading {
	display: none;
}

/* Styled independently of the eo/book-cta block's own (conditionally-loaded) stylesheet, since
   this needs to work on pages that reference this pattern without that block also being present. */
.eo-calendar__fallback {
	display: inline-flex;
	align-items: center;
	justify-content: center;
	min-height: 56px;
	padding-inline: var(--wp--preset--spacing--40);
	border-radius: var(--wp--custom--radius--pill);
	background-color: var(--wp--preset--color--orange);
	color: var(--wp--preset--color--soft-white);
	font-weight: 700;
	text-decoration: none;
}

/* eo-problem: two columns at desktop (left = the claim, right = the four symptoms), stacking
   left-first below 900px — a breakpoint and a gap (96px / 40px) that don't match any existing
   spacing token, so they're literal values here rather than forced onto the nearest preset. */
.eo-problem__columns {
	display: grid;
	grid-template-columns: 1fr 1fr;
	align-items: start;
	gap: 96px;
}

@media (max-width: 899px) {
	.eo-problem__columns {
		grid-template-columns: 1fr;
		gap: 40px;
	}
}

/* Pull quote: a bolder, dark-text line with a left rule, not an italic blockquote — the one
   accent color in this otherwise-monochrome block. The rule is a pseudo-element (decorative),
   not a literal <hr>, per the no-separate-semantic-meaning intent. */
.eo-problem__pull {
	font-weight: 700;
	padding-left: 20px;
	border-left: 3px solid var(--wp--preset--color--orange);
}

.eo-problem__list {
	margin: 0;
	padding: 0;
	list-style: none;
}

.eo-problem__item {
	display: flex;
	gap: var(--wp--preset--spacing--20);
	padding-block: 22px;
	border-bottom: 1px solid var(--wp--preset--color--line);
}

.eo-problem__item:last-child {
	border-bottom: none;
}

.eo-problem__number {
	flex: 0 0 32px;
	/* Changed iron (grey) -> orange 2026-10-16 per client request while reviewing the live site —
	   !important for the same reason as every other custom-component color this round: a bare
	   span with no has-X-color class is otherwise caught by this file's own
	   p/h1-h6:where(:not([class*="-color"])) safety-net rule (it isn't one of those tags, so not
	   actually affected by that one) and, more importantly, by Elementor's own leftover kit CSS on
	   the live production site, which applies directly to bare elements regardless of theme. */
	color: var(--wp--preset--color--orange) !important;
	font-weight: 700;
}

.eo-problem__item-body {
	display: flex;
	flex-direction: column;
	gap: var(--wp--preset--spacing--10);
}

.eo-problem__item-title {
	margin: 0;
	font-size: var(--wp--preset--font-size--heading-3);
}

.eo-problem__item-text {
	margin: 0;
	color: var(--wp--preset--color--iron);
}

/* Generic "no real asset yet" box — dashed border + centered bracketed label, deliberately not
   a stock photo (CLAUDE.md build-brief rule). Named generically (not eo-about-*) since any future
   page needing the same "photo coming later" treatment can reuse it as-is. */
.eo-photo-placeholder {
	aspect-ratio: 4 / 5;
	display: flex;
	align-items: center;
	justify-content: center;
	border: 1px dashed var(--wp--preset--color--line);
	border-radius: var(--wp--custom--radius--card);
	background-color: var(--wp--preset--color--soft-white);
}

.eo-photo-placeholder__label {
	color: var(--wp--preset--color--slate);
	font-size: var(--wp--preset--font-size--body);
	text-align: center;
	padding-inline: var(--wp--preset--spacing--30);
}

/* eo-service-tiers: a 2x2 card grid — same card shape as the homepage services grid /
   eo-about-principles (border, radius, white-on-soft-white) rather than a new one. Was stacked
   full-width rows at first (the original build brief's own reasoning: "the copy is too long for
   cards"), changed to a grid per client feedback once there was no alternating visual column
   left to justify the full-width rows. */
.eo-service-tier {
	display: flex;
	flex-direction: column;
	gap: var(--wp--preset--spacing--20);
	padding: var(--wp--preset--spacing--40);
	border: 1px solid var(--wp--preset--color--line);
	border-radius: var(--wp--custom--radius--card);
	background-color: var(--wp--preset--color--white);
}

.eo-service-tier h3 {
	margin: 0;
}

.eo-service-tier p {
	margin: 0;
}

/* The $697 diagnostic price is the only one that renders anywhere on the site (CLAUDE.md §7) —
   styled as a bordered chip, not body copy, so its one-off status reads as intentional rather
   than like a label that's merely missing from the other three tiers. */
.eo-service-tier__price {
	display: inline-block;
	align-self: flex-start;
	padding: var(--wp--preset--spacing--10) var(--wp--preset--spacing--30);
	border: 1px solid var(--wp--preset--color--line);
	border-radius: var(--wp--custom--radius--pill);
	color: var(--wp--preset--color--charcoal);
	font-weight: 700;
}

/* Standard WordPress convention (not previously needed in this theme — every other template has
   a real, visible H1 of its own). Added for page-book-a-call.html (2026-10-14), once removing its
   visible charcoal intro band left the page with no H1 at all — "one H1 per page" (CLAUDE.md §9)
   still needs one for a11y/SEO, just not a visible one the client asked to remove. */
.screen-reader-text {
	position: absolute;
	width: 1px;
	height: 1px;
	margin: -1px;
	padding: 0;
	overflow: hidden;
	clip: rect(0, 0, 0, 0);
	white-space: nowrap;
	border: 0;
}

/* WordPress core's own grid layout (columnCount) never collapses to fewer columns on its own —
   there's no built-in responsive behavior, it's a fixed column count at every viewport width
   unless a theme adds its own override. These two 2-up grids read as squished side by side on
   mobile (client, 2026-10-15) — stack to one column below the same 899px breakpoint
   eo-problem/eo-about-story already use for their own 2-column layouts. */
@media (max-width: 899px) {
	.eo-services__grid,
	.eo-service-tiers__grid {
		grid-template-columns: 1fr;
	}
}
