/* ─────────────────────────────────────────────────────────
   eSIM Store — shared components.css (Voyager)
   Loaded AFTER tokens.css on every page.
   Each block here corresponds to a WP/WC block or custom block;
   classnames mirror the block name (es-{component}).
   ───────────────────────────────────────────────────────── */

/* ─── Layout primitives ─────────────────────────────────── */
.es-page { background: var(--color-base); min-height: 100vh; display: flex; flex-direction: column; }
.es-main  { flex: 1; }
.es-container { max-width: var(--container-max-width); margin: 0 auto; padding: 0 var(--container-gutter); }

/* ─── The reading measure, and why it is a GRID ──────────────────────────────
 * ESM-99 / ESM-100 / ESM-101 — three cards, one cause.
 *
 * Long-form text is set at `--measure-prose` (44rem). That is correct and is
 * not what the test team reported. What was wrong is that the measure was
 * pinned to the LEFT of the 1168px rail, so every legal page, /about/ and the
 * signed-out account screen rendered a 704px column with ~460px of dead space
 * running down the whole right-hand side. Measured on the deployed 0.16.0 at
 * 1440: h1 and every paragraph at x=136 w=704 inside a rail of x=136…1304.
 *
 * The cause is a habit, not a page:
 *
 *     .es-legal-body { max-width: 44rem; margin: 2.75rem 0 0; }
 *
 * Three values in `margin` are top / RIGHT / bottom — the left and right
 * margins become 0, and the auto-centring a constrained layout would have
 * given is gone. `max-width` alone has never centred anything. Six rules in
 * two files carried that shape. It is the ESM-68 family: there a shorthand
 * `padding: 16px` zeroed the 48px left reserve an absolutely positioned radio
 * needed, and the radio covered its own label. A shorthand quietly setting the
 * sides to 0 and destroying a value that mattered.
 *
 * THE CENTRING IS THEREFORE DONE BY THE PARENT, AS A GRID TRACK, and by no
 * margin on any child. That is the whole point of the construction: a child's
 * own `margin: 2rem 0 0` cannot move a grid track. The next person to write
 * that shorthand — and someone will — gets an unwanted top margin at worst,
 * never this defect back. The obvious alternative,
 *
 *     .x { max-width: var(--measure-prose); margin-inline: auto; }
 *
 * does NOT have that property: it loses to any later shorthand at equal
 * specificity, which is precisely how the defect arrived in the first place.
 *
 * Two tracks. Everything is prose by default; anything that is NOT prose — a
 * figure row, a wide table, a media strip — says so with `.es-rail-wide` and
 * takes the full rail. That is a per-element judgement, not a page-wide one.
 * ─────────────────────────────────────────────────────────────────────────── */
.es-measure-grid {
  display: grid;
  /* `minmax(0, …)` on all three tracks: a grid track's automatic minimum is
     min-content, which would refuse to shrink below a long unbroken string and
     push the layout wider than the rail on a phone. */
  grid-template-columns:
    [rail-start] minmax(0, 1fr)
    [measure-start] minmax(0, var(--es-measure, var(--measure-prose))) [measure-end]
    minmax(0, 1fr) [rail-end];
}
/* `--es-measure` is the per-surface override, and it is read HERE — inside the
   `grid-template-columns` value — on purpose. Wrapping the whole track list in
   its own custom property would resolve the inner `var()` on :root instead of
   on the element that uses it, and every surface would silently get 44rem. */
/* `width: 100%` is not decoration. WordPress's constrained-layout rule sets
   `margin-left/right: auto !important` on the direct children of any
   `.is-layout-constrained` element, and `main.es-account-page` is one. On a
   grid item, auto margins absorb the free space in the track — so the "My
   account" h1 shrink-wrapped to 213px and centre-ALIGNED itself in the 736px
   track while the form below it filled the track. Two different left edges,
   which is the thing ESM-72 correctly objected to. Giving the item the full
   track leaves the auto margins no free space to take, and needs no
   `!important` of its own to do it. */
.es-measure-grid > *             { grid-column: measure; width: 100%; }
.es-measure-grid > .es-rail-wide { grid-column: rail; }

.es-section { padding: var(--spacing-70) 0; }
.es-section--tight { padding: var(--spacing-60) 0; }
/* ESM-70: `--alt` / `--ink` deleted, with `.es-card`, `.es-mono`, `.es-shimmer`
   and `.es-pulse-dot` — six unreferenced utilities. The dark bands the mock used
   `--ink` for are `.hp-how`, `.es-install-stepper` and `.ed-final`. */
.es-section h2 { font-size: var(--font-size-xl); font-weight: 600; letter-spacing: var(--letter-spacing-tight); }
.es-eyebrow { display: inline-block; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--color-contrast-faint); font-weight: 600; }

/* ─── Wordmark / logo ───────────────────────────────────── */
/*
 * Styled via render_block filter in inc/logo-filter.php.
 * The site-title block wraps the anchor; we inject:
 *   esim<span class="es-logo-dot">·</span>store
 *
 * .es-logo-wordmark is the className on the core/site-title block
 * in parts/header.html — it sets the base typography; the inner
 * spans control the separator dot color.
 */
.es-logo-wordmark { font-family: var(--font-family-heading); font-weight: 700; font-size: 18px; letter-spacing: -0.03em; line-height: 1; text-decoration: none; color: var(--color-contrast); }
.es-logo-wordmark a { color: inherit; text-decoration: none; }
.es-logo-text { display: inline; }
.es-logo-dot { color: var(--color-accent-1); font-size: 1em; margin: 0 0.5px; }

/* ─── Header / footer / brand: NOT IN THIS FILE (ESM-70, ADR-011) ────────────
 * The mock's `.es-hdr*`, `.es-nav*`, `.es-kbd`, `.es-foot*` and `.es-brand*`
 * were deleted — no markup has ever emitted them. The live header, footer and
 * wordmark are `.es-site-header`, `.es-site-footer` / `.es-footer-*` and
 * `.es-logo-*`, all in theme-bridge.css. In particular the scroll shadow is
 * `.es-site-header.is-scrolled`; the `.es-hdr.is-scrolled` copy that used to be
 * here never fired. `.es-cart-trigger` / `.es-cart-count` went earlier, in
 * ESM-50, with the theme's own cart drawer. */

/* ─── Buttons ───────────────────────────────────────────── */
.es-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; font: inherit; font-weight: 600; text-decoration: none; cursor: pointer; border: 0; transition: background var(--transition-duration), transform var(--transition-duration), box-shadow var(--transition-duration); }
.es-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.es-btn-primary { background: var(--color-contrast); color: var(--color-base); font-size: 14px; padding: 10px 18px; border-radius: var(--radius-full); min-height: 40px; }
.es-btn-primary:hover { background: #1A2538; }
.es-btn-cta { background: var(--color-accent-1); color: #FFFFFF; font-size: 15px; padding: 14px 24px; border-radius: var(--radius-full); min-height: 48px; }
.es-btn-cta:hover { background: var(--color-accent-1-hover); }
.es-btn-cta--lg { font-size: 16px; padding: 16px 28px; min-height: 56px; }
.es-btn-ghost { background: var(--color-base-elevated); color: var(--color-contrast); border: 1px solid var(--color-neutral); font-size: 14px; padding: 10px 18px; border-radius: var(--radius-full); min-height: 40px; }
.es-btn-ghost:hover { background: var(--color-base); border-color: var(--color-neutral-strong); }
.es-btn-link { background: transparent; color: var(--color-accent-1-hover); font-size: 14px; font-weight: 600; padding: 6px 0; border-radius: 4px; }
.es-btn-link:hover { text-decoration: underline; text-underline-offset: 3px; }

/* ─── Hero search input ─────────────────────────────────── */
.es-search { display: flex; align-items: center; gap: 8px; background: var(--color-base-elevated); border: 1px solid var(--color-neutral); border-radius: var(--radius-full); padding: 8px 8px 8px 22px; box-shadow: var(--shadow-md); max-width: 540px; }
.es-search:focus-within { border-color: var(--color-accent-1); box-shadow: var(--shadow-md), var(--shadow-focus); }
.es-search svg { color: var(--color-contrast-faint); }
.es-search input { flex: 1; border: 0; background: transparent; font: inherit; font-size: 16px; outline: none; padding: 12px 0; color: var(--color-contrast); min-width: 0; }
.es-search input::placeholder { color: var(--color-contrast-faint); }

/* ─── Country tile ──────────────────────────────────────── */
/* ESM-70: the mock's tile shell (`.es-tile` and its `-inner` / `-top` /
 * `-pergb` / `-bottom` / `-meta` / `--wide` children) is gone — the theme
 * renders `.es-country-tile`, styled in theme-bridge.css. The three leaves
 * below survived the rename and ARE emitted, by patterns/country-tile.php and
 * inc/helpers-product-card.php. The per-country tint `.es-tile-<cc>`
 * (inc/tile-tint.php) is a different thing again and is untouched. */
/* ─── The flag ────────────────────────────────────────────
 *
 * ESM-91 — THE FLAG IS A PICTURE AGAIN. Two elements can carry
 * `.es-tile-flag` and they are sized differently, so the box rules are split
 * from the two variants deliberately rather than by accident:
 *
 *   img.es-tile-flag         a 36×24 SVG from OUR media library, on 160 of the
 *                            173 catalog tiles
 *   .es-tile-flag--glyph     the emoji character, on the other 13 — the
 *                            destinations Keepgo publishes no flag for because
 *                            they have no country-type bundle
 *
 * WHY THIS CHANGED. ESM-89 restored the mock's 36×24 BOX with `min-width`, but
 * a box is not a picture. Measured from rendered pixels on the deployed page —
 * not from canvas font metrics, which return font metrics rather than ink for a
 * colour emoji, the error that produced a wrong number in this very comment
 * once already:
 *
 *     glyph  box 36×24, drawn ink 26×19  →  57% of the box
 *     img    box 36×24, drawn ink 36×24  →  100% of the box, as the mock had
 *
 * The gap could not be closed with `font-size`: an emoji's ink is ~0.895 of its
 * advance, so ink 36px wide needs a ~40px box, and 36 is what the frozen
 * `tile-flag-wh` expectation pins. A picture closes it exactly, and costs
 * nothing external — the files are on this domain, so ESM-89's zero-foreign-
 * hosts guarantee is untouched.
 *
 * The `box-shadow` and `border-radius` come BACK on the image variant. ESM-89
 * removed them for a good reason that no longer holds: behind a glyph they
 * framed nothing and Chromium painted them as a raised near-white card with the
 * character spilling out of it. Behind a 36×24 graphic they frame the graphic,
 * which is what deliverables/design/components.css `.es-tile-flag` always
 * intended. `object-fit: cover` is likewise no longer inert — though for these
 * particular files it is a no-op that earns its place as a guard: the flags are
 * 3:2 (`viewBox="0 0 513 342"`, natural 225×150) and the box is 3:2, so cover
 * and contain agree today and cover is what keeps a differently-proportioned
 * future flag from letterboxing.
 *
 * The glyph variant keeps every ESM-89 decision, and the reasoning is still
 * live because the fallback is still rendered:
 *
 * · `min-width`, not `width`. With `width: 36px` the two regional-indicator
 *   LETTERS a no-flag-glyph platform draws ("JP") wrap onto a second line inside
 *   a 24px-tall box as soon as text is scaled up (at 40px: text box 25.6×75 over
 *   two line boxes). With `min-width` the box grows to 40.9 and stays on one
 *   line. `scrollWidth` cannot see that — the element's overflow is visible, so
 *   Chromium clamps scrollWidth to clientWidth and reports a spilling box as
 *   fitting.
 * · The 24px line box, not the natural 28px. At 375 a 28px line box grew
 *   `.es-tile-header` by 4.2px and pushed `.es-tile-name` down 2.5px.
 * · `text-align: start`, which stopped being a non-choice once the box became
 *   wider than the glyph. index.html settles it: the flag <img>, `.es-tile-name`
 *   below it and `.es-tile-inner` all begin at x=152 — the flag sits flush with
 *   the tile's content rail, not centred in it.
 *
 * This is the ONLY place `.es-tile-flag` is sized. theme-bridge.css used to
 * restate font-size/line-height and would have silently overridden the line
 * height above, since it loads later at equal specificity. */
.es-tile-flag { flex: 0 0 auto; min-width: 36px; height: 24px; }
img.es-tile-flag { display: block; width: 36px; height: 24px; border-radius: 4px; box-shadow: 0 1px 3px rgba(0,0,0,0.18); object-fit: cover; }
.es-tile-flag--glyph { font-size: 28px; line-height: 24px; text-align: start; }
.es-tile-name { font-family: var(--font-family-heading); font-weight: 600; font-size: 20px; letter-spacing: -0.02em; line-height: 1.1; }
/* ESM-49: 0.78 → 0.90. A tile's text colour is the per-country `fg` from
   inc/tile-tint.php, which is verified at ≥4.5:1 against its `bg` AT FULL
   ALPHA. Every point of opacity spends that margin: on the worst tint
   (#FBE5C5 / #7A4D17) 0.78 leaves 3.70:1. 0.90 is the lowest value that keeps
   all 15 tints ≥4.5:1 (worst 4.76:1). `strong { opacity: 1 }` below is a no-op
   — a child cannot exceed its parent's opacity — so this value governs the
   price too. */
/* ESM-90 — `white-space: nowrap` is the invariant, not a layout tweak: a price
 * is a NUMBER, and a number that wraps inside itself is not a smaller price, it
 * is a broken page. On 2026-08-07 the catalog rendered "$0.6" on one line and
 * "0" on the next, on 172 of 173 tiles at 375. Restoring the mock's composition
 * (the per-GB chip moved back out of the footer — see
 * inc/helpers-product-card.php) is what made the price fit again; this rule is
 * what guarantees it can never split at some width nobody tested. Both, not
 * either: the composition fixes today's catalog, the invariant fixes the next
 * one. Stated on the wrapper AND on the value because `strong` is
 * `display: block` and inherits `white-space` — belt only, cheap. */
.es-tile-price { font-size: 12.5px; opacity: 0.9; white-space: nowrap; }
.es-tile-price strong { display: block; font-family: var(--font-family-heading); font-weight: 700; font-size: 20px; opacity: 1; letter-spacing: -0.02em; white-space: nowrap; }

/* ESM-70: the mock's `.es-region-card*` family is gone — patterns/region-card.php
 * renders the homepage dictionary (`.hp-regional` / `.hp-region*`, pages.css).
 * Not to be confused with `.es-region-{slug}` in theme-bridge.css, which is
 * live: it tints the region chip, and its name is built from the term slug. */

/* ─── Badge ──────────────────────────────────────────────── */
.es-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: var(--radius-sm); font-size: 11px; font-weight: 600; font-family: var(--font-family-mono); letter-spacing: 0.04em; text-transform: uppercase; line-height: 1.4; }
.es-badge-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
/* ESM-70: `--lifetime`, `--featured` and `--local` deleted — no markup asks for
   them. `--regional` and `--global` stay: patterns/region-card.php builds the
   modifier at runtime (`es-badge--<global|regional>`), so both are reachable
   and `--global` renders whenever the global bundle is one of the two biggest
   regions. The plan card's own badges are `.es-pc-badge-*` in theme-bridge.css. */
.es-badge--regional { background: var(--color-region-europe-soft); color: var(--color-region-europe); }
.es-badge--global { background: var(--color-region-global-soft); color: var(--color-region-global); }

/* ESM-70: two mock components deleted, neither the one the theme renders.
 * `.es-hotspot*` → the live badge is `.es-hotspot-badge` in theme-bridge.css,
 * where the same modifiers live scoped to it and stay (they are unreached, not
 * dead — Keepgo publishes no tethering field). `.es-status*` → the live pill is
 * `.es-status-pill.is-{state}`, also theme-bridge.css; the uncalled helper that
 * built the `--` form went with these rules. */

/* ─── Callout (activation timing, info, warning) ───────── */
.es-callout { display: flex; gap: 14px; padding: 16px 20px; border-radius: var(--radius-lg); border: 1px solid; font-size: 14.5px; line-height: 1.5; }
.es-callout-icon { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center; font-family: var(--font-family-heading); font-weight: 700; flex: 0 0 auto; }
.es-callout-body strong { font-weight: 600; }
/* ESM-70: the double-dash callout modifiers (`.es-callout--warning` / `--info` /
   `--success`) are gone — every callout in the theme emits the SINGLE-dash form,
   which theme-bridge.css defines in full. */

/* ESM-70: the mock's `.es-plan` card was deleted with its twelve children. The
 * buy surface is `.es-plan-card` / `.es-pc-*` (theme-bridge.css), and this
 * catalog has no "best value" state for the ribbon to mark. */

/* ─── Filter sidebar ────────────────────────────────────── */
/* ESM-70 — only `.es-filter-group` survived. `.es-filters`, `.es-filter-h`,
 * `.es-chip*`, `.es-check*`, `.es-toggle*`, `.es-range*` were the mock's names;
 * patterns/catalog-filters.php emits `.es-catalog-filters` / `.es-filter-label`
 * / `-chips` / `-chip` / `-reset`. Careful with the toggle: theme-bridge.css
 * KEEPS `.es-filter-toggle` / `.es-toggle-label` / `-input` / `-track` / `-text`
 * as a deliberate reserve (ESM-39) — the `.es-toggle-switch` spelling deleted
 * here is a third vocabulary that matched neither markup. */
.es-filter-group { padding: 16px 0; border-bottom: 1px solid var(--color-neutral); }
.es-filter-group:first-child { padding-top: 0; }
.es-filter-group:last-child { border-bottom: 0; padding-bottom: 0; }

/* ─── Tabs ──────────────────────────────────────────────── */
/* ESM-70: `.es-tabs` / `.es-tab` / `.es-tab--on` deleted — the segmented control
   is `.es-catalog-tabs` / `.es-catalog-tab` (theme-bridge.css).
   `.es-tab-count` survived the rename and is emitted by
   patterns/catalog-tabs.php, so it stays.

   ESM-49: 0.70 → 0.90. The count inherits `--color-contrast-muted` from
   `.es-catalog-tab`; at 0.70 on the white pill that renders 3.11:1. 0.90 gives
   4.73:1 on white and 13.32:1 on the active (dark) tab, so one value serves
   both grounds and the count stays quieter than its label. */
.es-tab-count { font-family: var(--font-family-mono); font-size: 11px; opacity: 0.9; }

/* ─── Breadcrumbs ───────────────────────────────────────── */
.es-crumbs { font-size: 13px; color: var(--color-contrast-muted); padding: 16px 0; display: flex; gap: 8px; align-items: center; }
.es-crumbs a { color: inherit; text-decoration: none; }
.es-crumbs a:hover { color: var(--color-contrast); }
/* ESM-70: `.es-crumbs-sep` / `-current` deleted; every trail renders
   `.es-breadcrumb-sep` / `-current` (below). `.es-crumbs` itself stays —
   patterns/page-how-it-works.php puts it alongside `.es-breadcrumb`. */

/* pdp-hero.php uses .es-breadcrumb / .es-breadcrumb-list */
.es-breadcrumb { font-size: 13px; margin-bottom: 18px; }
.es-breadcrumb-list { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.es-breadcrumb-list li { display: inline-flex; align-items: center; }
/* ESM-49: the trail's links were muted with `opacity: 0.7` on top of
   `--color-contrast-muted`, which renders 3.00:1 on cream and 2.70:1 on a
   tinted PDP hero. The hover affordance is now an underline instead of the
   opacity step, so the link colour is the token at full alpha (5.57:1 cream /
   5.95:1 white / ≥5.90:1 on every tint via the hero rule in theme-bridge.css).
   The separator's own 0.45 is gone for the same reason; its size and colour are
   set by theme-bridge.css. */
.es-breadcrumb-list a { color: inherit; text-decoration: none; }
.es-breadcrumb-list a:hover { text-decoration: underline; text-underline-offset: 3px; }
.es-breadcrumb-sep { font-size: 11px; padding: 0 2px; }
.es-breadcrumb-list [aria-current="page"] { font-weight: 600; opacity: 0.95; }

/* ESM-69 — the four rules that styled the footer's PCI / Visa / Mastercard /
 * SSL badge strip were DELETED here, not commented out, and so was the design
 * pointer in patterns/footer-bottom.php that named them as its exemplar.
 *
 * They were never used: the classes appeared in no theme markup, in neither
 * plugin, and on none of the rendered pages. That alone would only have made
 * them dead code. What makes them wrong is that they were dead code waiting on
 * a condition that will never be true — this site is not getting a live card
 * gateway (Andrew, 2026-07-30; `forge-test-gateway` is the only payment
 * method). The badge set is therefore not deferred to launch, it is
 * inapplicable, so standard-stack-v1.md §10's "mandatory at launch" premise —
 * "every Forge site launches with a live card gateway" — does not hold here.
 *
 * This was the fourth separate trace of a payment stack the site will not have
 * (mock artwork, footer slot, checkout rules in pages.css, then these). Each
 * one cost someone a fresh investigation into whether it was an oversight or a
 * decision. It was a decision; it is recorded in websites/esim-store/CLAUDE.md
 * §Plugins and it is now recorded here, so the next reader stops at the answer.
 *
 * The footer's go-live slot itself survives in patterns/footer-bottom.php,
 * still empty and still explaining why. If a real card gateway is ever enabled,
 * the badges get designed then — against whatever the payment stack actually
 * turns out to be, not against a 2026 mock of one we declined.
 */

/* ─────────────────────────────────────────────────────────
   ANIMATION ENGINE
   Reveal-on-scroll, scroll progress, staggered children, magnetic CTA,
   country-tile flag parallax, order-confirmation QR scan beam.
   All respect prefers-reduced-motion via animations.js (it skips
   transforms entirely when reduce is on; CSS classes still apply
   but stay at their resting state).

   ESM-70 found that two of those — the tile parallax and the QR beam — had
   never run once: animations.js queried the MOCK's class names (`.es-tile`,
   `.oc-qr-canvas-wrap`) while the theme renders `.es-country-tile` and
   `.es-qr-image-wrap`, so both selectors matched zero elements from the first
   commit. ESM-70 deleted their CSS and left the JS annotated; ESM-86 finished
   the job — both are retargeted at the classes the theme emits and their CSS
   is back, written against those names. The third hook it found, `[data-tilt]`,
   is gone for good: it had no host element in the theme OR in the design set.
   ADR-011.
   ───────────────────────────────────────────────────────── */

/* Scroll progress bar */
.es-scroll-progress {
  position: fixed; top: 0; left: 0; right: 0; height: 3px;
  background: linear-gradient(90deg, var(--color-accent-1), var(--color-accent-2));
  transform: scaleX(0); transform-origin: left center;
  z-index: calc(var(--z-header-sticky) + 5);
  pointer-events: none;
  transition: transform 80ms linear;
}

/* Reveal on scroll */
[data-reveal] {
  opacity: 0;
  transform: translateY(18px);
  will-change: opacity, transform;
}
[data-reveal].es-revealed {
  animation: es-reveal-in 700ms cubic-bezier(0.2, 0.7, 0.2, 1) var(--es-stagger-delay, 0ms) both;
}
@keyframes es-reveal-in {
  from { opacity: 0; transform: translateY(18px); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  [data-reveal] { opacity: 1; transform: none; animation: none !important; }
  .es-scroll-progress { display: none; }
}

/* Country-tile flag parallax. The transform itself is written by animations.js
   on `mousemove` over `.es-country-tile`; this is only the easing that turns a
   per-event jump into a follow. Live since ESM-86 — before that the JS queried
   the mock's `.es-tile` and this transition smoothed a transform that was never
   set.
   `will-change` is deliberate and paid for: the flag is the ONLY node that
   moves, so the promoted layer is 36×24, not the tile. */
.es-tile-flag { transition: transform 200ms cubic-bezier(0.2, 0.7, 0.2, 1); will-change: transform; }
@media (prefers-reduced-motion: reduce) {
  /* Belt and braces. animations.js never registers the mousemove handler when
     reduce is on, so there is nothing to suppress — unless a future edit moves
     the effect out of that guard. Then the flag stops at its resting position
     instead of jumping, and the transition does not fight the reset. */
  .es-tile-flag { transition: none; transform: none !important; }
}

/* QR scan beam — order confirmation only.
   Ported from the mock's `.oc-qr-canvas-wrap` rules (deliverables/design/
   components.css, `@keyframes oc-scan`) onto the class the theme renders.
   Three deliberate changes from the mock:
   1. `position` / `overflow` are applied ONLY while the beam runs. Outside the
      2.4s window the wrap is untouched, so `overflow: hidden` can never clip
      the focus ring of the "Save QR" button that lives inside it.
   2. The sweep is container-relative (`top` 0→100% against a beam translated
      by its own height) instead of the mock's hard `translateY(360px)`. The
      live wrap is 418.5px tall at 1440 and shorter at 375; a fixed 360 would
      stop short on one and overshoot on the other.
   3. The colour is the CURRENT accent, rgb(53,116,93) = #35745D, and the alpha
      is 0.32 rather than the mock's 0.45. The mock's rgba(63,138,110) is the
      pre-ESM-64 accent this site retired; the alpha is lowered because this
      beam passes over a QR code that a customer may be pointing a camera at.
      0.32 reads as a sweep and still leaves the modules well inside a scanner's
      contrast budget. (rgba of the accent, rather than a token, follows the
      existing precedent in theme-bridge.css — `.es-oc-tick`'s shadow is the
      same colour written the same way; CSS has no alpha form of a var() hex.)
   The beam is decoration, not status: it plays once, on scroll-into-view, and
   the card's real activation state is the `.es-status-pill` next to it. */
.es-qr-image-wrap.es-qr-scanning { position: relative; overflow: hidden; }
.es-qr-image-wrap.es-qr-scanning::after {
  content: ""; position: absolute; left: 0; right: 0; top: 0;
  height: 36px;
  background: linear-gradient(180deg,
    rgba(53, 116, 93, 0) 0%, rgba(53, 116, 93, 0.32) 50%, rgba(53, 116, 93, 0) 100%);
  box-shadow: 0 0 24px rgba(53, 116, 93, 0.55);
  pointer-events: none;
  opacity: 0;
  animation: es-qr-scan 2.1s cubic-bezier(0.6, 0, 0.4, 1) 1;
}
@keyframes es-qr-scan {
  0%   { top: 0;    transform: translateY(-100%); opacity: 0; }
  10%  { opacity: 1; }
  90%  { opacity: 1; }
  100% { top: 100%; transform: translateY(0);     opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .es-qr-image-wrap.es-qr-scanning { position: static; overflow: visible; }
  .es-qr-image-wrap.es-qr-scanning::after { content: none; animation: none; opacity: 0; }
}

/* ESM-70 deleted the `[data-tilt]` transition; ESM-86 deleted the JS loop that
   went with it — the attribute has no host element in this theme or in the
   design set it was ported from. `[data-magnetic]` below stays: it IS emitted,
   by patterns/hero-homepage.php. */

/* Magnetic CTA — smooth follow */
[data-magnetic] { transition: transform 200ms cubic-bezier(0.2, 0.7, 0.2, 1) !important; will-change: transform; }

/* ESM-70: the pulse ring (`.es-pulse-dot`, `@keyframes es-pulse`) and the
   eyebrow shimmer (`.es-shimmer`, `@keyframes es-shimmer`) are gone — neither
   class appears in any markup. The live indicator's own pulse is
   `.es-live-indicator-dot` / `@keyframes es-live-pulse` below, which is
   rendered and is untouched. */

/* ─────────────────────────────────────────────────────────
   GLOBE — WebGL canvas styles + tooltip
   ───────────────────────────────────────────────────────── */
.es-globe { position: relative; width: 100%; aspect-ratio: 1; min-height: 420px; }
.es-globe canvas { display: block; width: 100% !important; height: 100% !important; }
/*
 * ESM-18 — `min-height: 420px` together with `aspect-ratio: 1` gives this element
 * an automatic MINIMUM WIDTH of 420px: the ratio transfers the height floor onto
 * the inline axis. In the hero's single-column grid below the tablet breakpoint
 * the content rail is 335px (375 − 2×20 gutter), so the whole hero track was
 * pushed to 420 and the page scrolled sideways by 65px
 * (documentElement.scrollWidth 440 vs viewport 375). The aspect ratio alone
 * already gives the globe a square box off the rail width at those sizes, so the
 * floor is only needed from 768 up, where the rail is ≥704 anyway.
 * Kept byte-identical with deliverables/design/components.css so the mock and the
 * live page stay comparable — the fidelity gate derives its numbers from the mock.
 */
@media (max-width: 767.98px) { .es-globe { min-height: 0; } }
.globe-tip {
  background: rgba(15,26,46,0.94);
  color: #FBF7F0;
  padding: 7px 12px;
  border-radius: var(--radius-md);
  font-size: 13px;
  font-weight: 500;
  white-space: nowrap;
  box-shadow: 0 8px 24px rgba(15,26,46,0.3);
  backdrop-filter: blur(8px);
  z-index: 10;
}
.globe-tip-name { font-weight: 600; }
.globe-tip-price { font-family: var(--font-family-mono); font-size: 12px; opacity: 0.75; margin-left: 2px; }

.es-globe-legend {
  position: absolute; left: 14px; bottom: 14px;
  display: flex; align-items: center; gap: 6px;
  background: rgba(251,247,240,0.85); backdrop-filter: blur(8px);
  border: 1px solid rgba(15,26,46,0.08);
  padding: 6px 10px 6px 8px; border-radius: var(--radius-full);
  font-family: var(--font-family-mono); font-size: 11px; letter-spacing: 0.06em;
  color: var(--color-contrast-muted);
}
.es-globe-legend-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--color-accent-2); box-shadow: 0 0 0 3px rgba(216,122,92,0.20); }
.es-globe-hint {
  position: absolute; right: 14px; top: 14px;
  font-family: var(--font-family-mono); font-size: 11px;
  color: var(--color-contrast-faint);
  background: rgba(251,247,240,0.7); backdrop-filter: blur(6px);
  padding: 6px 10px; border-radius: var(--radius-full);
  letter-spacing: 0.04em;
  pointer-events: none;
}

/* Live-data status banner (homepage) */
.es-live-indicator {
  display: inline-flex; align-items: center; gap: 8px;
  font-family: var(--font-family-mono); font-size: 11.5px;
  letter-spacing: 0.06em; color: var(--color-success);
}
.es-live-indicator-dot {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--color-success);
  box-shadow: 0 0 0 0 rgba(46,125,92,0.6);
  animation: es-live-pulse 1.8s ease-out infinite;
}
@keyframes es-live-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(46,125,92,0.6); }
  50%      { box-shadow: 0 0 0 8px rgba(46,125,92,0); }
}
@media (prefers-reduced-motion: reduce) { .es-live-indicator-dot { animation: none; } }

/* ESM-70 — four more effects deleted with their targets, none of them ever
 * visible: the QR scan beam (`.oc-qr-canvas-wrap`, `.oc-corner*`), the coverage
 * pin pulse (`.pd-coverage-pin` — the mock's decorative Japan map was never
 * built), the plan-card glow (`.es-plan--featured`) and the active-status dot
 * (`.es-status--active`). `.es-live-indicator-dot` above and the CTA shine
 * below are the two animations that DO run on rendered elements. */

/* CTA button shine */
.es-btn-cta { position: relative; overflow: hidden; }
.es-btn-cta::before {
  content: ""; position: absolute; top: 0; left: -80%;
  width: 50%; height: 100%;
  background: linear-gradient(105deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.35) 50%, rgba(255,255,255,0) 100%);
  transform: skewX(-20deg);
  pointer-events: none;
}
.es-btn-cta:hover::before {
  animation: es-cta-shine 900ms cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes es-cta-shine {
  from { left: -80%; }
  to   { left: 180%; }
}
@media (prefers-reduced-motion: reduce) { .es-btn-cta::before { display: none; } }


/* ─── Mobile responsive ─────────────────────────────────── */
/* ESM-70: the header and footer entries that used to sit here went with their
   families — every one of them addressed a `.es-hdr-*` / `.es-foot-*` name no
   markup carries. The live header and footer have their own responsive blocks
   in theme-bridge.css. */
@media (max-width: 767.98px) {
  .es-section { padding: var(--spacing-60) 0; }
}
