:root {
  /* ── Brand palette: gilly.space "Heliostatic" dark twin ──────────
     Unified 2026-07-29 so gilly.space, myheliograph.com and the Shopify
     storefront read as one brand. Source of truth is gilly.space's
     assets/site.css `:root.dark` block: warm black #1a1814, warm cream
     #e8e2d0, single gold accent #d9a91a.

     We stay DARK (gilly.space is light-first with this dark twin) because
     solar imagery and an image editor both need it — matching its dark
     side rather than its light side keeps the product legible while making
     the identity one thing. The change from the old palette is cool→warm:
     the blue-black backgrounds became warm blacks, and the corona purple
     (#7b61ff, the single most off-brand element) became a light gold so
     the whole surface runs on one hue family like gilly.space does.

     Values are retokenized here ONLY — every call site follows, so there
     is no per-rule churn. Contrasts re-verified against the new
     backgrounds; see the per-token notes below. */
  --bg-deep: #141210;       /* page ground, a step under the site bg  */
  --bg-surface: #1a1814;    /* gilly.space --bg (dark)                */
  --bg-card: #25221c;       /* gilly.space --card-bg                  */
  --bg-elevated: #302b23;   /* one step above card                    */
  --accent-sun: #d9a91a;    /* gilly.space --accent (the gold)        */
  --accent-flare: #e8663a;  /* warmed from #ff5e3a; semantic alerts   */
  --accent-corona: #f0c75e; /* was #7b61ff purple — now light gold    */
  --accent-cool: #7fb0d4;   /* muted, desaturated: info states still
                               need a hue that isn't gold             */
  /* BETA-mode advisory orange (Lex P2 round-2: BETA badge + beta-
     banner were three near-duplicate orange literals drifting
     independently; locked behind one token). Softer + lighter than
     --accent-sun on purpose — BETA reads as a friendly advisory,
     not a CTA. */
  --beta-orange: #ffb74d;
  --beta-orange-bg: rgba(255, 152, 0, 0.14);
  --beta-orange-border: rgba(255, 152, 0, 0.48);
  /* #f4efe6 / #c3b9a9 (were #e8e2d0 / #a8a094): unified with web3d's ink
     pair, the other place this same cream/muted role exists (Tier 3 polish
     pass, 2026-08-17). Both brighter, so the AA margin only improves:
     13.85:1 / 8.18:1 on --bg-card (was 12.25:1 / 6.13:1). */
  --text-primary: #f4efe6;
  --text-secondary: #c3b9a9;
  --text-dim: #c3b9a9;
  /* Brightened --accent-corona for use AS TEXT on dark backgrounds
     (decorative uses — borders, focus rings, gradients — keep
     --accent-corona itself). 10.9:1 on the card: comfortable AA. */
  --accent-corona-text: #f3d385;
  /* Fixed dark ink for text ON a gold/orange fill (buttons, pills).
     The fill itself never themes (accent-sun/-corona/-flare are the
     same values in both palettes), so this token isn't re-declared
     in the light block either. 8.5-12.8:1 across every gold gradient
     stop it's used on (Tier-1 polish pass, 2026-08-15). */
  --text-on-gold: #1a1206;
  --border: #3a362c;         /* gilly.space --card-border */
  --border-active: #d9a91a;  /* focus rings are gold now, not purple */
  --glow-sun: rgba(217,169,26,0.25);
  --glow-corona: rgba(240,199,94,0.20);
  /* MOLTEN RECORD: sections are translucent so the continuous field reads
     through them. Must be a token, not a literal — the light theme below has
     an entirely different ground, and a hardcoded dark rgba turns to mud on
     paper. --field-cool-tint is the cool end of the temperature scale. */
  --panel: rgba(37, 34, 28, 0.55);
  --field-cool-tint: rgba(127, 176, 212, 0.20);
  --field-cool-tint-soft: rgba(127, 176, 212, 0.14);
  /* The field's own palette, deliberately stronger than --glow-*. Those two
     tokens were tuned for a decorative wash behind an opaque card layout; the
     field is now the page's actual ground and has to carry real luminosity or
     the whole surface reads flat. */
  --field-core: rgba(245, 205, 100, 0.44);
  --field-core-mid: rgba(217, 169, 26, 0.22);
  --field-flare: rgba(232, 102, 58, 0.18);
  --field-vignette: rgba(4, 3, 2, 0.58);
  --field-cell-tint: rgba(245, 205, 100, 0.38);
  --radius-sm: 8px;
  --radius-md: 14px;
  --radius-lg: 20px;
  /* cubic-bezier(0.16,1,0.3,1) (was Material's stock 0.4,0,0.2,1): matches
     web3d's expo-out, used everywhere over there already. This one line
     retunes every element using --transition (27 call sites). Tier 3, 2026-08-17. */
  --transition: 0.3s cubic-bezier(0.16,1,0.3,1);
}


/* Default color-scheme tells the browser to render its OWN chrome
   (scrollbar, autofill, native form controls, mobile address-bar
   hint) in the correct theme. Without this the frame "around" the
   page rendered in OS-light while the page itself was dark, which
   read as incongruous (friction audit). `light dark` lets the browser
   pick based on what's actually rendered; html.dark forces dark even
   if the OS is light. */
html { color-scheme: light dark; background: var(--bg-deep); }
html.dark { color-scheme: dark; }

html:not(.dark) {
  /* Light mode = gilly.space's DEFAULT palette (assets/site.css :root):
     warm paper #faf8f3, ink-navy #1a2a5a, one gold accent. Where gold is
     used as TEXT it drops to a darkened gold, exactly as gilly.space does
     with its --accent-text, because the #d9a91a decorative gold is far
     below AA on paper. */
  --bg-deep: #f2efe7;
  --bg-surface: #faf8f3;    /* gilly.space --bg (light)   */
  --bg-card: #ffffff;       /* gilly.space --card-bg      */
  --bg-elevated: #f2efe7;
  /* Darkened gold for gold-as-text on light. #8a6a00 (gilly.space's own
     --accent-text) measures 4.41:1 on --bg-elevated — just under AA — so
     we go one step darker: #806200 clears 4.5:1 on paper, card AND
     elevated (5.40 / 5.73 / 4.99). */
  --accent-corona-text: #806200;
  --text-primary: #1a2a5a;  /* gilly.space --text (ink-navy) */
  --text-secondary: #555555;/* gilly.space --muted           */
  /* Warm-neutral dim. Old #6a6a8a measured 5.20:1 on white; #5f5a52
     measures 6.84:1 — AA margin improved while joining the warm ramp. */
  --text-dim: #5f5a52;
  /* #38bdf8 on the light surfaces is ~2:1 — fails AA for the footer
     legal links that use --accent-cool as text (launch review #7c).
     #0369a1 measures ~5.9:1 on white. */
  --accent-cool: #0369a1;
  --border: #e4ddcf;        /* gilly.space --rule */
  --border-active: #806200;
  --glow-sun: rgba(217,169,26,0.15);
  --glow-corona: rgba(217,169,26,0.10);
  /* Light counterparts. The panel inverts to a translucent WHITE so the field
     still reads through it on paper; the dark rgba used in the dark theme
     would render as a muddy grey box here. */
  --panel: rgba(255, 255, 255, 0.74);
  --field-cool-tint: rgba(3, 105, 161, 0.09);
  --field-cool-tint-soft: rgba(3, 105, 161, 0.06);
  /* Paper carries far less luminosity before it turns muddy, and the vignette
     inverts to a barely-there warm edge rather than a dark one. */
  --field-core: rgba(217, 169, 26, 0.20);
  --field-core-mid: rgba(217, 169, 26, 0.09);
  --field-flare: rgba(232, 102, 58, 0.09);
  --field-vignette: rgba(150, 125, 70, 0.10);
  --field-cell-tint: rgba(217, 169, 26, 0.14);
}

* { margin:0; padding:0; box-sizing:border-box; }

/* Global focus-visible ring. WCAG 2.4.7 (Sam Rosenberg round-2 a11y
   audit, P1) requires a visible focus indicator on every interactive
   element. Many of our buttons and tabs currently rely on a default
   browser outline that's been stripped by `outline:none` on a few
   nearby rules, breaking keyboard navigation. `:focus-visible` only
   fires for keyboard / programmatic focus (NOT mouse clicks), so the
   ring doesn't show up for sighted-mouse users — best of both worlds.
   Two-token outline so the ring is visible against both the deep
   purple panels and the orange CTAs. */
:focus-visible {
  outline: 2px solid var(--border-active);
  outline-offset: 2px;
  border-radius: 4px;
}
/* Some compound widgets manage their own focus styling already
   (date input, modal close X). Re-applying the global ring on top
   doubles the indicator. Opt out by adding `data-no-focus-ring` to
   the element. */
/* Screen-reader-only utility — used by the assertive #alertRegion.
   Without this the alert region paints error text visibly at the
   bottom of the page (launch review #7d). */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
[data-no-focus-ring]:focus-visible {
  outline: none;
}

/* Honor prefers-reduced-motion (Sam P1, Asha P1 overlap). Wipe
   transitions, animations, and smooth scroll behaviour for users
   who've asked the OS for less motion. The `* {...}` rule is
   intentionally heavy-handed — anything we want to keep animated
   for that audience should opt in with `data-animate-always`. */
@media (prefers-reduced-motion: reduce) {
  /* Pseudo-class must precede the pseudo-element (`::before:not()` is
     invalid and voids the whole selector list — launch review #7e). */
  *:not([data-animate-always]),
  *:not([data-animate-always])::before,
  *:not([data-animate-always])::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

body {
  font-family: var(--sans);
  background: var(--bg-deep);
  color: var(--text-primary);
  min-height: 100vh;
  overflow-x: hidden;
}

/* Animated background — superseded by the continuous field (.field), which
   lives in index.html before <main> and is defined in the MOLTEN RECORD block
   at the end of this file. The old single-layer body::before could not
   cross-fade warm to cool, which the scroll-driven temperature gradient
   requires, so it was replaced by explicit stacked layers. */

.app-container {
  position: relative;
  z-index: 1;
  max-width: 960px;
  margin: 0 auto;
  padding: 20px 16px 80px;
}

/* Skip-to-content link — Sam Rosenberg + Cole Ramirez round-2 a11y
   (WCAG 2.4.1 Bypass Blocks). Off-screen until focused, then snaps
   into the top-left corner so a keyboard user can jump past the hero
   and banners straight into <main>. Corona-orange so it stays visible
   over both the dark page background and the corona-glow body::before
   layer; sun-yellow outline on focus mirrors the rest of the focus
   styles in this stylesheet. */
.skip-link {
  position: absolute;
  top: -40px;
  left: 8px;
  background: var(--accent-corona);
  /* white measured 1.61:1 on this gold background, failing WCAG AA (the
     comment here predates the 2026-07-29 palette migration that turned
     --accent-corona from purple into light gold — white worked on the old
     purple, not on this). Tier 3 polish pass, 2026-08-17. */
  color: var(--text-on-gold);
  padding: 8px 12px;
  border-radius: 4px;
  z-index: 10000;
  text-decoration: none;
  font-weight: 600;
}
.skip-link:focus {
  top: 8px;
  outline: 3px solid var(--accent-sun);
  outline-offset: 2px;
}

/* Header */
.hero {
  text-align: center;
  padding: 32px 0 24px;
  animation: fadeInUp 0.8s ease-out;
}

.hero-icon {
  width: 64px; height: 64px;
  border-radius: 50%;
  background: radial-gradient(circle at 40% 40%, #ffe066, var(--accent-sun), var(--accent-flare));
  margin: 0 auto 16px;
  box-shadow: 0 0 40px var(--glow-sun), 0 0 80px rgba(255,94,58,0.15);
  animation: pulse 3s ease-in-out infinite;
}

@keyframes pulse {
  0%,100% { box-shadow: 0 0 40px var(--glow-sun), 0 0 80px rgba(255,94,58,0.15); transform: scale(1); }
  50% { box-shadow: 0 0 60px var(--glow-sun), 0 0 120px rgba(255,94,58,0.25); transform: scale(1.05); }
}


.hero h1 {
  font-size: 2rem;
  /* 380 (was 800): matches web3d's light editorial Fraunces weight (its
     .line uses 380 throughout the film) — same typeface, one personality
     now instead of the store reading heavier/more "dashboard" by contrast.
     Gilly's call, Tier 3 polish pass, 2026-08-17. */
  font-weight: 380;
  letter-spacing: -0.03em;
  background: linear-gradient(135deg, var(--accent-sun), var(--accent-flare), var(--accent-corona));
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

.hero p {
  color: var(--text-secondary);
  font-size: 1rem;
  margin-top: 6px;
  font-weight: 300;
  /* Balance the wrap so the last line doesn't orphan a single word
     ("…or / canvas."). */
  text-wrap: balance;
}
/* Trust bar — reassurance row near the funnel end. */
.trust-bar {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 10px 16px;
  max-width: 900px;
  margin: 8px auto 0;
  padding: 14px 18px;
  color: var(--text-secondary);
  font-size: 0.85rem;
}
.trust-bar i { color: var(--accent-sun, var(--accent-sun)); margin-right: 5px; }
.trust-bar > span[aria-hidden] { color: var(--text-dim); opacity: 0.5; }
@media (max-width: 600px) {
  .trust-bar > span[aria-hidden] { display: none; }
  .trust-bar { gap: 8px; flex-direction: column; text-align: center; }
}

/* Landing quality-showcase strip (RHEF innovation, above the product picker). */
.quality-strip-section { text-align: center; }
/* Collapse-after-first-view: once .collapsed is set (first step transition),
   hide the heavy showcase + copy and show only the thin re-open bar. */
.quality-strip-bar { display: none; }
.quality-strip-section.collapsed .quality-diptych,
.quality-strip-section.collapsed .compare-slider,
.quality-strip-section.collapsed .quality-strip-caption,
.quality-strip-section.collapsed .quality-strip-fineprint { display: none !important; }
/* .flow-inner's scroll-motion margin-bottom (12vh/6vh, below) is sized for
   the section's full expanded content. Collapsed, the section is just the
   thin re-open bar, so the same margin reserved an 86px+ blank band under
   it (live-measured, 2026-08-16) — trim to match the bar's own spacing. */
.quality-strip-section.collapsed .flow-inner { margin-bottom: 1.5rem; }
.quality-strip-section.collapsed .quality-strip-bar {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin: 0 auto;
  padding: 8px 16px;
  color: var(--text-secondary);
  font-size: 0.82rem;
  background: var(--surface-2, rgba(255,255,255,0.04));
  border: 1px solid var(--border-subtle, rgba(255,255,255,0.12));
  border-radius: 999px;
  cursor: pointer;
}
.quality-strip-section.collapsed .quality-strip-bar:hover {
  color: var(--accent-corona-text, #f3d385);
}
/* Manual "Hide comparison" control — visible only when expanded. */
.quality-strip-hide {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin: 12px auto 0;
  padding: 6px 14px;
  color: var(--text-dim, #8a8a8a);
  font-size: 0.78rem;
  background: transparent;
  border: 1px solid var(--border-subtle, rgba(255,255,255,0.12));
  border-radius: 999px;
  cursor: pointer;
}
.quality-strip-hide:hover { color: var(--text-secondary); }
.quality-strip-section.collapsed .quality-strip-hide { display: none; }
/* Fine-tune section: explicit "See this wavelength" submit. */
.wl-submit-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  margin: 12px 0 0;
  padding: 12px 16px;
  font-size: 0.95rem;
  font-weight: 600;
  color: #1a1206;
  background: var(--accent-corona-text, #f3d385);
  border: none;
  border-radius: 10px;
  cursor: pointer;
}
.wl-submit-btn:hover { filter: brightness(1.06); }
.wl-submit-btn.hidden { display: none; }
/* Light theme: the dark-mode fill above is --accent-corona-text, which
   in the light palette is a darkened GOLD-AS-TEXT token (#806200), not
   a button fill — near-black text on it measured 3.23:1. Use the real
   gold fill token instead (--accent-sun is the same value in both
   themes, so this only needs a light-mode override). */
html:not(.dark) .wl-submit-btn { background: var(--accent-sun); }
.quality-strip-section img {
  width: 100%;
  max-width: 1100px;
  height: auto;
  border-radius: 12px;
  display: block;
  margin: 0 auto;
  box-shadow: 0 6px 24px rgba(0,0,0,0.35);
}
/* ── Before/after compare slider (landing showcase) ─────────────
   Two co-registered images; the over (Original) layer is clipped to the
   left of the divider via --split. Pure CSS default --split:50% means the
   comparison reads even with JS disabled. touch-action: pan-y keeps
   vertical page scrolling alive on mobile while horizontal drags come to
   the pointer handlers. */
/* ✕ back to the static side-by-side. BOTTOM-right: the entire top row is
   taken by the two corner captions (.compare-label-left / -right are both
   top:10px), and putting the button top-left landed it directly on top of
   the "ORIGINAL" pill. The bottom row has no other children — only the
   full-height divider crosses it, and this sits above that (z-index) and
   stops propagation, so it stays clickable even when the divider is
   dragged underneath. Bumped to a 44px target on touch. */
.compare-close-btn {
  position: absolute;
  bottom: 10px;
  right: 10px;
  z-index: 4;
  background: rgba(0,0,0,0.45);
  border: 1px solid rgba(255,255,255,0.18);
  color: var(--text-secondary, #d0c8d8);
  font-size: 0.95rem;
  line-height: 1;
  cursor: pointer;
  padding: 5px 8px;
  border-radius: var(--radius-sm, 6px);
  transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
}
.compare-close-btn:hover,
.compare-close-btn:focus-visible {
  color: #fff;
  background: rgba(255,94,58,0.28);
  border-color: var(--accent-flare, #ff5e3a);
}
@media (pointer: coarse) {
  .compare-close-btn {
    min-width: 44px;
    min-height: 44px;
    display: flex;
    align-items: center;
    justify-content: center;
  }
}
@media (prefers-reduced-motion: reduce) {
  .compare-close-btn { transition: none; }
}

/* ── Landing showcase: static diptych (default) ──────────────────
   Side-by-side beats the wipe as the DEFAULT because it compares the
   whole disk in both states — like-for-like — and needs no interaction.
   It's a <button> so click + Enter/Space + focus ring come free; it
   upgrades in place to .compare-slider on activation. Stays two columns
   at every width: showing both states at once IS the point, so stacking
   would defeat it. */
.quality-diptych {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: clamp(8px, 2vw, 18px);
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
  text-align: center;
  position: relative;
}
.diptych-pane {
  display: flex;
  flex-direction: column;
  align-items: center;
  min-width: 0;
}
.diptych-label {
  font-weight: 700;
  font-size: clamp(0.95rem, 2.4vw, 1.15rem);
  color: var(--text-primary);
  letter-spacing: -0.01em;
}
.diptych-sub {
  font-size: clamp(0.68rem, 1.7vw, 0.82rem);
  color: var(--text-dim);
  margin-bottom: 8px;
}
.diptych-img {
  width: 100%;
  height: auto;
  display: block;
  border-radius: var(--radius-sm, 8px);
  background: var(--bg-deep);
}
/* Affordance for the hands-on upgrade. Sits under both panes, centred. */
.diptych-hint {
  grid-column: 1 / -1;
  margin-top: 10px;
  font-size: 0.78rem;
  color: var(--text-secondary);
  opacity: 0.85;
  transition: opacity 0.15s ease, color 0.15s ease;
}
.quality-diptych:hover .diptych-hint,
.quality-diptych:focus-visible .diptych-hint {
  opacity: 1;
  color: var(--accent-corona-text, #f3d385);
}
@media (prefers-reduced-motion: reduce) {
  .diptych-hint { transition: none; }
}
/* Ultimate fallback image (either webp 404s). */
.quality-strip-fallback {
  width: 100%;
  height: auto;
  display: block;
  border-radius: var(--radius-md, 12px);
}

.compare-slider {
  position: relative;
  width: 100%;
  max-width: 620px;
  margin: 0 auto;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 6px 24px rgba(0,0,0,0.35);
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
  cursor: ew-resize;
}
/* Override the generic .quality-strip-section img treatment inside the
   slider — these two stack, they don't flow. */
.compare-slider img {
  width: 100%;
  height: auto;
  display: block;
  margin: 0;
  border-radius: 0;
  box-shadow: none;
  pointer-events: none;
}
.compare-over {
  position: absolute;
  inset: 0;
  clip-path: inset(0 calc(100% - var(--split)) 0 0);
}
.compare-handle {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--split);
  width: 2px;
  margin-left: -1px;
  background: rgba(255,255,255,0.85);
}
/* Fat invisible hit target around the 2px bar. */
.compare-handle::before {
  content: "";
  position: absolute;
  top: 0; bottom: 0;
  left: -16px; right: -16px;
}
.compare-handle:focus-visible {
  outline: 2px solid var(--accent-sun, var(--accent-sun));
  outline-offset: 2px;
}
.compare-grip {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 42px;
  height: 42px;
  border-radius: 50%;
  background: rgba(15,17,30,0.85);
  border: 1.5px solid rgba(255,255,255,0.85);
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-size: 0.85rem;
  box-shadow: 0 4px 14px rgba(0,0,0,0.5);
}
.compare-label {
  position: absolute;
  top: 10px;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgba(10,9,17,0.65);
  color: #d0c8d8;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  pointer-events: none;
}
.compare-label-left  { left: 10px; }
.compare-label-right { right: 10px; }

/* Handoff bridge's own compare instance: "still developing" state (right
   half is showing the placeholder frame on both sides until the real RHEF
   render lands) and the pick-without-closing-first buttons. */
/* [hidden] must always win — this codebase has hit the "author class beats
   the UA [hidden] rule" bug before (#vibeRevealCta, .vibe-reveal-cta.hidden),
   same fix here: the boolean hidden attribute set from JS was being
   silently overridden by this class's own display:flex. */
.compare-pending[hidden] { display: none; }
.compare-pending {
  position: absolute;
  top: 10px;
  right: 10px;
  left: auto;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgba(10,9,17,0.75);
  color: #d0c8d8;
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  pointer-events: none;
}
.handoff-compare-pick {
  display: flex;
  justify-content: center;
  gap: 0.6rem;
  margin: 0.7rem auto 0;
}
.handoff-compare-pick-btn {
  font-family: var(--sans);
  font-size: 0.76rem;
  font-weight: 600;
  color: var(--handoff-text-dim);
  background: transparent;
  border: 1px solid rgba(232, 226, 208, 0.25);
  border-radius: 999px;
  padding: 0.5rem 1.1rem;
  cursor: pointer;
  transition: border-color var(--transition), color var(--transition), background var(--transition);
}
.handoff-compare-pick-btn:hover,
.handoff-compare-pick-btn:focus-visible {
  color: #0a0908;
  background: var(--text-primary);
  border-color: var(--text-primary);
}

.quality-strip-caption {
  max-width: 760px;
  margin: 12px auto 0;
  color: var(--text-secondary);
  font-size: 0.85rem;
  line-height: 1.45;
}
.quality-strip-caption strong { color: var(--accent-corona-text, #f3d385); font-weight: 600; }
/* The method + citation, demoted below the plain-language caption. */
.quality-strip-fineprint {
  max-width: 760px;
  margin: 6px auto 0;
  color: var(--text-secondary);
  font-size: 0.72rem;
  line-height: 1.45;
  /* was opacity:0.62 — diluted #555 below 4.5:1 on the light card (axe). */
}

/* Backend status banner removed from the UI — the landing is zero-backend
   and warms in the background, so "checking/online" was pure noise. Hidden
   rather than deleted so the JS that references #backendBanner stays valid.
   ponytail: one-line hide beats guarding ~10 JS call sites. */
#backendBanner { display: none !important; }

/* Promoted tagline — sits right under the wordmark, larger + warmer than
   the descriptive line below it. */
.hero p.hero-tagline {
  font-size: 1.5rem;
  font-weight: 400;
  color: var(--accent-corona-text, #f3d385);
  margin-top: 4px;
  letter-spacing: 0.01em;
}
/* NASA/SDO acknowledgement under the hero subtitle — present but quiet,
   so it's prominent (top of page, seen first) without competing with
   the tagline. */
.hero .hero-credit {
  font-size: 0.72rem;
  color: var(--text-dim);
  margin-top: 8px;
  font-weight: 400;
  letter-spacing: 0.01em;
}
/* Price/format anchor directly under the hero paragraph — the concrete
   "what is this and what does it cost" line for a visitor who is deciding
   in the first few seconds. Sits above .hero-credit in the visual order
   and is deliberately brighter than it: the credit is an obligation, this
   is the offer. */
.hero .hero-anchor {
  font-size: 0.86rem;
  color: var(--text-secondary);
  margin-top: 10px;
  letter-spacing: 0.01em;
}
.hero .hero-anchor strong {
  color: var(--accent-corona-text, #f3d385);
  font-weight: 600;
}
@media (max-width: 749px) {
  .hero .hero-anchor { font-size: 0.78rem; margin-top: 8px; }
}

/* Beta-mode banner under the hero. Hidden by default; the
   .beta-mode-active class on <body> (set by JS once /store-config
   returns beta_mode:true) reveals it. Warm-orange so it reads as a
   friendly advisory rather than a red error. */
.beta-banner {
  display: none;
  margin: 12px auto 0;
  padding: 8px 14px;
  border-radius: 999px;
  background: var(--beta-orange-bg);
  border: 1px solid var(--beta-orange-border);
  color: var(--beta-orange);
  font-size: 0.85rem;
  font-weight: 500;
  letter-spacing: 0.01em;
  max-width: max-content;
  align-items: center;
  gap: 8px;
}
.beta-banner i {
  color: var(--beta-orange);
  font-size: 0.95rem;
}
body.beta-mode-active .beta-banner {
  display: inline-flex;
}
/* Beta-mode title badge — appended as " BETA" to the existing
   "Solar Archive" h1 via JS. Same warm orange so the header and
   advisory banner read as one visual cue. */
.app-title-beta-badge {
  display: inline-block;
  margin-left: 10px;
  padding: 2px 10px;
  border-radius: 999px;
  background: var(--beta-orange-bg);
  border: 1px solid var(--beta-orange-border);
  color: var(--beta-orange);
  font-size: 0.42em;
  font-weight: 700;
  letter-spacing: 0.18em;
  vertical-align: middle;
  -webkit-text-fill-color: var(--beta-orange); /* override the gradient text-fill on h1 */
}

/* ── Birthday Sun CTA ──────────────────────────────────────
   Above-the-fold gifting hook. Two beta personas (Riley + Jordan)
   independently flagged birthday/anniversary as the dominant
   conversion path; this surfaces it as the primary entry point
   without removing the "explore by date + wavelength" flow below. */
.birthday-cta-section {
  /* Tighter than a regular section — feels like a hero CTA, not
     another numbered step. */
  padding: 0;
  background: transparent;
  border: none;
  box-shadow: none;
  margin-bottom: 18px;
}
.birthday-cta {
  display: grid;
  grid-template-columns: auto 1fr auto;
  grid-template-areas:
    "icon text  form";
  align-items: center;
  gap: 18px 20px;
  padding: 22px 26px;
  background: linear-gradient(135deg,
    rgba(247, 168, 37, 0.16) 0%,
    rgba(255, 94, 58, 0.12) 50%,
    rgba(217, 169, 26, 0.16) 100%);
  border: 1px solid rgba(247, 168, 37, 0.32);
  border-radius: var(--radius-md);
}
.birthday-cta-icon {
  grid-area: icon;
  font-size: 1.8rem;
  color: var(--accent-sun, var(--accent-sun));
  flex: none;
}
.birthday-cta-text { grid-area: text; min-width: 0; }
.birthday-cta-title {
  margin: 0 0 4px;
  font-size: 1.18rem;
  font-weight: 700;
  color: var(--text-primary);
  letter-spacing: -0.01em;
}
.birthday-cta-sub {
  margin: 0;
  font-size: 0.86rem;
  color: var(--text-secondary);
  line-height: 1.4;
}
.birthday-cta-form {
  grid-area: form;
  display: flex;
  align-items: center;
  gap: 8px;
}
.birthday-cta-label {
  /* Visually hidden but available to screen readers. */
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.birthday-cta-input {
  font-family: inherit;
  font-size: 0.95rem;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--bg-elevated);
  color: var(--text-primary);
  outline: none;
  min-width: 0;
}
.birthday-cta-input:focus {
  border-color: var(--accent-corona);
  box-shadow: 0 0 0 3px rgba(217, 169, 26, 0.2);
}
.birthday-cta-btn {
  padding: 10px 18px;
  font-family: inherit;
  font-size: 0.92rem;
  font-weight: 600;
  background: linear-gradient(135deg, var(--accent-sun), var(--accent-flare));
  color: #fff;
  border: 0;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: transform 0.12s ease, box-shadow 0.12s ease;
  white-space: nowrap;
}
.birthday-cta-btn:hover {
  transform: translateY(-1px);
  box-shadow: 0 4px 14px rgba(247, 168, 37, 0.35);
}
.birthday-cta-btn:active { transform: translateY(0); }

/* Stack on narrow screens — three rows: icon+title row collapses
   to text-only, then the form below. */
@media (max-width: 740px) {
  .birthday-cta {
    grid-template-columns: 1fr;
    grid-template-areas:
      "icon"
      "text"
      "form";
    text-align: center;
    padding: 18px 18px 20px;
  }
  .birthday-cta-icon {
    margin: 0 auto;
  }
  .birthday-cta-form {
    flex-direction: column;
    align-items: stretch;
  }
  .birthday-cta-input,
  .birthday-cta-btn {
    width: 100%;
  }
}

/* ── Post-download thank-you modal (beta flow only) ─────── */
.beta-thanks-modal {
  position: fixed;
  inset: 0;
  z-index: 1100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 16px;
}
.beta-thanks-modal.hidden { display: none; }
.beta-thanks-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(12, 11, 9, 0.72);
  backdrop-filter: blur(2px);
}
.beta-thanks-panel {
  position: relative;
  /* Theme-aware (2026-08-08 palette sweep): the old pinned-dark +
     forced-light-text scope predated a working light palette and
     rendered off-brand navy in dark mode. var(--bg-card) + document
     tokens are correct in both themes now. */
  background: var(--bg-card);
  border: 1px solid rgba(255, 152, 0, 0.35);
  border-radius: 12px;
  padding: 28px 28px 24px;
  max-width: 440px;
  width: 100%;
  text-align: center;
  box-shadow: 0 24px 56px rgba(0, 0, 0, 0.45);
}
.beta-thanks-close {
  position: absolute;
  top: 10px;
  right: 12px;
  background: transparent;
  border: 0;
  color: var(--text-dim, #6a6a8a);
  font-size: 1.1rem;
  cursor: pointer;
  padding: 4px 8px;
}
.beta-thanks-close:hover { color: var(--text-primary, #e9eaf5); }
.beta-thanks-icon {
  font-size: 2.4rem;
  color: #ffb74d;
  margin-bottom: 8px;
}
.beta-thanks-title {
  font-size: 1.25rem;
  font-weight: 600;
  margin: 0 0 12px;
  color: var(--text-primary, #e9eaf5);
}
.beta-thanks-body {
  font-size: 0.92rem;
  line-height: 1.55;
  color: var(--text-secondary, #9898b8);
  margin: 0 0 20px;
}
.beta-thanks-actions {
  display: flex;
  justify-content: center;
  gap: 10px;
}
.beta-thanks-btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 18px;
  border-radius: 8px;
  border: 0;
  font: 600 0.92rem -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  cursor: pointer;
  transition: transform 0.06s ease, box-shadow 0.15s ease;
}
.beta-thanks-btn-primary {
  background: linear-gradient(135deg, #d9a91a, #f0c75e);
  color: #fff;
}
.beta-thanks-btn-primary:hover {
  transform: translateY(-1px);
  box-shadow: 0 6px 16px rgba(217, 169, 26, 0.4);
}

/* Sections / Steps
   MOLTEN RECORD (2026-08-13): sections no longer read as separate cards.
   The hard 1px border and the opaque --bg-card fill were what made every
   boundary a visible seam; the continuous field behind the page is meant to
   run unbroken from hero to checkout. Panels are now translucent so the field
   shows through and grouping survives without a hard edge.

   Deliberately NOT going fully background-less: this page carries dense
   content (product grids, editor controls, forms) that needs visual grouping
   to stay legible. A translucent panel is still a continuous surface; a hard
   border is not. backdrop-filter is avoided here on purpose — it is expensive
   over section-sized areas (see the perf constraint register, P6).

   NOTE: no `animation` here, and never a `transform`. The old
   `animation: fadeInUp` used translateY, which even at a "to" state of 0
   promoted .section to a containing block for position:fixed descendants and
   broke position:sticky for the editor preview pane. Entrances are owned by
   .flow-inner (see the MOLTEN RECORD block at the end of this file). */
.section {
  background: var(--panel);
  border: 0;
  border-radius: var(--radius-lg);
  padding: 24px;
  margin-bottom: 20px;
}

/* Still referenced by .hero (L216) and .backend-banner (L3356), so these stay.
   Opacity-only, so no containing-block side effects. `sectionFadeIn` and the
   .section:nth-child stagger delays were removed with the card treatment
   above: leaving them alongside the scroll-driven reveals would double-fade
   every section (fires once on load, then again on scroll-into-view). */
@keyframes fadeInUp {
  from { opacity: 0; }
  to { opacity: 1; }
}

.section-header {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 16px;
}

/* ── Collapsible sections ─────────────────────────────────────────
   Step 1 (config — time + wavelength) is collapsed by default
   because most users get into the editor via a vibe-card preset
   that fills both fields automatically. The full controls only
   need to come out for the "your day" birthday card or when the
   user explicitly clicks the "Customize time & wavelength" CTA
   in step 2.

   .section-collapse-toggle is a real <button> wrapper around the
   header so click + Space + Enter all work + a screen reader
   announces it as a disclosure. .section-collapsible-body holds
   everything beneath the header that should collapse away. */
.section-collapse-toggle {
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  width: 100%;
  text-align: left;
  cursor: pointer;
  color: inherit;
  font: inherit;
}
.section-collapse-toggle .section-header {
  margin-bottom: 0;
}
.section-collapse-chevron {
  margin-left: auto;
  font-size: 14px;
  /* The inherited --sans stack (Inter Variable, ...) renders U+25BE
     as a near-invisible ~6.5px dot instead of a triangle. Force a
     font stack with reliable symbol glyph coverage for this one
     character. */
  font-family: -apple-system, "Segoe UI Symbol", Arial, sans-serif;
  color: var(--text-secondary, #a0a0bd);
  transition: transform 0.15s ease;
  transform: rotate(180deg);  /* up when expanded */
}
.section-collapsed .section-collapse-chevron {
  transform: rotate(0deg);    /* down when collapsed (default) */
}
.section-collapse-hint {
  color: var(--text-secondary, #a0a0bd);
  font-weight: 400;
  font-size: 0.85em;
  margin-left: 6px;
}
.section-collapsible-body {
  /* When expanded, add breathing room above the body so it doesn't
     crash into the now-clickable header. Collapse via display:none
     so hidden grids inside don't run their reveal animations on
     every section toggle. */
  margin-top: 16px;
}
.section-collapsed .section-collapsible-body {
  display: none;
}

/* Small "secondary" CTA buttons in section headers — used for
   things like "Customize time & wavelength →" inside step 2's
   header. Sits at the right of the header (margin-left:auto). */
.section-secondary-cta {
  margin-left: auto;
  background: transparent;
  border: 1px solid rgba(255, 255, 255, 0.18);
  color: var(--text-secondary, #a0a0bd);
  padding: 6px 12px;
  border-radius: 8px;
  font-size: 13px;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.section-secondary-cta:hover {
  background: rgba(255, 255, 255, 0.06);
  color: var(--text-primary, #e9eaf5);
  border-color: rgba(255, 255, 255, 0.3);
}

.step-badge {
  width: 32px; height: 32px;
  border-radius: 50%;
  /* corona → flare per Lex round-2 ("strongest opinion"): the prior
     corona→cool teal had no parent in the sun/flare/corona triad and
     "read like leftover Tailwind sky-400." Corona→flare keeps the
     badges inside the brand palette. */
  background: linear-gradient(135deg, var(--accent-corona), var(--accent-flare));
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: 0.85rem;
  color: #fff;
  flex-shrink: 0;
}

.section-title {
  font-size: 1.15rem;
  font-weight: 380; /* was 700 -- matches .hero h1's serif-weight unification above */
}

/* Controls */
.controls-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
}

@media (max-width: 560px) {
  .controls-grid { grid-template-columns: 1fr; }
}

.field-group {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.field-label {
  font-size: 0.82rem;
  font-weight: 500;
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

input[type="date"],
input[type="time"],
select {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 12px 14px;
  font-size: 16px;
  font-family: var(--sans);
  color: var(--text-primary);
  transition: border-color var(--transition), box-shadow var(--transition);
  width: 100%;
  -webkit-appearance: none;
}

/* Mouse-focus styling: subtle border + glow, no default outline.
   :focus-visible (keyboard) still applies the global outline via the
   top-of-file rule — the agent friction audit found those rings were
   getting stripped because the old `:focus` block beat the global
   `:focus-visible` on specificity. Splitting :focus from :focus-visible
   keeps the mouse-clean look while preserving keyboard ring. */
input[type="date"]:focus:not(:focus-visible),
input[type="time"]:focus:not(:focus-visible),
select:focus:not(:focus-visible) {
  outline: none;
  border-color: var(--border-active);
  box-shadow: 0 0 0 3px var(--glow-corona);
}
input[type="date"]:focus-visible,
input[type="time"]:focus-visible,
select:focus-visible {
  border-color: var(--border-active);
  box-shadow: 0 0 0 3px var(--glow-corona);
}

/* Wavelength cards */
.wavelength-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 10px;
  margin-top: 10px;
}

@media (max-width: 500px) {
  .wavelength-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

.filter-toggle {
  display: inline-flex;
  gap: 2px;
  background: var(--bg-deep);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 2px;
}

.filter-opt {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 10px;
  font-size: 0.75rem;
  color: var(--text-secondary);
  cursor: pointer;
  border-radius: 4px;
  transition: all 0.15s ease;
  white-space: nowrap;
}

.filter-opt:has(input:checked),
.filter-opt.active {
  background: var(--accent-corona);
  color: #fff;
  font-weight: bold;
  outline: 2px solid rgba(255, 255, 255, 0.5);
  outline-offset: 1px;
}

/* Match option shows mode color (set in JS); when selected, .active provides outline and bold */

/* HQ RHEF option — matches sibling filter options. A subtle star cue in the
   label signals "premium/slow" without the old gold treatment that tested as
   intimidating to click. When selected, it uses the same accent-corona as
   the other filters. */
#hqRhefFilterOpt {
  color: var(--text-secondary);
}

.filter-opt input[type="radio"] {
  display: none;
}

/* ── Filter quality timeline ──
   Replaces the JPG/Raw/RHEF/HQ-RHEF radio row with a stepper that reads as
   "image quality progresses through these tiers": locked tiers show a small
   lock icon, loading tiers spin, ready tiers get a check, the active tier
   gets the accent treatment. Connectors between steps fill in as each tier
   becomes ready. Underlying radio inputs are kept hidden for a11y. */
.filter-timeline {
  position: relative;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  padding: 6px 10px;
  background: var(--bg-deep);
  border: 1px solid var(--border);
  border-radius: 8px;
}
.filter-timeline-label {
  /* Own row so the four steps align vertically — beta tester noted the
     prior layout staggered "QUALITY" inline next to JPG which made the
     stages drift across rows. */
  flex: 0 0 100%;
  font-size: 0.7rem;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  margin: 0 0 2px;
}
.filter-step {
  position: relative;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 10px 4px 6px;
  border-radius: 999px;
  cursor: pointer;
  transition: background-color 0.12s ease, color 0.12s ease, opacity 0.15s ease;
  color: var(--text-dim);
  user-select: none;
}
.filter-step input[type="radio"] {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
/* Keyboard operability (launch review #7b): the radios are now in the
   tab order, so paint a visible ring around the whole step pill when
   one has keyboard focus. The input covers the step and renders
   nothing but its outline. */
.filter-step input[type="radio"]:focus-visible {
  opacity: 1;
  appearance: none;
  -webkit-appearance: none;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  background: transparent;
  border: 0;
  border-radius: 999px;
}
.filter-step-num {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--border);
  color: var(--text-dim);
  font-size: 0.7rem;
  flex-shrink: 0;
  transition: background-color 0.12s ease, color 0.12s ease;
}
.filter-step-info {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  line-height: 1;
}
.filter-step-name {
  font-size: 0.78rem;
  font-weight: 600;
}
.filter-step-status {
  font-size: 0.62rem;
  letter-spacing: 0.02em;
  margin-top: 1px;
  min-height: 1em;
}
.filter-step-status::before {
  content: "";
  display: inline-block;
  vertical-align: middle;
}
.filter-step-status[data-status="locked"]::before {
  content: "\f017 Not ready yet";
  font-family: "Font Awesome 6 Free", "Font Awesome 5 Free", sans-serif;
  font-weight: 900;
  /* Removed letter-spacing: 0.4em — the wide-tracked uppercase-feeling
     "L O C K E D" alongside lowercase "Loading…" looked inconsistent
     and the agent friction audit read it as an error state. */
  letter-spacing: 0.02em;
  color: var(--text-dim);
}
.filter-step-status[data-status="loading"]::before {
  content: "";
  width: 8px;
  height: 8px;
  border: 1.5px solid rgba(217, 169, 26, 0.35);
  border-top-color: var(--accent-corona, #d9a91a);
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
  margin-right: 4px;
}
.filter-step-status[data-status="loading"]::after {
  content: "Loading…";
  color: var(--accent-corona, #d9a91a);
}
.filter-step-status[data-status="ready"]::before {
  content: "\f00c Ready";
  font-family: "Font Awesome 6 Free", "Font Awesome 5 Free", sans-serif;
  font-weight: 900;
  letter-spacing: 0.4em;
  color: #3ddc84;
}
.filter-step-status[data-status="active"]::before {
  content: "\f00c Active";
  font-family: "Font Awesome 6 Free", "Font Awesome 5 Free", sans-serif;
  font-weight: 900;
  letter-spacing: 0.4em;
  color: #fff;
}

/* Locked steps are dimmed and not clickable (cursor stays default) — but
   we still allow the click so the existing handleFilterChange() can show
   the "starting fetch…" toast. The cursor cue communicates "not yet". */
.filter-step.locked {
  opacity: 0.55;
  cursor: progress;
}
.filter-step.ready:hover {
  background: rgba(217, 169, 26, 0.08);
  color: var(--text-primary);
}
.filter-step.ready .filter-step-num {
  background: rgba(61, 220, 132, 0.18);
  color: #3ddc84;
}
.filter-step.loading .filter-step-num {
  background: rgba(217, 169, 26, 0.18);
  color: var(--accent-corona, #d9a91a);
}
.filter-step.active {
  background: var(--accent-corona, #d9a91a);
  color: #fff;
  font-weight: 700;
}
.filter-step.active .filter-step-num {
  background: rgba(255, 255, 255, 0.22);
  color: #fff;
}

/* Connector line between steps. Thicker + accent when both flanking steps
   are ready; muted otherwise. */
.filter-step-connector {
  flex: 0 0 18px;
  height: 2px;
  background: var(--border);
  border-radius: 2px;
  transition: background-color 0.18s ease;
}
.filter-step-connector.completed {
  background: linear-gradient(90deg, #3ddc84, var(--accent-corona, #d9a91a));
}

/* Defensive: hide any leftover old-markup labels on caches that haven't
   reloaded. */
.filter-timeline .filter-opt { display: none; }

.filter-status-line {
  font-size: 0.7rem;
  font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
  padding: 3px 12px;
  margin-top: 4px;
  border-radius: 4px;
  line-height: 1.4;
  transition: opacity 0.3s ease;
}
.filter-status-line.loading {
  background: rgba(255, 165, 0, 0.12);
  color: var(--accent-corona-text, #f3d385);
}
.filter-status-line.success {
  background: rgba(61, 220, 132, 0.12);
  color: #3ddc84;
}
.filter-status-line.error {
  background: rgba(255, 87, 51, 0.12);
  color: var(--accent-flare, #ff5733);
}

.wl-card {
  background: var(--bg-elevated);
  border: 2px solid var(--border);
  border-radius: var(--radius-md);
  padding: 12px 10px;
  text-align: center;
  cursor: pointer;
  transition: all var(--transition);
  position: relative;
  overflow: hidden;
  /* Button-tag reset (round-2 a11y, Cole Ramirez P0): tiles are
     now real <button> elements so Voice Control + screen readers
     can name them. The defaults wreck the look, so we re-baseline
     here. The shared styling above still applies regardless of
     tag. */
  display: block;
  width: 100%;
  font: inherit;
  color: inherit;
  -webkit-appearance: none;
  appearance: none;
}

.wl-card::before {
  content: '';
  position: absolute;
  top: 0; left: 0;
  width: 100%; height: 4px;
}

.wl-card[data-wl="94"]::before { background: #3ddc84; }
.wl-card[data-wl="131"]::before { background: #00bcd4; }
.wl-card[data-wl="171"]::before { background: var(--accent-sun); }
.wl-card[data-wl="193"]::before { background: #e67e22; }
.wl-card[data-wl="211"]::before { background: #9b59b6; }
.wl-card[data-wl="304"]::before { background: #e74c3c; }
.wl-card[data-wl="335"]::before { background: #3498db; }
.wl-card[data-wl="1600"]::before { background: #f0c040; }
.wl-card[data-wl="1700"]::before { background: #f5deb3; }

.wl-card:hover {
  border-color: var(--text-dim);
  transform: translateY(-2px);
}

.wl-card.selected {
  border-color: var(--accent-corona);
  background: rgba(217, 169, 26,0.08);
  box-shadow: 0 0 16px var(--glow-corona);
}

.wl-thumb {
  width: 120px;
  height: 120px;
  margin: 0 auto 8px;
  border-radius: 50%;
  overflow: hidden;
  background: var(--bg-deep);
  display: flex;
  align-items: center;
  justify-content: center;
  transition: opacity 0.3s ease;
}
/* Desktop: roughly double the thumb radius (120 → 220) so the
   wavelength previews land as posters in their own right, not
   afterthoughts. Mobile keeps the compact 120 so the 9 tiles still
   fit on a phone without two-row tile labels. */
@media (min-width: 720px) {
  .wl-thumb { width: 220px; height: 220px; }
}

.wl-thumb:not(.loaded) {
  opacity: 0.5;
}

.wl-thumb.loaded {
  opacity: 1;
  box-shadow: 0 0 12px rgba(0,0,0,0.4);
}

.wl-thumb img,
.wl-thumb canvas {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 50%;
}

.wl-thumb-spinner {
  width: 16px;
  height: 16px;
  border: 2px solid var(--border);
  border-top-color: var(--accent-corona);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* Colour name now leads the tile (was the raw "94 Å"). Two usability testers
   (2026-07-28) independently called nine angstrom numbers unreadable; one
   asked for "Green / Golden / Amber with a one-word mood". The wavelength is
   still on the tile, demoted to .wl-sci. */
.wl-value {
  font-family: inherit;
  font-size: 1.05rem;
  font-weight: 600;
  letter-spacing: 0.01em;
}
/* The science, kept but quiet. */
.wl-sci {
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.72rem;
}

.wl-label {
  font-size: 0.72rem;
  color: var(--text-dim);
  margin-top: 4px;
  line-height: 1.3;
}

/* Preview Products Button */
.btn-preview {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  padding: 11px;
  margin-top: 14px;
  background: transparent;
  color: var(--accent-cool);
  font-size: 0.9rem;
  font-weight: 600;
  border: 1.5px solid var(--accent-cool);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all var(--transition);
  font-family: var(--sans);
  letter-spacing: 0.02em;
}
.btn-preview:hover:not(:disabled) {
  background: rgba(52,152,219,0.1);
  transform: translateY(-1px);
}
.btn-preview:disabled { opacity: 0.4; cursor: not-allowed; }

/* Generate Button */
.btn-generate {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  padding: 14px;
  margin-top: 18px;
  background: linear-gradient(135deg, var(--accent-sun), var(--accent-flare));
  /* white measured 2.18:1 (sun end) / 3.28:1 (flare end) against this
     gradient, both failing WCAG AA — the same bug class Tier 1 fixed on
     Add to Cart, missed here. Found during the Tier 3 token pass,
     2026-08-17; ~13 more instances of the same pattern exist elsewhere in
     this file (flagged, not fixed in this pass — see the session report). */
  color: var(--text-on-gold);
  font-size: 1rem;
  font-weight: 700;
  border: none;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all var(--transition);
  font-family: var(--sans);
  letter-spacing: 0.02em;
}

.btn-generate:hover:not(:disabled) {
  transform: translateY(-2px);
  box-shadow: 0 8px 30px rgba(255,94,58,0.35);
}

.btn-generate:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Progress bar */
.progress-track {
  width: 100%;
  height: 6px;
  background: var(--bg-elevated);
  border-radius: 3px;
  margin-top: 14px;
  overflow: hidden;
  display: none;
}

.progress-track.active { display: block; }

.progress-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--accent-corona), var(--accent-cool));
  border-radius: 3px;
  transition: width 0.4s ease;
}

.status-msg {
  font-size: 0.85rem;
  color: var(--text-secondary);
  margin-top: 8px;
  min-height: 20px;
  display: flex;
  align-items: center;
  gap: 8px;
}

.status-msg .spinner {
  width: 14px; height: 14px;
  border: 2px solid var(--border);
  border-top-color: var(--accent-corona);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

@keyframes spin { to { transform: rotate(360deg); } }

/* Image Display.
   Sharp corners on the editor canvas — beta tester pointed out that the
   rounded image-stage corners weren't a faithful preview of the final
   physical product (acrylic / canvas / poster prints have square edges).
   Outer panel still has its own rounding via .section. */
.image-stage {
  /* Default backdrop is solid black so transparency in the canvas
     reads as "what you'll get printed against a dark fabric/medium"
     rather than a generic editor checker. The .image-stage--checker
     modifier opts back into the transparency-checker pattern for
     users who want to visualise alpha explicitly. */
  position: relative;
  background-color: #000;
  border-radius: 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  /* User-resizable editor (proportional + centered). The corner handle
     sets --editor-scale (0.4–1, persisted per device).
     `width: fit-content` makes the stage HUG the canvas — no side
     letterbox — so the handle scales the image directly across its
     full range instead of first collapsing empty black bars. The
     scale is applied as a max-width cap (× available width) plus the
     canvas height cap below; both shrink together so the image scales
     linearly regardless of which axis is binding. margin-inline:auto
     centers it in BOTH the static grid cell and the floating
     (absolute, left:0/right:0) embed state. */
  width: fit-content;
  max-width: calc(100% * var(--editor-scale, 1));
  margin-inline: auto;
}
/* Empty / loading states have no canvas to hug, so give them a
   definite box (and the dashed placeholder room). */
.image-stage.empty,
.image-stage.loading {
  width: 100%;
  min-height: 200px;
}

/* Resize handle — bottom-right corner grip. Pointer + touch (one
   pointer model). Sits above the canvas; its pointerdown stops
   propagation so it never triggers canvas pan/crop. */
.editor-resize-handle {
  position: absolute;
  right: 0;
  bottom: 0;
  width: 22px;
  height: 22px;
  z-index: 7;
  cursor: nwse-resize;
  touch-action: none;
  opacity: 0.55;
  transition: opacity 0.15s ease;
  background:
    linear-gradient(-45deg,
      transparent 0 6px,
      rgba(255, 255, 255, 0.7) 6px 7px,
      transparent 7px 10px,
      rgba(255, 255, 255, 0.7) 10px 11px,
      transparent 11px 14px,
      rgba(255, 255, 255, 0.7) 14px 15px,
      transparent 15px);
}
.editor-resize-handle:hover,
.editor-resize-handle:focus-visible {
  opacity: 0.95;
}
@media (pointer: coarse) {
  .editor-resize-handle { width: 32px; height: 32px; }
}

html.dark .image-stage {
  background-color: #000;
}

/* Opt-in: classic editor checker pattern. Not used today; kept so
   a future "show transparency" toggle can flip the class. */
.image-stage.image-stage--checker {
  background-color: #fff;
  background-image:
    linear-gradient(45deg, #c8c8c8 25%, transparent 25%),
    linear-gradient(-45deg, #c8c8c8 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, #c8c8c8 75%),
    linear-gradient(-45deg, transparent 75%, #c8c8c8 75%);
  background-size: 16px 16px;
  background-position: 0 0, 0 8px, 8px -8px, -8px 8px;
}
html.dark .image-stage.image-stage--checker {
  background-color: #1a1814;
  background-image:
    linear-gradient(45deg, #3a362c 25%, transparent 25%),
    linear-gradient(-45deg, #3a362c 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, #3a362c 75%),
    linear-gradient(-45deg, transparent 75%, #3a362c 75%);
}

.image-stage.empty {
  background: var(--bg-elevated) !important;
  background-image: none !important;
  border: 2px dashed var(--border);
}

.image-stage.empty::after {
  content: 'Your solar image will appear here';
  color: var(--text-dim);
  font-size: 0.9rem;
}

/* Loading spinner inside the image stage while a wavelength preview
   is being fetched. Beta tester reported the canvas looked frozen
   between tile click and image arrival ("had to be clicked a
   couple of times"); now a centred spinner makes the load visible
   so users don't re-click. JS toggles .loading on/off in
   loadHelioviewerPreview / _installPreviewImage. */
.image-stage.loading::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 56px;
  height: 56px;
  margin: -28px 0 0 -28px;
  border: 4px solid rgba(255, 255, 255, 0.16);
  border-top-color: var(--accent-corona, #d9a91a);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  z-index: 5;
  pointer-events: none;
}
.image-stage.loading::after {
  /* Subtle "Loading…" label below the spinner so the affordance is
     obvious even on very small canvases. */
  content: "Loading…";
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, calc(-50% + 48px));
  font-size: 0.78rem;
  color: rgba(255, 255, 255, 0.7);
  letter-spacing: 0.04em;
  z-index: 5;
  pointer-events: none;
}

#solarCanvas {
  max-width: 100%;
  /* Height cap scales with the user's --editor-scale so tall/narrow
     products shrink proportionally too (width already scales via the
     stage). */
  max-height: calc(70vh * var(--editor-scale, 1));
  width: auto;
  height: auto;
  display: block;
  cursor: crosshair;
  object-fit: contain;
  transform-origin: center center;
  transition: transform 0.15s ease;
}

#solarImg {
  display: none;
}

/* Edit Tools */
.edit-toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 14px;
}

.edit-toolbar-row {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  /* Flex item of .edit-toolbar. Without this floor it keeps
     min-width:auto and holds its min-content width (~640px), so it
     punches straight out of the editor's toolbar column no matter
     how narrow that column gets — nothing nested inside can shrink
     past an ancestor that refuses to. */
  min-width: 0;
}

.edit-toolbar-break {
  width: 100%;
  height: 0;
  flex-basis: 100%;
}

/* Tab bar */
.edit-tab-bar {
  display: flex;
  align-items: center;
  gap: 4px;
  flex-basis: 100%;
  width: 100%;
  border-bottom: 1px solid var(--border);
  padding-bottom: 6px;
  margin-top: 4px;
}
/* Reset button lives in the tab bar, pushed to the far right */
.edit-btn-reset {
  margin-left: auto;
  padding: 4px 12px;
  font-size: 0.78rem;
  opacity: 0.75;
}
.edit-btn-reset:hover { opacity: 1; }

.edit-tab {
  padding: 5px 16px;
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  color: var(--text-secondary);
  font-size: 0.82rem;
  font-family: var(--sans);
  cursor: pointer;
  transition: all var(--transition);
}

.edit-tab:hover { color: var(--text-primary); border-color: var(--border); }
.edit-tab.active {
  /* `--accent-corona-text` is the brightened variant of the same
     purple, tuned for legibility on dark backgrounds (~6:1 against
     #1a1a2e). The original --accent-corona stays on the border so
     the visual identity is unchanged. */
  color: var(--accent-corona-text);
  border-color: var(--accent-corona);
  background: rgba(217, 169, 26,0.08);
  font-weight: 600;
}

/* Clock tab specific styling — when visible (wall_clock selected) the tab
   pulses gently so first-time users notice the clock-face customization
   controls. The pulse stops once the user activates the tab. */
.edit-tab-clock:not(.hidden):not(.active) {
  border-color: rgba(34, 197, 94, 0.45);
  color: #22c55e;
  font-weight: 600;
  animation: clock-tab-pulse 1.6s ease-in-out infinite;
}
@keyframes clock-tab-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.0); }
  50%      { box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18); }
}
.edit-tab-clock.active {
  color: #22c55e;
  border-color: #22c55e;
  background: rgba(34,197,94,0.08);
  animation: none;
}

/* Tab content panels */
.edit-tab-panel {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  flex-basis: 100%;
  width: 100%;
  padding-top: 8px;
}

.edit-tab-panel.hidden { display: none; }

/* Friendly hint shown when no tab panel is expanded — leans on the
   "defaults are already great" message so the editor doesn't intimidate
   on first open. Hidden via .hidden when a panel is active. */
.edit-tab-hint {
  flex-basis: 100%;
  width: 100%;
  margin-top: 6px;
  padding: 12px 14px;
  border-radius: var(--radius-sm, 8px);
  background: linear-gradient(135deg, rgba(217, 169, 26, 0.06), rgba(240, 199, 94, 0.06));
  border: 1px dashed rgba(217, 169, 26, 0.18);
  color: var(--text-secondary, #9898b8);
  font-size: 0.82rem;
  display: flex;
  align-items: center;
  gap: 10px;
}
.edit-tab-hint.hidden { display: none; }
.edit-tab-hint i {
  color: var(--accent-corona, #b692ff);
  font-size: 0.95rem;
}

.edit-btn {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 14px;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  font-size: 0.82rem;
  font-family: var(--sans);
  cursor: pointer;
  transition: all var(--transition);
}

.edit-btn:hover { border-color: var(--accent-corona); background: rgba(217, 169, 26,0.06); }
.edit-btn.active { border-color: var(--accent-corona); background: rgba(217, 169, 26,0.12); color: var(--accent-corona-text, #f3d385); }

.edit-btn i { font-size: 0.85rem; }

/* Clock button: more apparent when visible (wall clock product selected) */
.edit-btn-clock:not(.hidden) {
  background: rgba(34, 197, 94, 0.12);
  border-color: #22c55e;
  color: #16a34a;
  font-weight: 600;
  box-shadow: 0 0 0 1px rgba(34, 197, 94, 0.2);
}
.edit-btn-clock:not(.hidden):hover {
  background: rgba(34, 197, 94, 0.2);
  box-shadow: 0 0 12px rgba(34, 197, 94, 0.25);
}
.edit-btn-clock.active { border-color: #22c55e; background: rgba(34, 197, 94, 0.2); color: #16a34a; }

/* Timestamp position picker — 2×3 of small radio cells. Sits inline
   alongside the Tools-tab buttons; hidden until the user enables the
   timestamp so it doesn't crowd the panel for users who don't need it. */
.timestamp-pos {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-left: 6px;
  padding: 6px 10px;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.06);
}
.timestamp-pos.hidden { display: none; }
.timestamp-pos-label {
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
}
.timestamp-pos-grid {
  display: grid;
  grid-template-columns: repeat(3, 22px);
  grid-template-rows: repeat(2, 22px);
  gap: 4px;
}
.timestamp-pos-cell {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.06);
  color: var(--text-secondary, #9898b8);
  font-size: 0.7rem;
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.timestamp-pos-cell input[type="radio"] { display: none; }
.timestamp-pos-cell:hover {
  color: var(--text-primary, #e9eaf5);
  background: rgba(217, 169, 26, 0.1);
}
.timestamp-pos-cell:has(input:checked) {
  background: linear-gradient(135deg, rgba(217, 169, 26, 0.35), rgba(240, 199, 94, 0.35));
  border-color: rgba(217, 169, 26, 0.6);
  color: #fff;
}

/* Vertical-offset slider — sits to the right of the position grid so
   the user can nudge the caption inward from the chosen edge. */
.timestamp-pos-offset {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 130px;
}
.timestamp-pos-offset input[type="range"] {
  width: 70px;
  height: 4px;
}
.timestamp-pos-offset .slider-val {
  font-size: 0.7rem;
  font-weight: 600;
  color: var(--text-secondary, #9898b8);
  min-width: 18px;
  text-align: right;
}

/* Clock color presets — sits inline with the colour picker. Smaller
   than the standard preset row so we can fit four chips + a picker
   on one line, and tagged with the same `.preset-btn` styling so the
   active-state highlight matches the crop/vignette presets above. */
.clock-color-presets {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
}
.clock-color-presets .clock-color-preset {
  padding: 4px 10px;
  font-size: 0.72rem;
}

/* Preset buttons row */
.preset-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 5px;
  margin-top: 8px;
  width: 100%;
}

.preset-row-label {
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-right: 2px;
  white-space: nowrap;
}

/* Inline axis-letter chip ("X", "Y") that sits between the two
   Hard/Soft button pairs on the combined Crop Edge preset row.
   Tighter than .preset-row-label so the four buttons + label still
   fit one line on a desktop editor column. */
.preset-axis-label {
  font-size: 0.7rem;
  font-weight: 700;
  color: var(--text-secondary);
  opacity: 0.9;
  padding: 0 2px;
  margin-left: 6px;
}
.preset-axis-label:first-of-type {
  margin-left: 0;
}

.preset-btn {
  font-size: 0.72rem !important;
  padding: 3px 8px !important;
  border-style: dashed !important;
  opacity: 0.85;
}

.preset-btn:hover {
  opacity: 1;
  border-style: solid !important;
}

/* Slider Row */
.slider-row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 10px;
  /* Let the −/＋ step buttons (Cole P0) wrap to a second line on
     narrow viewports instead of squeezing the track to nothing. */
  flex-wrap: wrap;
}

/* ── Slider step buttons (Cole Ramirez round-2 motor/AT, P0) ──────
   Visible −/＋ controls flanking every range input so Voice Control
   can address them by name and Switch Control can scan them. 32px
   (38px on coarse pointers) clears WCAG 2.5.8 target size. The
   global :focus-visible ring from the a11y pass applies as-is. */
.slider-step-btn {
  flex: none;
  width: 32px;
  height: 32px;
  min-width: 32px;
  padding: 0;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--bg-elevated);
  color: var(--text-primary);
  font-size: 1.15rem;
  line-height: 1;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation; /* no 300ms tap delay / double-tap zoom */
  transition: background var(--transition), border-color var(--transition);
}
.slider-step-btn:hover {
  border-color: var(--border-active);
  background: var(--bg-card);
}
.slider-step-btn:active {
  transform: translateY(1px);
}
@media (pointer: coarse) {
  .slider-step-btn {
    width: 38px;
    height: 38px;
    min-width: 38px;
  }
}
/* Dense text-effect rows pack a range input next to color pickers and
   toggles; allow them to wrap so the added step buttons never force
   horizontal overflow on mobile. */
.field-row { flex-wrap: wrap; }

.slider-row label {
  font-size: 0.82rem;
  color: var(--text-secondary);
  min-width: 80px;
}

.slider-row input[type="range"] {
  flex: 1;
  accent-color: var(--accent-corona);
  height: 10px;
}

/* Hank P1 (round-2 ergonomics): bump slider hit-area above Fitts' Law
   threshold. Native ~14px thumbs on 6px tracks were below the ~9.6mm
   trackpad acquisition sweet spot, and with 6-9 sliders stacked per
   tab a 30-min session was a first-dorsal-interosseous fatigue / RSI
   risk. Track 6→10px, thumb ~14→22px (26px on coarse pointers). The
   thumb's negative margin-top keeps it centered: -(22-10)/2 = -6px.
   Applies to every range input in the editor (slider-row, text-tool,
   clock numbers, vignette, etc.); the compact .timestamp-pos-offset
   inline offset keeps its own smaller height. */
input[type="range"]::-webkit-slider-runnable-track {
  height: 10px;
  border-radius: 5px;
}
input[type="range"]::-moz-range-track {
  height: 10px;
  border-radius: 5px;
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  margin-top: -6px;
}
input[type="range"]::-moz-range-thumb {
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: none;
}
@media (pointer: coarse) {
  input[type="range"]::-webkit-slider-thumb {
    width: 26px;
    height: 26px;
    margin-top: -8px;
  }
  input[type="range"]::-moz-range-thumb {
    width: 26px;
    height: 26px;
  }
}

.slider-row .slider-val {
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.8rem;
  color: var(--text-dim);
  min-width: 40px;
  text-align: right;
}

.vignette-fade-row {
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: 12px;
  margin-top: 8px;
}

.vignette-fade-toggle {
  flex-shrink: 0;
}

.vignette-fade-row .vignette-fade-color-label {
  margin-left: auto;
  margin-right: 0;
}

.vignette-fade-row .vignette-color-input {
  width: 32px;
  height: 32px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  cursor: pointer;
  background: var(--bg-card);
  flex-shrink: 0;
}

/* Background toggle: Color option shows current color as face; picker opens on click */
.filter-opt-color {
  position: relative;
  min-width: 36px;
  padding-left: 10px;
  padding-right: 10px;
}
.filter-opt-color .vignette-fade-color-picker {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: pointer;
  padding: 0;
  border: none;
}
.filter-opt-color .filter-opt-color-label {
  position: relative;
  z-index: 0;
  pointer-events: none;
  color: inherit;
}

/* Non-blocking loading indicator pinned to the upper-right of the quality
   timeline, next to the "Quality" label, so it doesn't shift the step row. */
.filter-loading-indicator {
  position: absolute;
  top: 4px;
  right: 10px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 0.7rem;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.filter-loading-indicator.hidden {
  display: none !important;
}
.filter-loading-spinner {
  width: 14px;
  height: 14px;
  border: 2px solid var(--border);
  border-top-color: var(--accent-corona);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

/* Product Cards */
.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 16px;
}

.product-card {
  background: var(--bg-elevated);
  border: 2px solid var(--border);
  border-radius: var(--radius-md);
  overflow: hidden;
  cursor: pointer;
  transition: all var(--transition);
}

/* Hover = subtle lift + soft neutral border tint. NOT the same color
   as the .selected sun-orange ring (friction audit: agent reported
   the hover glow read as a permanent "you selected this" state and
   followed the cursor card to card, making the actual selection
   ambiguous). Selected stays a thicker orange ring + glow. */
.product-card:hover {
  border-color: var(--text-dim);
  transform: translateY(-3px);
  box-shadow: 0 8px 25px rgba(0,0,0,0.2);
}

.product-card.selected {
  border-color: var(--accent-sun);
  box-shadow: 0 0 0 2px var(--accent-sun), 0 0 24px var(--glow-sun);
}
.product-card.selected:hover {
  border-color: var(--accent-sun);
}

.product-preview {
  width: 100%;
  aspect-ratio: 1;
  background: var(--bg-deep);
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  overflow: hidden;
}

.product-preview canvas {
  max-width: 70%;
  max-height: 70%;
}

/* Cold-load mockup skeleton (product-first refactor, commit 4).
   When the defaultMockupManifest hasn't resolved yet, the product
   cards render with empty preview boxes. The skeleton animates a
   subtle shimmer so the empty state reads as "loading" rather than
   "broken". Removed by JS on the second renderProducts() call after
   the manifest lands. */
.product-grid.is-loading-mockups .product-preview {
  background: linear-gradient(110deg,
    var(--bg-deep) 30%,
    var(--bg-elevated) 50%,
    var(--bg-deep) 70%);
  background-size: 200% 100%;
  animation: productMockupShimmer 1.4s ease-in-out infinite;
}
.product-grid.is-loading-mockups .product-preview::after {
  content: "Loading photoreal previews…";
  position: absolute;
  inset: auto 0 14px;
  font-size: 0.68rem;
  color: var(--text-dim);
  text-align: center;
  letter-spacing: 0.04em;
}
@keyframes productMockupShimmer {
  from { background-position: 100% 0; }
  to   { background-position: -100% 0; }
}

.product-preview .product-icon {
  font-size: 3rem;
  color: var(--text-dim);
}

/* Tiny upper-left glyph that persists once the mockup loads. Beta testers
   asked for an at-a-glance product-type indicator so they can scan the grid
   without reading each name — this is the same fa-icon that renders pre-load,
   shrunk to a corner badge. The backdrop is semi-opaque for legibility on
   both light-product and dark-product mockups. */
/* Faint popularity counter, caddy-corner (bottom-right) from the type
   icon. Shows "buys | clicks-that-didn't-convert"; tooltip "buys |
   clicks". The grid sorts by these same numbers. */
.product-preview .product-stats-badge {
  position: absolute;
  bottom: 8px;
  right: 8px;
  padding: 2px 7px;
  font-size: 11px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.03em;
  color: rgba(255, 255, 255, 0.62);
  background: rgba(10, 12, 22, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.10);
  border-radius: 8px;
  cursor: help;
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  z-index: 2;
}
.product-preview .product-icon-badge {
  position: absolute;
  top: 8px;
  left: 8px;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10, 12, 22, 0.72);
  color: rgba(255, 255, 255, 0.92);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 10px;
  font-size: 24px;
  pointer-events: none;
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  z-index: 2;
}

.product-info {
  padding: 14px;
}

.product-name {
  font-weight: 600;
  font-size: 1rem;
}

.product-desc {
  font-size: 0.8rem;
  color: var(--text-secondary);
  margin-top: 4px;
}

.product-price {
  font-family: 'JetBrains Mono', monospace;
  font-weight: 500;
  color: var(--accent-corona-text, #f3d385);
  margin-top: 6px;
}

/* Order Button */
.btn-order {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  padding: 16px;
  margin-top: 18px;
  /* #5c4bbf was the last live purple in the app, 13 months after the
     2026-07-29 warm-palette migration — matches .btn-generate's own
     gradient partner now. Tier 3 polish pass, 2026-08-17. */
  background: linear-gradient(135deg, var(--accent-corona), var(--accent-flare));
  /* Same WCAG fix as .btn-generate above (white failed against this gradient). */
  color: var(--text-on-gold);
  font-size: 1.05rem;
  font-weight: 700;
  border: none;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all var(--transition);
  font-family: var(--sans);
}

.btn-order:hover:not(:disabled) {
  transform: translateY(-2px);
  box-shadow: 0 8px 30px var(--glow-corona);
}

.btn-order:disabled { opacity: 0.4; cursor: not-allowed; }

.btn-secondary {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 20px;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  font-size: 0.9rem;
  font-family: var(--sans);
  cursor: pointer;
  transition: all var(--transition);
  text-decoration: none;
}

.btn-secondary:hover { border-color: var(--accent-corona); }

/* Modal */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.6);
  backdrop-filter: blur(6px);
  display: flex;
  align-items: center;
  justify-content: center;
  /* z-index bumped above the feedback FAB cluster (9990) so the
     orange/purple Request + Comment pills don't bleed on top of every
     showInfo dialog. Scroll/popup reviewer flagged this as BLOCKER. */
  z-index: 9996;
  padding: 20px;
  animation: fadeIn 0.2s ease;
}
/* Belt-and-suspenders: also hide the FAB cluster while ANY modal is
   open (body.modal-open is toggled by showInfo / showConfirmSelectModal
   / showColorChooser). Covers edge cases where stacking contexts
   conspire to keep FAB visible despite the z bump. */
body.modal-open .feedback-fab-group { display: none !important; }
/* Belt and braces for the same collision: while consent is pending, get the
   floating pills out of the way entirely. (:has() is well supported; the
   z-index rule above is the fallback for anything that lacks it.) */
body:has(.cookie-banner:not(.hidden)) .feedback-fab-group { display: none !important; }

@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }

.modal-box {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 28px;
  max-width: 420px;
  width: 100%;
  animation: slideUp 0.3s ease;
}

@keyframes slideUp { from { transform:translateY(20px); opacity:0; } to { transform:translateY(0); opacity:1; } }

.modal-box h3 { margin-bottom: 12px; font-size: 1.1rem; }

.modal-box p { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 16px; line-height: 1.5; }

.modal-actions {
  display: flex;
  gap: 10px;
  justify-content: flex-end;
}

.modal-actions button {
  padding: 8px 18px;
  border-radius: var(--radius-sm);
  font-family: var(--sans);
  font-size: 0.9rem;
  cursor: pointer;
  border: 1px solid var(--border);
  background: var(--bg-elevated);
  color: var(--text-primary);
  transition: all var(--transition);
}

.modal-actions .btn-confirm {
  background: var(--accent-corona);
  border-color: var(--accent-corona);
  color: #fff;
}

/* Loading state for the modal's Create/Confirm button. While the Promise
   returned by onConfirm is pending the button shows a spinner + label so
   users see the click registered and compute is underway. Both buttons are
   disabled until the handler settles. */
.modal-actions .btn-confirm.is-loading {
  opacity: 0.88;
  cursor: progress;
  /* Keep background full-strength so "loading" reads as active, not inert. */
}
.modal-actions button:disabled {
  cursor: progress;
  opacity: 0.7;
}
.modal-actions .btn-confirm .btn-spinner {
  display: inline-block;
  width: 12px;
  height: 12px;
  border: 2px solid rgba(255, 255, 255, 0.35);
  border-top-color: #fff;
  border-radius: 50%;
  animation: btn-spin 0.7s linear infinite;
  vertical-align: -2px;
  margin-right: 6px;
}
@keyframes btn-spin {
  to { transform: rotate(360deg); }
}

.hidden { display: none !important; }

/* Info tip */
.info-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 12px;
  padding: 10px 14px;
  background: rgba(217, 169, 26,0.06);
  border-radius: var(--radius-sm);
  border-left: 3px solid var(--accent-corona);
}

.info-row i { color: var(--accent-corona); }
.info-row span { font-size: 0.82rem; color: var(--text-secondary); }

/* ── Vibe grid (above-the-fold curated moments) ─────────────────────
   Six cards in a 3-col grid on desktop, 2-col on tablet, 1-col on mobile.
   Each card is a real <button>: clicking pre-fills date+wl+time in the
   configurator below and scrolls to it. The thumbnail lives in a circular
   well so the Sun reads as a Sun even before the image loads. */
.vibe-grid-section { margin-bottom: 24px; }
.vibe-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 14px;
}
@media (max-width: 880px) { .vibe-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 520px) { .vibe-grid { grid-template-columns: 1fr; } }

.vibe-card {
  position: relative;
  display: flex;
  flex-direction: column;
  background: rgba(255,255,255,0.03);
  border: 1px solid rgba(255,255,255,0.08);
  border-radius: var(--radius-md, 14px);
  transition: background 160ms ease, border-color 160ms ease, transform 160ms ease;
  overflow: visible;  /* allow info-popover to escape */
}
.vibe-card:hover {
  background: rgba(255,179,71,0.06);
  border-color: rgba(255,179,71,0.35);
}
@media (prefers-reduced-motion: no-preference) {
  .vibe-card:hover { transform: translateY(-1px); }
}

/* The main click target — wraps thumb + meta. */
.vibe-open {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 14px;
  background: transparent;
  border: 0;
  border-radius: var(--radius-md, 14px);
  cursor: pointer;
  text-align: left;
  color: inherit;
  font: inherit;
  width: 100%;
}
.vibe-open:focus-visible {
  outline: 2px solid var(--accent-corona);
  outline-offset: -2px;
}

/* Controls cluster (Raw/RHEF + info) sits top-right, layered over thumb.
   Hidden by default; .has-tiers reveals the toggle once the manifest
   confirms a Raw/RHEF pair exists for this slug. */
.vibe-controls {
  position: absolute;
  top: 8px; right: 8px;
  display: flex;
  align-items: center;
  gap: 6px;
  z-index: 2;
  pointer-events: none;  /* let clicks pass to .vibe-open by default */
}
.vibe-controls > * { pointer-events: auto; }
.vibe-tier-toggle {
  display: none;  /* shown via .has-tiers below */
  background: rgba(0,0,0,0.55);
  backdrop-filter: blur(6px);
  border-radius: 999px;
  padding: 2px;
  border: 1px solid rgba(255,255,255,0.10);
}
.vibe-card.has-tiers .vibe-tier-toggle { display: inline-flex; }

/* Click feedback — testers reported a multi-second pause with no visual
   confirmation that tapping a vibe card had been received. Dim the card
   and overlay a soft spinner ring until _installPreviewImage clears the
   class (or the 12s backstop in _activateVibe fires). */
.vibe-card.is-loading {
  pointer-events: none;
  opacity: 0.85;
}
.vibe-card.is-loading::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 38px;
  height: 38px;
  margin: -19px 0 0 -19px;
  border-radius: 50%;
  border: 3px solid rgba(255, 179, 71, 0.25);
  border-top-color: var(--accent-corona);
  animation: spin 0.9s linear infinite;
  z-index: 5;
  pointer-events: none;
}
.vibe-card.is-loading::before {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(8, 10, 20, 0.32);
  border-radius: inherit;
  pointer-events: none;
}
.vibe-tier-btn {
  background: transparent;
  color: var(--text-secondary, #d0c8d8);
  border: 0;
  padding: 4px 10px;
  border-radius: 999px;
  font: inherit;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  cursor: pointer;
  min-height: 24px;
  transition: background 120ms ease, color 120ms ease;
}
.vibe-tier-btn.is-active {
  background: var(--accent-corona);
  color: var(--text-on-gold);
}
.vibe-tier-btn:focus-visible {
  outline: 2px solid var(--accent-corona);
  outline-offset: 1px;
}
.vibe-info-btn {
  background: rgba(0,0,0,0.55);
  backdrop-filter: blur(6px);
  color: var(--text-secondary, #d0c8d8);
  border: 1px solid rgba(255,255,255,0.10);
  border-radius: 999px;
  width: 28px; height: 28px;
  display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease;
}
.vibe-info-btn:hover { color: var(--accent-corona); }
.vibe-info-btn:focus-visible { outline: 2px solid var(--accent-corona); outline-offset: 1px; }

/* Attribution popover (created dynamically — kept stylable here). */
/* Narrative reveal CTA — a single pulsing orange button above the
   vibe grid. Cards start in Raw mode; clicking this button kicks off
   a radial-wipe transition to RHEF across every card, then fades the
   button out and reveals the per-card Raw/RHEF toggles. The "Ohhh and
   ahhh" hook at the start of the user experience. */
.vibe-reveal-cta {
  display: flex;
  justify-content: center;
  margin: 4px 0 18px;
}
/* !important: #vibeRevealCta and body.step-image both set display with
   !important, which silently overrode this and left a JS-"hidden" element
   painted on top of the workflow breadcrumb, swallowing clicks on the only
   back control (usability sweep, 2026-07-28). "hidden" must always win. */
.vibe-reveal-cta.hidden { display: none !important; }
.vibe-reveal-cta.is-dismissed { opacity: 0; pointer-events: none; transition: opacity 600ms ease-out; }
.vibe-reveal-btn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 14px 24px;
  font: inherit;
  font-size: 1.0rem;
  font-weight: 700;
  color: var(--text-on-gold);
  background: linear-gradient(135deg, var(--accent-corona) 0%, var(--accent-flare) 100%);
  border: 0;
  border-radius: 999px;
  cursor: pointer;
  box-shadow: 0 4px 18px rgba(255,179,71,0.35);
  min-height: 48px;
  letter-spacing: 0.01em;
}
.vibe-reveal-btn i { font-size: 1.05rem; }
/* Subtitle next to the main CTA label — explains WHAT "filtered view"
   means without making the user click to find out. Friction audit
   flagged the unexplained CTA as competing with the vibe cards. */
.vibe-reveal-sub {
  display: inline-block;
  margin-left: 8px;
  font-size: 0.78rem;
  font-weight: 500;
  opacity: 0.85;
  letter-spacing: 0;
}
@media (max-width: 520px) {
  .vibe-reveal-sub { display: block; margin: 4px 0 0; }
}
.vibe-reveal-btn:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .vibe-reveal-btn {
    animation: vibeRevealPulse 2.2s ease-in-out infinite;
  }
  .vibe-reveal-btn:hover {
    transform: translateY(-1px);
    box-shadow: 0 6px 24px rgba(255,179,71,0.55);
  }
}
@keyframes vibeRevealPulse {
  0%, 100% { box-shadow: 0 4px 18px rgba(255,179,71,0.35), 0 0 0 0 rgba(255,179,71,0.55); }
  50%      { box-shadow: 0 4px 18px rgba(255,179,71,0.35), 0 0 0 14px rgba(255,179,71,0.00); }
}

/* ── Workflow step-machine section visibility (commit 3) ──────
   Body classes drive which sections are visible per step. The
   .hidden class was removed from #productSection's initial markup
   so it's visible by default; CSS body.step-product hides everything
   else.

   Step "product" (cold landing): products only. Vibe-grid + configure
   + editor hidden.
   Step "image" (variant picked): vibe-grid + configure visible.
   Product section + editor hidden — the breadcrumb pill is how the
   user navigates back to product.
   Step "editor" (image picked): editor visible. Product + vibe-grid
   + configure hidden — the editor's own UI takes over.

   Each rule uses !important to defeat any leftover JS-managed .hidden
   class. JS no longer manages .hidden on these sections under the
   new flow, but be defensive in case a legacy code path adds it back. */
body.step-product .vibe-grid-section,
body.step-product #configSection,
body.step-product #editSection,
body.step-product #vibeRevealCta {
  display: none !important;
}
/* Retired (2026-08-17, Gilly): this was a second, independently-editable
   control for the same raw/enhanced decision the handoff bridge now makes
   once, up front, for every visitor — not just 3D-experience handoffs (see
   _installPreviewImage's commit branch). Two controls for one decision let
   them drift out of sync ("the toggle seems unwired"). The vibe cards' own
   reveal-ramp animation (state.vibeMasterTier) is untouched and still runs
   on its own timer as ambient showcase flavor; only this manual override
   button is hidden. Was `display: flex !important` — the highest-specificity
   rule actually keeping it on-screen on step "image"; see also the base
   #vibeRevealCta rule further down, edited the same way. */
body.step-image #vibeRevealCta {
  display: none !important;
}
body.step-image .vibe-reveal-btn { display: none !important; }
body.step-image .vibe-master-toggle { display: inline-flex !important; }
body.step-image #productSection,
body.step-image #editSection {
  display: none !important;
}
body.step-editor #productSection,
body.step-editor .vibe-grid-section,
body.step-editor #configSection {
  display: none !important;
}
/* When a product is selected (step image/editor), .product-card
   stays in DOM but we override .hidden so the user can see their
   pick rendered with the editor canvas in scroll-back view. NOT
   implemented in commit 3 — defer to a future polish pass that
   adds a "selected product mini preview" affordance. */

/* ── Workflow breadcrumb (product-first refactor, commit 2) ──────
   Sticky-top compact bar. Hosts back-pills for completed steps.
   Stacks ABOVE the master tier toggle (`#vibeRevealCta`) on step
   "image" — friction-audit agent 2 reported both were fixed-top
   centered and visually collided. The breadcrumb takes top:8px,
   the master toggle below it takes top:52px (offset = pill height
   + gap). */
.workflow-breadcrumb {
  position: fixed;
  top: max(8px, env(safe-area-inset-top, 0px));
  left: 50%;
  transform: translateX(-50%);
  z-index: 52;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0;
  pointer-events: none;
}
/* On step "image" the master toggle sits below the breadcrumb. */
body.step-image #vibeRevealCta {
  /* !important so the blanket `#vibeRevealCta { top: 8px !important }` below
     can't collapse this back onto the breadcrumb. Higher specificity than
     that rule, so this wins on desktop; the max-width:749px override later
     still wins on mobile, where the breadcrumb is hidden anyway. */
  top: max(52px, calc(env(safe-area-inset-top, 0px) + 44px)) !important;
}
/* Mobile: the breadcrumb + master-toggle stack ate ~100px of the
   viewport's top chrome on a 390x844 iPhone (persona-sweep finding).
   Hide the breadcrumb on narrow screens — the product name is still
   accessible via the editor's "Change product" button and the master
   toggle is the only thing that needs constant access on step image. */
@media (max-width: 749px) {
  .workflow-breadcrumb { display: none !important; }
  body.step-image #vibeRevealCta {
    top: max(8px, env(safe-area-inset-top, 0px)) !important;
  }
}
/* Backstop: hard-hide the preview pane on the product/image steps so
   it can never leak into them after browser-back, regardless of JS
   class timing (single-preview-mode is cleared in _applyStep). */
body.step-product #selectedProductPreview,
body.step-image #selectedProductPreview {
  display: none !important;
}
.workflow-breadcrumb > * { pointer-events: auto; }
/* Start-over control in the breadcrumb (desktop). Mirrors the pill look
   but muted so it doesn't compete with the step pills. */
.workflow-startover-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  font-size: 0.8rem;
  color: var(--text-secondary);
  background: var(--surface-2, rgba(255,255,255,0.04));
  border: 1px solid var(--border-subtle, rgba(255,255,255,0.12));
  border-radius: 999px;
  cursor: pointer;
}
.workflow-startover-btn:hover,
.workflow-startover-btn:focus-visible {
  color: var(--accent-corona-text, #f3d385);
  border-color: var(--accent-corona-text, #f3d385);
}
.workflow-breadcrumb.hidden { display: none !important; }
.workflow-breadcrumb-pills {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.workflow-breadcrumb-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  border-radius: 999px;
  background: rgba(0,0,0,0.55);
  backdrop-filter: blur(6px);
  border: 1px solid rgba(255,179,71,0.30);
  color: var(--text-primary, #eeeef6);
  font: inherit;
  font-size: 0.82rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  cursor: pointer;
  transition: background 160ms ease, border-color 160ms ease;
}
.workflow-breadcrumb-pill:hover,
.workflow-breadcrumb-pill:focus-visible {
  background: rgba(0,0,0,0.72);
  border-color: var(--accent-corona);
}
.workflow-breadcrumb-pill .arrow {
  font-size: 0.78rem;
  opacity: 0.7;
}
.workflow-breadcrumb-sep {
  color: var(--text-dim, #8e8eab);
  font-size: 0.85rem;
  opacity: 0.6;
}

/* Master Preview / Original / Filtered segmented toggle. Sticky-pinned
   to the top of the viewport so the user can flip tiers while scrolled
   into the product picker — the original placement at the vibe-grid
   header was off-screen by the time the products were visible. Hidden
   once the editor engages since the editor has its own Quality
   timeline. */
.vibe-master-toggle {
  display: inline-flex;
  background: rgba(0,0,0,0.55);
  backdrop-filter: blur(6px);
  border-radius: 999px;
  padding: 4px;
  border: 1px solid rgba(255,179,71,0.30);
  gap: 2px;
}
.vibe-master-toggle.hidden { display: none !important; }
/* Floating placement. position:fixed escapes the .section parent and
   pins to the viewport so the toggle stays visible while the user
   scrolls from the vibe-card section down into the product picker.
   Centered horizontally; top-padded clear of the address bar on mobile
   (env(safe-area-inset-top) for notched devices). The first-reveal
   pulsing button (.vibe-reveal-btn) also floats here during its brief
   pre-reveal lifetime, then is hidden by JS once clicked. */
/* !important on position guards against any later rule (or browser
   quirk involving ancestor transforms) downgrading the float to
   static. z-index sits above .workflow-breadcrumb (52) so when the
   user scrolls and both stack at the top, the toggle is the upper
   layer rather than the lower. */
/* Defence in depth — every property that controls "is this floating
   at the top of the viewport?" is !important so no other rule (or
   future regression) can quietly drop the toggle into the document
   flow. Reviewer & Gilly both kept hitting "the toggle isn't sticking";
   strengthening the rules here closes the door on it. */
#vibeRevealCta {
  position: fixed !important;
  top: max(8px, env(safe-area-inset-top, 0px)) !important;
  left: 50% !important;
  transform: translateX(-50%) !important;
  z-index: 60 !important;
  display: flex !important;
  flex-direction: column !important;
  align-items: center !important;
  gap: 8px !important;
  margin: 0 !important;  /* override .vibe-reveal-cta margin: 4px 0 18px */
  pointer-events: none;
}
#vibeRevealCta > * { pointer-events: auto; }
/* The vibe-grid section reserves space for the toggle's original (in-flow)
   slot so the floating widget doesn't slide over the section header.
   Adds 64px of breathing room at the top of the vibe grid. */
.vibe-grid-section { padding-top: 64px; }
/* Drop the toggle's float-pin on step "editor" — the editor's Quality
   timeline owns the control surface there. Gating on body.step-editor
   (NOT the prior :has(#editSection:not(.hidden)) selector) so the
   selector doesn't keep matching after a popstate that leaves the
   editSection element with .hidden class removed but the section
   visually hidden by body-class CSS — friction-audit agent 3 found
   this left the toggle "position: static" on step "image" after a
   back-nav from editor, so it scrolled out of view. */
body.step-editor #vibeRevealCta {
  /* Editor owns the Quality timeline; hide the master toggle entirely
     instead of dropping it into static flow (which used to leak a
     visible row at the top of the editor on prod). */
  display: none !important;
}
body.step-editor .vibe-master-toggle,
body.step-editor .vibe-reveal-btn {
  display: none;
}

.vibe-master-btn {
  background: transparent;
  /* Pinned, not var(--text-secondary) — .vibe-master-toggle's track
     (rgba(0,0,0,0.55)) never themes, so the light-mode token (#555)
     went unreadable on it (~1.4:1). */
  color: rgba(255, 255, 255, 0.72);
  border: 0;
  padding: 8px 18px;
  border-radius: 999px;
  font: inherit;
  font-size: 0.88rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  cursor: pointer;
  min-height: 36px;
  transition: background 160ms ease, color 160ms ease;
}
.vibe-master-btn.is-active {
  background: var(--accent-corona);
  color: var(--text-on-gold);
}
/* Indicator-first: while a tier request is in flight, the toggle shows
   a subtle pending state on the TARGET pill (where the indicator will
   land if the wipe succeeds). The button doesn't take .is-active until
   _wipeAllCards resolves, so the visible "you are here" indicator can't
   de-sync from the actually-rendered tier. */
.vibe-master-toggle.is-pending .vibe-master-btn { cursor: progress; }
.vibe-master-btn.is-pending-target {
  background: rgba(255,179,71,0.18);
  color: var(--accent-corona);
  position: relative;
}
.vibe-master-btn.is-pending-target::after {
  content: "";
  display: inline-block;
  width: 9px; height: 9px;
  margin-left: 8px;
  border: 1.5px solid rgba(255,179,71,0.30);
  border-top-color: var(--accent-corona);
  border-radius: 50%;
  vertical-align: middle;
  animation: spin 0.7s linear infinite;
}
.vibe-master-btn:focus-visible {
  outline: 2px solid var(--accent-corona);
  outline-offset: 2px;
}
@media (pointer: coarse) {
  .vibe-master-btn { min-height: 44px; padding: 10px 22px; }
}

/* Radial-wipe transition overlay: the new RHEF (or Raw) image is
   appended over the current thumb image with clip-path: circle(0%
   at center), then animated out to circle(150%). The underlying img
   is removed once the animation ends; see _runTierWipe() in
   solar-archive.js. */
.vibe-thumb-overlay {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 50%;
  z-index: 1;
  clip-path: circle(0% at 50% 50%);
}
@media (prefers-reduced-motion: no-preference) {
  .vibe-thumb-overlay.is-wiping {
    animation: vibeThumbWipe 720ms ease-out forwards;
  }
}
@media (prefers-reduced-motion: reduce) {
  /* Snap rather than animate when reduced-motion is requested. */
  .vibe-thumb-overlay.is-wiping {
    clip-path: circle(150% at 50% 50%);
  }
}
@keyframes vibeThumbWipe {
  0%   { clip-path: circle(0% at 50% 50%); opacity: 0.6; }
  20%  { opacity: 1; }
  100% { clip-path: circle(150% at 50% 50%); opacity: 1; }
}

/* HEK event-tile selected styling — make it loud enough that the
   X9.3-match pre-check (or any selection-follows-focus pick) is
   obviously "the one." Adds a checkmark + brighter background +
   ring on top of the existing aria-checked = orange-tint style. */
.hek-tile[role="radio"][aria-checked="true"] {
  background: rgba(255,179,71,0.22);
  border-color: var(--accent-corona);
  box-shadow: 0 0 0 1px var(--accent-corona) inset, 0 6px 18px rgba(255,179,71,0.18);
}
.hek-tile[role="radio"][aria-checked="true"]::after {
  content: "\f00c";  /* Font Awesome check (solid) */
  font-family: "Font Awesome 6 Free";
  font-weight: 900;
  position: absolute;
  top: 8px;
  right: 10px;
  color: var(--accent-corona);
  font-size: 0.85rem;
  opacity: 0.85;
}

.vibe-info-popover {
  position: absolute;
  top: 44px; right: 8px;
  z-index: 10;
  max-width: min(280px, calc(100% - 16px));
  padding: 12px 14px 12px 14px;
  background: var(--bg-card); /* was hard-pinned dark under theme-switching text tokens below -- same invisible-in-light-mode bug fixed elsewhere tonight */
  border: 1px solid rgba(255,179,71,0.35);
  border-radius: var(--radius-sm, 10px);
  box-shadow: 0 8px 24px rgba(0,0,0,0.55);
  font-size: 0.80rem;
  color: var(--text-secondary, #d0c8d8);
  line-height: 1.4;
}
.vibe-info-popover.hidden { display: none; }
.vibe-info-popover h4 {
  margin: 0 0 6px;
  font-size: 0.85rem;
  color: var(--text-primary, #fff);
}
.vibe-info-popover p { margin: 0 0 6px; }
.vibe-info-popover p:last-child { margin-bottom: 0; }
.vibe-info-popover a { color: var(--accent-corona); }
.vibe-info-close {
  position: absolute;
  top: 4px; right: 6px;
  background: transparent;
  color: var(--text-dim, #9a93a8);
  border: 0;
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  padding: 4px 6px;
}
.vibe-info-close:hover { color: var(--text-primary, #fff); }

/* "Your day" birthday card — no thumb; date input + hint only. */
.vibe-card-birthday { background: linear-gradient(135deg, rgba(255,179,71,0.06), rgba(217, 169, 26,0.06)); border-color: rgba(255,179,71,0.20); }
.vibe-birthday-inner {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 18px 16px;
  height: 100%;
  justify-content: center;
  text-align: left;
}
.vibe-birthday-icon {
  color: var(--accent-corona);
  font-size: 1.8rem;
}
.vibe-birthday-meta { display: flex; flex-direction: column; gap: 2px; margin-bottom: 6px; }
.vibe-birthday-label {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-dim, #9a93a8);
  font-weight: 600;
}
.vibe-birthday-input {
  padding: 10px 12px;
  background: rgba(0,0,0,0.30);
  color: var(--text-primary, #fff);
  border: 1px solid rgba(255,255,255,0.15);
  border-radius: 8px;
  font: inherit;
  font-size: 1.0rem;
  min-height: 44px;
  width: 100%;
}
.vibe-birthday-input:focus-visible { outline: 2px solid var(--accent-corona); outline-offset: 1px; }
.vibe-birthday-submit {
  margin-top: 4px;
  padding: 11px 14px;
  min-height: 44px;
  width: 100%;
  border: 0;
  border-radius: 8px;
  font: inherit;
  font-weight: 600;
  font-size: 1.0rem;
  cursor: pointer;
  color: #1a1206;
  background: linear-gradient(135deg, var(--accent-corona), var(--accent-flare));
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: filter 0.12s ease, transform 0.06s ease;
}
.vibe-birthday-submit:hover { filter: brightness(1.06); }
.vibe-birthday-submit:active { transform: translateY(1px); }
.vibe-birthday-submit:focus-visible { outline: 2px solid var(--accent-corona); outline-offset: 2px; }
.vibe-birthday-hint {
  font-size: 0.78rem;
  color: var(--text-dim, #9a93a8);
}
.vibe-thumb {
  position: relative;
  aspect-ratio: 1 / 1;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 50%, rgba(255,179,71,0.10), rgba(0,0,0,0.4) 70%);
  overflow: hidden;
  display: flex; align-items: center; justify-content: center;
}
.vibe-thumb img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; display: block; }
/* JPG plate-scale match. Helioviewer takeScreenshot at 1024² covers
   FOV = 3000 arcsec (image_scale = 3000/1024 ≈ 2.93 arcsec/px). SDO/AIA
   L1 FITS rendered at 3000² with sunpy's native extent covers
   FOV ≈ 2458 arcsec (4096 × 0.6). So the JPG shows the disk at 2458/3000
   = 0.82× the relative size the science image shows. We scale the JPG up
   by 3000/2458 ≈ 1.22× so its disk co-registers with the Raw/RHEF disk
   when toggling. This crops ~11% off each edge of the JPG (off-limb
   empty space — no info loss at the card thumb size). The alternative
   — shrinking the science to match — would leave a transparent margin
   since FITS data doesn't extend past 2458 arcsec. */
.vibe-thumb img.is-jpg-tier { transform: scale(1.22); transform-origin: center; }
/* The Preview tier's sqrt display stretch is baked into the committed
   raw_thumb.png files at build time (see infra scripts), so no runtime
   filter is applied. Arbitrary (non-committed) dates get the stretch
   client-side where their raw frame is displayed. */
.vibe-thumb.is-loading::after {
  content: "";
  position: absolute; inset: 0;
  background: radial-gradient(circle at 50% 50%, rgba(255,179,71,0.18), transparent 70%);
}
.vibe-meta { display: flex; flex-direction: column; gap: 2px; }
.vibe-title { font-weight: 700; font-size: 1.0rem; color: var(--text-primary, #fff); }
.vibe-sub   { font-size: 0.85rem; color: var(--text-secondary, #d0c8d8); }
.vibe-date  { font-size: 0.78rem; color: var(--text-dim, #9a93a8); margin-top: 2px; }

/* ── HEK 4-tile picker (replaces the single badge) ───────────────────
   3 event tiles + 1 custom-time tile. Section is visible-but-skeleton
   during HEK loading; never hidden on quiet/error days (landmark
   consistency per a11y agent). */
.hek-picker-section {
  margin-top: 12px;
  padding: 14px;
  background: rgba(255,179,71,0.05);
  border: 1px solid rgba(255,179,71,0.18);
  border-radius: var(--radius-md, 14px);
}
.hek-picker-section.hidden { display: none; }
.hek-picker-header { margin-bottom: 10px; }
.hek-picker-title  { margin: 0; font-size: 1.0rem; color: var(--text-primary, #fff); font-weight: 600; }
.hek-picker-sub    { margin: 4px 0 0; font-size: 0.82rem; color: var(--text-secondary, #d0c8d8); }
/* Plain-language footnote under the event tiles — deliberately quiet. */
.hek-picker-note   { margin: 10px 2px 0; font-size: 0.72rem; line-height: 1.45;
                     color: var(--text-secondary, #d0c8d8); opacity: 0.72; }
.hek-tile-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 10px;
}
@media (max-width: 760px) { .hek-tile-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }

.hek-tile {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 10px 12px;
  background: rgba(0,0,0,0.18);
  border: 1px solid rgba(255,255,255,0.08);
  border-radius: var(--radius-sm, 10px);
  cursor: pointer;
  text-align: left;
  color: inherit;
  font: inherit;
  position: relative;
  transition: background 160ms ease, border-color 160ms ease;
  /* WCAG 2.5.5 / 2.5.8 target-size: guarantee 64px+ hit area regardless
     of inner content height. Inner flex column will still grow past
     this for multi-line tiles. */
  min-height: 64px;
}
.hek-tile:hover { background: rgba(255,179,71,0.10); border-color: rgba(255,179,71,0.45); }
.hek-tile:focus-visible { outline: 2px solid var(--accent-corona); outline-offset: 2px; }
.hek-tile[aria-checked="true"] {
  background: rgba(255,179,71,0.15);
  border-color: var(--accent-corona);
}
.hek-tile.is-top { border-left: 3px solid var(--accent-corona); }
.hek-tile.is-fallback { border-left-color: rgba(160,160,160,0.45); background: rgba(255,255,255,0.02); }
.hek-tile-rank {
  font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--text-dim, #9a93a8); font-weight: 600;
}
.hek-tile.is-top .hek-tile-rank { color: var(--accent-corona); }
.hek-tile-time { font-size: 1.05rem; font-weight: 700; color: var(--text-primary, #fff); }
.hek-tile-type { font-size: 0.82rem; color: var(--text-secondary, #d0c8d8); }
.hek-tile-meta { font-size: 0.72rem; color: var(--text-dim, #9a93a8); }

/* GOES class badges — three redundant channels per WCAG 1.4.1 (color
   alone isn't enough; icon-shape + letter + color). */
.hek-tile-goes {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px 6px;
  border-radius: 4px;
  font-size: 0.72rem; font-weight: 700;
  background: rgba(255,255,255,0.06);
}
.hek-tile-goes[data-cls="X"] { background: rgba(255,80,80,0.20); color: #ffb0b0; }
.hek-tile-goes[data-cls="M"] { background: rgba(255,160,60,0.18); color: #ffd089; }
.hek-tile-goes[data-cls="C"] { background: rgba(255,220,80,0.12); color: #ffe18a; }

/* Fine-tune custom-time tile — [-] / display / [+] row.
   Replaces the earlier <select>+Apply pattern. ±1 min per click, with
   a 1.5 s debounce in JS before the JPG previews refresh. The display
   doubles as the live aria-live readout. */
.hek-tile-custom {
  background: rgba(255,255,255,0.03);
  cursor: default;
}
.hek-tile-custom:hover { background: rgba(255,179,71,0.06); }
.hek-tile-custom .hek-tile-rank { color: var(--accent-corona); }
.hek-time-fine {
  display: flex;
  gap: 8px;
  align-items: center;
  justify-content: space-between;
  margin-top: 4px;
}
.hek-time-step {
  flex: 0 0 auto;
  width: 44px; height: 44px;     /* WCAG 2.5.5 hit-area */
  background: rgba(255,179,71,0.12);
  color: var(--accent-corona);
  border: 1px solid rgba(255,179,71,0.40);
  border-radius: 999px;
  font: inherit; font-size: 1.25rem; font-weight: 700;
  line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer;
  transition: background 120ms ease, transform 120ms ease;
}
.hek-time-step:hover { background: rgba(255,179,71,0.22); }
@media (prefers-reduced-motion: no-preference) {
  .hek-time-step:active { transform: scale(0.95); }
}
.hek-time-step:focus-visible {
  outline: 2px solid var(--accent-corona);
  outline-offset: 2px;
}
@media (pointer: coarse) {
  .hek-time-step { width: 48px; height: 48px; }
}
.hek-time-display {
  flex: 1 1 auto;
  text-align: center;
  font-size: 1.25rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text-primary, #fff);
  letter-spacing: 0.02em;
}
/* Exact-time picker row — sits below the [-]/[+] cluster. Uses the
   native <input type="time"> so the user gets the OS's clock picker
   (analog clock on iOS, time spinner on desktop). Same debounced
   refresh as the step buttons. */
.hek-time-exact {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px dashed rgba(255,255,255,0.08);
}
.hek-time-exact-label {
  font-size: 0.75rem;
  color: var(--text-dim, #9a93a8);
}
.hek-time-exact-input {
  padding: 6px 10px;
  background: rgba(0,0,0,0.30);
  color: var(--text-primary, #fff);
  border: 1px solid rgba(255,255,255,0.15);
  border-radius: 6px;
  font: inherit;
  font-size: 0.95rem;
  min-height: 36px;
  font-variant-numeric: tabular-nums;
}
.hek-time-exact-input:focus-visible {
  outline: 2px solid var(--accent-corona);
  outline-offset: 1px;
}
@media (pointer: coarse) {
  .hek-time-exact-input { min-height: 44px; }
}
.hek-custom-utc { font-size: 0.72rem; color: var(--text-dim, #9a93a8); }

.hek-time-scrub {
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px dashed rgba(255,255,255,0.08);
}
.hek-time-scrub-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 4px;
}
.hek-time-scrub-input {
  flex: 1;
  accent-color: var(--accent-corona);
}
.hek-time-scrub-well {
  width: 40px;
  height: 40px;
  flex: 0 0 auto;
  border-radius: 6px;
  background: rgba(0,0,0,0.30);
  border: 1px solid rgba(255,255,255,0.15);
  overflow: hidden;
}
.hek-time-scrub-well img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.hek-time-scrub-well.is-loading {
  background: repeating-linear-gradient(
    45deg, rgba(255,255,255,0.04), rgba(255,255,255,0.04) 6px,
    rgba(255,255,255,0.08) 6px, rgba(255,255,255,0.08) 12px
  );
}

/* Skeleton tiles during loading. */
.hek-tile.is-skeleton {
  background: rgba(255,255,255,0.04);
  border-color: rgba(255,255,255,0.06);
  cursor: default;
  pointer-events: none;
  min-height: 80px;
}
@media (prefers-reduced-motion: no-preference) {
  .hek-tile.is-skeleton::before {
    content: "";
    position: absolute; inset: 0;
    background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.05) 50%, transparent 100%);
    animation: hekShimmer 1.4s ease-in-out infinite;
  }
}
@keyframes hekShimmer {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(100%); }
}

/* Wavelength auto-suggest caption (above the wavelength grid).
   Surfaces "we picked 304 Å because the event was a prominence" so the
   user understands the recommendation isn't random. */
.wl-suggest-caption {
  margin: 10px 0 6px;
  padding: 6px 10px;
  font-size: 0.78rem;
  color: var(--text-secondary, #d0c8d8);
  background: rgba(217, 169, 26,0.06);
  border-left: 3px solid var(--accent-corona);
  border-radius: var(--radius-sm, 8px);
}
.wl-suggest-caption.hidden { display: none; }
.wl-suggest-caption strong { color: var(--text-primary, #fff); font-weight: 600; }
.wl-card.is-suggested { box-shadow: 0 0 0 2px rgba(255,179,71,0.55) inset; }

/* HEK best-time badge — sits between the info-row and the wavelength grid.
   Visually parallel to .info-row (same padding / radius) but uses a
   warmer accent so it reads as a per-date suggestion rather than a
   static notice. The dot before the label gets a subtle pulse to draw
   the eye when the time auto-fills. */
.hek-event-info {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 8px;
  padding: 8px 14px;
  background: rgba(255,179,71,0.08);
  border-radius: var(--radius-sm);
  border-left: 3px solid var(--accent-corona);
  font-size: 0.82rem;
  color: var(--text-secondary);
  line-height: 1.35;
}
.hek-event-info.hidden { display: none; }
.hek-event-info.is-loading { opacity: 0.7; }
.hek-event-info.is-fallback { border-left-color: rgba(160,160,160,0.5); background: rgba(255,255,255,0.03); }
.hek-event-info strong { color: var(--text-primary); font-weight: 600; }
.hek-event-info .hek-dot {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent-corona); flex: 0 0 7px;
}
.hek-event-info.is-fallback .hek-dot { background: rgba(160,160,160,0.6); }
@media (prefers-reduced-motion: no-preference) {
  .hek-event-info:not(.is-fallback) .hek-dot {
    animation: hekPulse 2.4s ease-in-out 1;
  }
}
@keyframes hekPulse {
  0%   { box-shadow: 0 0 0 0 rgba(255,179,71,0.45); }
  60%  { box-shadow: 0 0 0 8px rgba(255,179,71,0); }
  100% { box-shadow: 0 0 0 0 rgba(255,179,71,0); }
}

/* Crop overlay */
.crop-overlay {
  position: absolute;
  border: 2px dashed rgba(255,255,255,0.7);
  background: rgba(217, 169, 26,0.1);
  pointer-events: none;
  z-index: 5;
}
.crop-overlay.circular {
  border-radius: 50%;
}

/* Backend status banner */
.backend-banner {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 18px;
  border-radius: var(--radius-md);
  margin-bottom: 20px;
  animation: fadeInUp 0.5s ease-out;
  font-size: 0.9rem;
  flex-wrap: wrap;
}

.backend-banner.checking {
  background: rgba(56,189,248,0.08);
  border: 1px solid rgba(56,189,248,0.25);
  color: var(--accent-cool);
}

.backend-banner.online {
  background: rgba(61,220,132,0.08);
  border: 1px solid rgba(61,220,132,0.25);
  color: #3ddc84;
}

.backend-banner.offline {
  background: rgba(255,94,58,0.08);
  border: 1px solid rgba(255,94,58,0.3);
  color: var(--accent-flare);
}

.backend-banner.waking {
  background: rgba(247,168,37,0.08);
  border: 1px solid rgba(247,168,37,0.3);
  color: var(--accent-corona-text, #f3d385);
}

.backend-banner .banner-icon {
  font-size: 1.1rem;
  flex-shrink: 0;
}

.backend-banner .banner-text {
  flex: 1;
  min-width: 0;
}

.backend-banner .banner-text strong {
  display: block;
  margin-bottom: 2px;
}

.backend-banner .banner-text small {
  color: var(--text-secondary);
  font-size: 0.78rem;
}

.backend-banner .banner-actions {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.backend-banner .banner-btn {
  padding: 6px 14px;
  border-radius: 6px;
  font-family: var(--sans);
  font-size: 0.82rem;
  cursor: pointer;
  border: 1px solid var(--border);
  background: var(--bg-elevated);
  color: var(--text-primary);
  transition: all var(--transition);
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.backend-banner .banner-btn:hover {
  border-color: var(--accent-corona);
}

/* Footer */
.app-footer {
  text-align: center;
  padding: 30px 0 10px;
  color: var(--text-dim);
  font-size: 0.78rem;
}

.app-footer a { color: var(--accent-cool); text-decoration: none; }

/* Footer-legal nav (Privacy / Terms / Refunds / Shipping / Data credits)
   sits above the build-info row so legal links are the prominent footer
   element required for Shopify Payments / Stripe operation. */
.footer-legal-nav {
  margin-bottom: 8px;
  display: inline-flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: center;
  gap: 4px 10px;
}
.footer-legal-nav a {
  color: var(--text-secondary, #9898b8);
  font-weight: 500;
  font-size: 0.82rem;
}
.footer-legal-nav a:hover {
  color: var(--text-primary, #eeeef6);
  text-decoration: underline;
}
.footer-legal-nav .footer-link-btn {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  color: var(--text-secondary, #9898b8);
  font-weight: 500;
  font-size: 0.82rem;
  font-family: inherit;
}
.footer-legal-nav .footer-link-btn:hover {
  color: var(--text-primary, #eeeef6);
  text-decoration: underline;
}
.footer-meta {
  font-size: 0.74rem;
  color: var(--text-dim, #8e8eab);
}
/* Bumped to 0.72rem + opacity .82 — fix for footer-build-low-contrast
   WCAG finding (was 0.5 opacity). */
.footer-build-tag {
  font-family: 'JetBrains Mono', monospace;
  font-size: 0.72rem;
  /* was opacity:0.82 — pushed --text-dim under 4.5:1 on cream (axe). */
}

/* ── Cookie consent banner (GDPR/ePrivacy minimum) ────────────────
   Shown on first visit OR after the user clears their localStorage.
   Sticky-bottom, two buttons (Accept / Decline), persists choice. */
.cookie-banner {
  position: fixed;
  left: 16px;
  right: 16px;
  /* Lift above the feedback FAB cluster (which sits at bottom: 18px,
     height ~52px). 90px clears it on mobile so the banner doesn't
     cover the Request / Comment pills (persona-sweep finding). */
  bottom: calc(90px + env(safe-area-inset-bottom, 0px));
  /* ABOVE the FAB cluster (9990). At bottom:16px on desktop the banner sits
     in the same zone as the pills, and at 9989 they painted on top of it —
     covering the "Accept" button (usability sweep, 2026-07-28). A consent
     gate should always win the stacking contest. */
  z-index: 9995;
  background: var(--bg-card);
  backdrop-filter: blur(8px);
  border: 1px solid var(--border, #2a2a44);
  border-radius: 14px;
  box-shadow: 0 18px 48px rgba(0, 0, 0, 0.45);
  color: var(--text-secondary, #9898b8);
  font-size: 0.85rem;
  line-height: 1.45;
}
@media (min-width: 720px) {
  .cookie-banner { bottom: 16px; }
}
.cookie-banner.hidden { display: none; }
.cookie-banner-inner {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 16px 18px;
  max-width: 920px;
  margin: 0 auto;
}
@media (min-width: 720px) {
  .cookie-banner-inner {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    gap: 24px;
  }
}
.cookie-banner-body strong { color: var(--text-primary, #eeeef6); display: inline; margin-right: 4px; font-size: inherit; }
.cookie-banner-body p { margin: 0; display: inline; }
/* Compact strip (2026-09-14): the banner was a floating card that sat on the
   primary CTA. Now a slim bar flush to the bottom edge. The 90px mobile lift
   above the feedback FABs is obsolete: those FABs are already display:none
   while the banner shows (see body:has(.cookie-banner:not(.hidden))). */
.cookie-banner {
  left: 0;
  right: 0;
  bottom: 0;
  border-radius: 0;
  border-width: 1px 0 0;
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.35);
  font-size: 0.8rem;
  line-height: 1.35;
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
@media (min-width: 720px) {
  .cookie-banner { bottom: 0; }
}
.cookie-banner-inner {
  flex-direction: row;
  align-items: center;
  gap: 12px;
  padding: 8px 16px;
}
/* Buttons keep their original 8px 14px padding: shrinking them made 34px
   tap targets on a 390px phone, which is the wrong trade for a thinner bar. */
.cookie-banner-body a { color: var(--accent-corona-text, #f3d385); }
.cookie-banner-actions {
  display: flex;
  gap: 8px;
  flex-shrink: 0;
  justify-content: flex-end;
}
.cookie-banner-btn {
  padding: 8px 14px;
  border-radius: 8px;
  border: 1px solid var(--border, #2a2a44);
  background: transparent;
  color: var(--text-secondary, #9898b8);
  font-weight: 600;
  font-size: 0.82rem;
  cursor: pointer;
  transition: background 140ms ease, color 140ms ease, border-color 140ms ease;
}
.cookie-banner-btn:hover { background: rgba(217, 169, 26, 0.10); color: var(--text-primary, #eeeef6); border-color: var(--accent-corona, #d9a91a); }
.cookie-banner-btn-accept {
  background: var(--accent-corona, #d9a91a);
  color: #141210;  /* ink on light gold: 9.9:1 (white was 1.7:1) */
  border-color: var(--accent-corona, #d9a91a);
}
.cookie-banner-btn-accept:hover {
  background: #806200;
  color: #fff;
}

/* Notification toast */
.toast {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%) translateY(80px);
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 12px 22px;
  font-size: 0.9rem;
  box-shadow: 0 8px 30px rgba(0,0,0,0.3);
  z-index: 200;
  transition: transform 0.4s cubic-bezier(0.34,1.56,0.64,1);
  display: flex;
  align-items: center;
  gap: 10px;
}

.toast.show { transform: translateX(-50%) translateY(0); }
/* Lift clear of the sticky editor action bar. A tester watched the
   "193 Å loaded!" toast sit directly on top of the Buy button (usability
   sweep, 2026-07-28). Also click-to-dismiss — see showToast(). */
body.step-editor .toast { bottom: 104px; }
@media (max-width: 749px) { body.step-editor .toast { bottom: 120px; } }
/* The confirm overlay's two look cards and its CTA sit centered
   mid-viewport; a bottom-anchored toast (e.g. a "generating..." message
   while a preview warms) could land on top of the Enhanced card (audit
   finding). body.handoff-confirm is already toggled by JS whenever the
   overlay opens/closes, so reuse it: while the overlay is open, park the
   toast at the top instead, clear of both cards and the CTA. */
body.handoff-confirm .toast {
  top: 20px;
  bottom: auto;
  transform: translateX(-50%) translateY(-80px);
}
body.handoff-confirm .toast.show { transform: translateX(-50%) translateY(0); }
.toast { cursor: pointer; }
.toast.success { border-color: #3ddc84; }
.toast.error { border-color: var(--accent-flare); }
.toast.info { border-color: var(--accent-cool); }
/* Clear the cookie banner's footprint while it's open — otherwise the
   toast renders inside/behind it (banner z-index 9995 vs toast 200).
   Heights are estimated from the banner's own layout (not
   live-measured): ~170px stacked on mobile, ~80px single-row on
   desktop, plus the banner's own bottom offset and a 24px gap. */
body:has(.cookie-banner:not(.hidden)) .toast {
  bottom: calc(284px + env(safe-area-inset-bottom, 0px));
}
@media (min-width: 720px) {
  body:has(.cookie-banner:not(.hidden)) .toast {
    bottom: 120px;
  }
}
/* ...but NOT while the handoff overlay is open, where the toast is parked at
   the top instead (see body.handoff-confirm .toast above).
   `body:has(.cookie-banner:not(.hidden))` scores (0,3,1) — :has() takes its
   argument's specificity — which beats `body.handoff-confirm .toast` at
   (0,2,1). So with the banner open the toast kept `top: 20px` from one rule
   AND got `bottom: 284px` from the other, and a fixed element with both
   offsets set STRETCHES: the "171 A loaded!" toast rendered as a full-height
   column straight down the middle of the quad. That is the vertical bar the
   2026-08-26 audit spotted and could not reproduce afterwards — it needs the
   cookie banner and the handoff open at the same time, which only happens on
   a first visit arriving from the film. */
body.handoff-confirm:has(.cookie-banner:not(.hidden)) .toast,
body.handoff-confirm:has(.cookie-banner:not(.hidden)) .toast.show {
  top: 20px;
  bottom: auto;
}

/* HQ button */
.btn-hq {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  padding: 12px;
  margin-top: 12px;
  background: linear-gradient(135deg, #3498db, var(--accent-corona));
  color: #fff;
  font-size: 0.95rem;
  font-weight: 600;
  border: none;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all var(--transition);
  font-family: var(--sans);
}

.btn-hq:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 25px rgba(52,152,219,0.3); }
.btn-hq:disabled { opacity: 0.4; cursor: not-allowed; }

/* Crop ratio buttons */
.crop-ratios {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 10px;
}

.crop-ratio-btn {
  padding: 6px 12px;
  font-size: 0.78rem;
  font-family: 'JetBrains Mono', monospace;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text-secondary);
  cursor: pointer;
  transition: all var(--transition);
}

.crop-ratio-btn:hover, .crop-ratio-btn.active {
  border-color: var(--accent-corona);
  color: var(--accent-corona-text, #f3d385);
}

.product-crop-btn {
  background: rgba(255,150,50,0.1);
  border-color: var(--accent-sun);
  color: var(--accent-corona-text, #f3d385);
  font-size: 0.72rem;
}

.product-crop-btn.active {
  background: rgba(255,150,50,0.2);
  border-color: var(--accent-corona);
  color: var(--accent-corona-text, #f3d385);
}

/* Text tool panel */
.text-tool-panel {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 14px;
  margin-top: 10px;
}

.text-tool-panel .field-row {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 10px;
  flex-wrap: wrap;
}

.text-overflow-warn {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: -2px 0 10px;
  padding: 8px 10px;
  border-radius: var(--radius-sm, 6px);
  background: rgba(245, 166, 35, 0.12);
  border: 1px solid rgba(245, 166, 35, 0.35);
  color: var(--accent-sun);
  font-size: 0.8rem;
  line-height: 1.3;
}
.text-overflow-warn.hidden { display: none; }

.text-tool-panel input[type="text"],
.text-tool-panel input[type="number"],
.text-tool-panel select {
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 8px 10px;
  font-size: 16px;
  font-family: var(--sans);
  color: var(--text-primary);
  flex: 1;
  min-width: 0;
}

.text-tool-panel input[type="color"] {
  width: 36px;
  height: 36px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-card);
  cursor: pointer;
  padding: 2px;
  flex-shrink: 0;
}

.text-tool-panel .field-label-sm {
  font-size: 0.78rem;
  color: var(--text-dim);
  min-width: 50px;
  flex-shrink: 0;
}

.text-effect-row {
  border-top: 1px solid var(--border);
  padding-top: 8px;
  margin-top: 4px;
}

.toggle-label {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 0.78rem;
  color: var(--text-secondary);
  cursor: pointer;
  min-width: 50px;
  white-space: nowrap;
}

.toggle-label input[type="checkbox"] {
  accent-color: var(--accent-corona);
  width: 16px;
  height: 16px;
}

/* Overlay / guide-line checkboxes row inside Geometry tab */
.guide-checks {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  flex-basis: 100%;
  width: 100%;
  padding: 4px 0 2px;
}

#textFontSelect optgroup {
  font-family: var(--sans);
  font-weight: 600;
  color: var(--text-secondary);
  font-size: 0.82rem;
  padding: 4px 0;
}

.text-tool-hint {
  font-size: 0.78rem;
  color: var(--text-dim);
  margin-top: 6px;
  display: flex;
  align-items: center;
  gap: 6px;
}

.text-tool-hint i { color: var(--accent-cool); }

/* Dragging text cursor */
.text-dragging {
  cursor: move !important;
}

/* Catalog modal */
/* Catalog CSS removed — product creation handled on Printify's site */

/* Variant info panel on product cards */
.variant-panel {
  border-top: 1px solid var(--border);
  padding-top: 8px;
  margin-top: 6px;
  max-height: 200px;
  overflow-y: auto;
}

.variant-loading {
  font-size: 0.75rem;
  color: var(--text-secondary);
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 0;
}

.variant-summary {
  font-size: 0.75rem;
}

.variant-count {
  display: inline-block;
  background: var(--accent-corona);
  color: #fff;
  font-size: 0.68rem;
  font-weight: 600;
  padding: 2px 8px;
  border-radius: 10px;
  margin-bottom: 6px;
}

.variant-group {
  margin-top: 4px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 3px;
}

.variant-group-label {
  font-weight: 600;
  color: var(--text-secondary);
  font-size: 0.7rem;
  margin-right: 2px;
}

.variant-tag {
  display: inline-block;
  background: var(--bg-deep);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 1px 6px;
  font-size: 0.68rem;
  color: var(--text-secondary);
  white-space: nowrap;
}

.variant-color {
  background: rgba(217, 169, 26,0.08);
  border-color: rgba(217, 169, 26,0.2);
}

.variant-more {
  font-size: 0.68rem;
  color: var(--text-dim);
  font-style: italic;
}

.variant-selected-msg {
  font-size: 0.7rem;
  font-weight: 600;
  color: var(--accent-cool);
  margin-top: 4px;
  margin-bottom: 6px;
}

.variant-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 6px;
}

/* Variant row in the inline pane is read-only: label left, price middle,
   optional "✓ Selected" badge on the active row. Selection happens only
   through the "Pick a variant" modal — these rows just summarise what's
   available so users can see the catalog without committing. */
.variant-row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  align-items: center;
  gap: 8px;
  font-size: 0.78rem;
  padding: 6px 8px;
  border-radius: 6px;
  border: 1px solid transparent;
  transition: background-color 0.15s ease, border-color 0.15s ease;
  color: var(--text-primary);
  background: var(--bg-deep);
}

.variant-list-readonly .variant-row { cursor: default; }
.variant-list-readonly .variant-row:hover { background: var(--bg-deep); }

.variant-row.confirmed {
  border-color: var(--accent-cool);
  background: rgba(217, 169, 26, 0.15);
  font-weight: 600;
}

.variant-row-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.variant-price {
  font-size: 0.74rem;
  color: var(--text-dim);
  font-weight: 500;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.variant-price-empty { min-width: 28px; }

/* Read-only "Selected" pill on the active variant row in the inline pane.
   Replaces the old per-row Select button now that selection lives in the
   modal — keeps the list informative without offering a hidden second
   commit path. */
.variant-row-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 8px;
  font-size: 0.7rem;
  font-weight: 600;
  border-radius: 999px;
  background: var(--accent-cool);
  color: #fff;
  white-space: nowrap;
}
.variant-row-badge i { font-size: 0.62rem; }
/* Defensive: hide any leftover Select buttons from older renders that
   haven't been re-rendered yet. */
.variant-select-btn { display: none !important; }
/* Variant-list expander: collapse long t-shirt/garment lists so the inline
   pane doesn't become a wall of rows. Friction audit found the unfolded
   list dwarfed every other section. */
.variant-list-expander {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin: 8px 0 0;
  padding: 6px 14px;
  border: 1px dashed var(--border);
  border-radius: 999px;
  background: transparent;
  color: var(--text-secondary);
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  cursor: pointer;
  transition: background 0.18s ease, color 0.18s ease, border-color 0.18s ease;
}
.variant-list-expander:hover {
  background: var(--bg-elevated);
  color: var(--text-primary);
  border-color: var(--text-dim);
}
.variant-list-rest-count {
  color: var(--text-dim);
  font-weight: 500;
  font-size: 0.7rem;
}
.variant-list-rest.hidden { display: none; }

.variant-clock-note {
  font-size: 0.7rem;
  color: var(--text-dim);
  margin: 6px 0 0;
  font-style: italic;
}

.variant-pick-hint {
  font-size: 0.72rem;
  color: var(--text-dim);
  margin: 8px 0 0;
  text-align: center;
}
.variant-pick-hint strong { color: var(--accent-cool); }

.mockup-status {
  font-size: 0.82rem;
  color: var(--text-secondary);
  margin-bottom: 10px;
  min-height: 0;
  transition: min-height 0.3s ease;
}

.mockup-status:not(:empty) {
  min-height: 24px;
}

.product-preview img.mockup-img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: var(--radius-sm);
}

/* Buy button on product cards */
.product-buy-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 100%;
  padding: 10px;
  margin-top: 8px;
  background: linear-gradient(135deg, var(--accent-sun), var(--accent-flare));
  color: #fff;
  font-size: 0.85rem;
  font-weight: 600;
  border: none;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: all var(--transition);
  font-family: var(--sans);
}

.product-buy-btn:hover:not(:disabled) {
  transform: translateY(-1px);
  box-shadow: 0 4px 15px rgba(255,94,58,0.3);
}

.product-buy-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  background: var(--bg-elevated);
  color: var(--text-dim);
}

/* Buy / Download button — lives in the sticky editor action bar at
   the bottom of the editor frame. Same style whether it's in the
   preview pane (legacy) or the action bar; the action bar uses
   flex: 1 on its children so width: 100% reads as "fill the flex
   slot." Margin-top removed because the action bar handles its
   own padding and would otherwise misalign the two buttons. */
.btn-buy-in-editor {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  padding: 12px 20px;
  background: linear-gradient(135deg, var(--accent-sun), var(--accent-flare));
  color: #fff;
  font-size: 0.95rem;
  font-weight: 600;
  border: none;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all var(--transition);
  font-family: var(--sans);
}

.btn-buy-in-editor:hover {
  transform: translateY(-1px);
  box-shadow: 0 4px 15px rgba(255, 94, 58, 0.3);
}

.btn-buy-in-editor.hidden {
  display: none;
}

/* Locked state: shown until a real Printify mockup has been generated, so
   the user can verify their preview matches what they'll get on Shopify. */
.btn-buy-in-editor:disabled,
.btn-buy-in-editor.buy-locked {
  background: #333344;
  color: rgba(255, 255, 255, 0.55);
  cursor: not-allowed;
  box-shadow: none;
  transform: none;
}
.btn-buy-in-editor:disabled:hover,
.btn-buy-in-editor.buy-locked:hover {
  transform: none;
  box-shadow: none;
}

/* Checkout progress overlay */
.checkout-progress {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 20px;
  margin-top: 16px;
  text-align: center;
}

.checkout-progress .checkout-step {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 0;
  font-size: 0.88rem;
  color: var(--text-secondary);
}

.checkout-progress .checkout-step.active {
  color: var(--accent-cool);
  font-weight: 500;
}

.checkout-progress .checkout-step.done {
  color: #3ddc84;
}

.checkout-progress .checkout-step i {
  width: 18px;
  text-align: center;
}

/* Escape hatch during the Printify -> Shopify publish wait. Revealed by
   pollShopifyUrl after ~45s so a slow sync never looks like a frozen
   panel with no way forward. */
.checkout-progress .checkout-escape {
  margin-top: 12px;
  padding: 10px 12px;
  border: 1px solid var(--border-subtle, rgba(255,255,255,0.12));
  border-radius: var(--radius-sm, 8px);
  font-size: 0.82rem;
  line-height: 1.45;
  color: var(--text-secondary);
}
.checkout-progress .checkout-escape.hidden { display: none; }
.checkout-progress .checkout-escape a {
  color: var(--accent-corona-text, #f3d385);
  font-weight: 600;
  white-space: nowrap;
}

.btn-shopify-checkout {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 14px 28px;
  margin-top: 14px;
  background: linear-gradient(135deg, #96bf48, #5a8a1c);
  color: #fff;
  font-size: 1rem;
  font-weight: 700;
  border: none;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all var(--transition);
  font-family: var(--sans);
  text-decoration: none;
}

.btn-shopify-checkout:hover {
  transform: translateY(-2px);
  box-shadow: 0 8px 25px rgba(150,191,72,0.35);
}

/* ── Editor layout — CSS grid frame ────────────────────────────
   Four regions: canvas (top-left), preview (top-right, spans the
   canvas + toolbar rows on desktop), toolbar (middle-left), action
   bar (bottom, full-width). On mobile the grid collapses to one
   column: canvas → preview → toolbar → actions stacked vertically.
   Canvas + preview are sticky at the top so they stay visible
   while the toolbar scrolls; the action bar is sticky at the
   bottom so the Generate + Buy/Download buttons never scroll off
   the screen. */
/* Selected-product hero in the editor section header. Gilly: "Let the
   type of object to be printed be centered and at least h3 or h2 as
   appropriate, so it is much more clear which object was selected."
   Lives BELOW the small "3 · View & edit your image" badge row, sized
   like a real page title (1.7rem desktop, scales down on mobile). */
.editor-selected-product-name {
  margin: 6px auto 18px;
  text-align: center;
  font-size: 1.7rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--text-primary, #eeeef6);
  /* Subtle sun-glow underline so the title reads as a marquee. */
  padding-bottom: 8px;
  border-bottom: 2px solid var(--accent-sun, var(--accent-sun));
  max-width: max-content;
}
.editor-selected-product-name:empty { display: none; }
@media (max-width: 600px) {
  .editor-selected-product-name {
    font-size: 1.25rem;
    margin: 4px auto 12px;
    padding-bottom: 6px;
  }
}

.editor-with-preview {
  display: grid;
  grid-template-columns: 1.4fr minmax(260px, 1fr);
  grid-template-areas:
    "canvas  preview"
    "toolbar preview"
    "actions actions";
  gap: 20px 20px;
  align-items: start;
}
/* STRESS-002: at tablet widths below the two-column breakpoint (1100 px) the
   two-column grid was squeezing the toolbar column down to ~150 px,
   below the toolbar's intrinsic content width — friction agent 4
   reported the entire toolbar disappeared with a phantom black gutter
   on the right at 768 px. Force the single-column stack at narrow
   widths so the toolbar always gets a full row of breathing room. */
@media (max-width: 1099px) {
  .editor-with-preview {
    grid-template-columns: 1fr;
    grid-template-areas:
      "canvas"
      "preview"
      "toolbar"
      "actions";
  }
}

.editor-with-preview .image-stage   { grid-area: canvas; }
.editor-with-preview .editor-main   { grid-area: toolbar; min-width: 0; }
.editor-with-preview .selected-product-preview { grid-area: preview; }
.editor-with-preview .editor-action-bar       { grid-area: actions; }

/* ── Single-preview mode ─────────────────────────────────────────
   The editor's working canvas (#imageStage / #solarCanvas) used to
   sit beside a smaller product-mockup preview, showing the source
   sun with overlays on the left and the actual product mockup on
   the right. Two canvases for the same content was confusing — the
   user just wants to see "what does my design look like ON the
   product I picked?". This mode hides the left/editor canvas and
   promotes the right/product-mockup preview into the canvas grid
   area, doubling its visible size. The hidden #solarCanvas stays
   in the DOM as the backing render target — drawProductMockup
   reads from it via getCleanCanvasSnapshot — so every slider /
   colour / crop control still updates the visible mockup via
   refreshLivePreview. Activated by .single-preview-mode on body
   (set by JS once on init; cleared by ?legacy-canvas=1 URL escape
   hatch for power users who still want the editor canvas visible). */
body.single-preview-mode .editor-with-preview .image-stage {
  display: none;
}
/* The pane previously spanned the full dashboard width with a 72vh
   height cap and default-visible overflow: the ratio caption and
   variant rows spilled past the pane border straight over the tip
   bar below it, and the controls landed under the fold. A bounded
   preview column beside the controls + natural pane height fixes
   both, and stacks back to one column under 1100px. */
body.single-preview-mode .editor-with-preview .selected-product-preview {
  width: 100%;
  max-width: none;
}
body.single-preview-mode .editor-with-preview .selected-product-preview .preview-mockup {
  /* Square aspect (matches the underlying canvas backing store),
     centered, fills the preview column. */
  aspect-ratio: 1 / 1;
  max-width: 100%;
  margin: 0 auto;
}
@media (min-width: 1100px) {
  body.single-preview-mode .editor-with-preview {
    /* minmax(0, 1fr), NOT 1fr: a bare `1fr` track has an automatic
       minimum of min-content, so the toolbar column refuses to shrink
       below its widest row and the whole grid overflows the section to
       the right (reported: the QUALITY panel spilling past the card
       edge). Flooring the min at 0 lets the track shrink and the
       toolbar's own flex rows wrap instead. */
    grid-template-columns: minmax(300px, 430px) minmax(0, 1fr);
    grid-template-areas:
      "preview toolbar"
      "actions actions";
  }
}
@media (max-width: 1099px) {
  body.single-preview-mode .editor-with-preview {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "preview"
      "toolbar"
      "actions";
  }
  body.single-preview-mode .editor-with-preview .selected-product-preview {
    max-width: 480px;
    margin-left: auto;
    margin-right: auto;
  }
}
/* Un-stick the preview in the stacked band. The base rule sticks the
   pane at top:20px, which is right in the two-column layout (the pane
   tracks the viewport beside a taller control column, in its own
   track, so it can't touch anything). Stacked, it's actively wrong:
   the pane pins itself over the toolbar row directly beneath it and
   the controls scroll underneath an opaque box (reported: QUALITY and
   the tab bar visible on either side of a floating preview).
   Scoped to ≥750px so the deliberate mobile design below — sticky
   top:0 WITH an opaque background, z-index and a 70vh cap, where the
   preview is the primary canvas — is left intact. */
@media (min-width: 750px) and (max-width: 1099px) {
  body.single-preview-mode .editor-with-preview .selected-product-preview {
    position: static;
  }
}
/* Both toolbar rows are flex items; without an explicit floor they
   inherit min-width:auto and shove the grid track wider than the
   card. (.edit-toolbar-nav-group sets its own above; the timeline
   only had this inside the ≤749px block, so desktop overflowed.) */
.edit-toolbar-row > .filter-timeline { min-width: 0; }
body.single-preview-mode .editor-with-preview .selected-product-preview canvas.live-preview-canvas {
  /* Display at full container width; backing-store resolution is
     bumped in updateSelectedProductPreview so this upscale doesn't
     pixellate. Grab cursor hints that the user can pan the source
     image by dragging on the mockup — the _mobilePan* handlers
     pick this gesture up and update state.panX/panY in REF space.
     Switches to grabbing on :active for the standard direct-
     manipulation feedback. */
  width: 100%;
  height: auto;
  max-width: 100%;
  cursor: grab;
}
body.single-preview-mode .editor-with-preview .selected-product-preview canvas.live-preview-canvas:active {
  cursor: grabbing;
}

/* In single-preview-mode there's nothing to "close" — deselect is
   owned by the "Change product" CTA — so hide the X. Only the
   ?legacy-canvas=1 escape hatch still shows/uses it. */
body.single-preview-mode .selected-product-preview .preview-close-btn {
  display: none;
}

/* Sticky editor canvas — see header comment for rationale. The cap
   on max-height keeps room visible for the toolbar to scroll behind
   it on shorter viewports. */
.editor-with-preview .image-stage {
  position: sticky;
  top: 20px;
  z-index: 5;             /* above the scrolling toolbar behind it */
  max-height: 45vh;       /* keep room for sliders below */
}

/* Sticky action bar — Generate real mockup + Buy/Download buttons.
   `position: sticky; bottom: 0` pins to the bottom of whatever
   scroll container holds it (the page in standalone, the embedded-
   iframe in Shopify after the parent listener resizes). The bar
   has its own dark backdrop so toolbar content scrolling underneath
   doesn't bleed through, plus a soft top fade so the transition
   reads as a docked footer rather than an abrupt cut. Hidden until
   a product is selected (mirrors .selected-product-preview). */
.editor-action-bar {
  position: sticky;
  bottom: 0;
  z-index: 6;
  display: flex;
  gap: 12px;
  padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px));
  background: linear-gradient(
    to top,
    var(--bg-card, #1a1a2e) 0%,
    var(--bg-card, #1a1a2e) 70%,
    rgba(37, 34, 28, 0.92) 100%
  );
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  box-shadow: 0 -10px 28px rgba(0, 0, 0, 0.35);
}
/* At the two-column desktop breakpoint the whole single-preview-mode
   card is only ~650px tall — shorter than most desktop viewports —
   so the sticky bar was never actually needed there; it only ever
   spent ~100px of scroll "catching up" to its resting spot, and
   during that catch-up it slid on top of .edit-tab-bar above it
   (reported: sticky bar overlapping the edit tabs mid-scroll).
   Static removes the overlap with no loss of visibility: measured
   live, the buttons already sit inside the viewport the moment the
   card scrolls into view. Left sticky below 1100px, where the
   stacked single-column layout can genuinely run taller than the
   viewport and a pinned CTA earns its keep. */
@media (min-width: 1100px) {
  body.single-preview-mode .editor-action-bar {
    position: static;
  }
}
.editor-action-bar.hidden { display: none; }
.editor-action-bar > button {
  flex: 1 1 0;
  min-width: 0;
}
/* Inline locked-state hint pointing at the mockup button. Lives in
   its own row directly beneath the action bar (NOT inside it — the
   previous flex-child layout collapsed the Generate/Download buttons
   to ~30% width). */
.editor-buy-lock-hint {
  margin: 8px 0 0;
  padding: 8px 12px;
  border-radius: 8px;
  background: rgba(255, 179, 71, 0.08);
  border: 1px solid rgba(255, 179, 71, 0.28);
  color: var(--accent-corona);
  font-size: 0.82rem;
  text-align: center;
  display: block;
}
.editor-buy-lock-hint i { margin-right: 4px; }
@media (max-width: 740px) {
  .editor-action-bar {
    flex-direction: column;
    gap: 8px;
  }
}

.editor-with-preview .selected-product-preview {
  flex: 1 0 260px;
  max-width: 400px;
  background: var(--bg-elevated);
  border: 2px solid var(--border);
  border-radius: var(--radius-md);
  padding: 14px;
  text-align: center;
  /* sticky on scroll; also acts as containing block for the absolute close button */
  position: sticky;
  top: 20px;
}

/* ✕ close / deselect button in the product preview panel */
.preview-close-btn {
  position: absolute;
  top: 8px;
  right: 10px;
  background: transparent;
  border: none;
  color: var(--text-dim);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  padding: 3px 5px;
  border-radius: var(--radius-sm);
  transition: color var(--transition), background var(--transition);
  z-index: 1;
}
.preview-close-btn:hover {
  color: var(--accent-flare);
  background: rgba(255,94,58,0.12);
}

/* Navigation row — three equal buttons (Change wavelength · product ·
   variant). Each takes ~1/3 of the row at the natural toolbar width so
   they read as a balanced triad rather than three different chip sizes. */
.edit-toolbar-nav-group {
  flex: 1 1 auto;
  margin-left: auto;
  display: flex;
  gap: 6px;
  min-width: 0; /* allow buttons to shrink below their content width */
}
.edit-toolbar-nav-group .edit-btn-nav {
  flex: 1 1 0;
  min-width: 0;
  justify-content: center;
  /* Was nowrap+ellipsis — but text-overflow can't elide inside a flex
     button (icon + text node are flex items, not a block's inline run),
     so labels hard-clipped: "Change time & wavelengt|" (UX pass
     2026-08-08). Wrap to a second line instead; nothing gets cut. */
  white-space: normal;
  line-height: 1.15;
  text-align: center;
}

/* Navigation-style edit-btn (Change Wavelength etc.) */
.edit-btn-nav {
  opacity: 0.7;
  font-size: 0.78rem;
}
.edit-btn-nav:hover { opacity: 1; }

.selected-product-preview .preview-product-name {
  font-weight: 600;
  font-size: 0.95rem;
  margin-bottom: 8px;
}

.selected-product-preview .preview-mockup {
  width: 100%;
  background: var(--bg-deep);
  border-radius: var(--radius-sm);
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 10px;
}

/* Canvas is always square 260×260; product shape is drawn inside. img fills when real mockup is shown */
.selected-product-preview .preview-mockup canvas.live-preview-canvas {
  max-width: 100%;
  height: auto;
  display: block;
}
.selected-product-preview .preview-mockup canvas.live-preview-canvas.circular {
  border-radius: 50%;
}

.selected-product-preview .preview-mockup > img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

.selected-product-preview .preview-product-ratio {
  font-size: 0.78rem;
  color: var(--text-dim);
  font-family: 'JetBrains Mono', monospace;
}

.selected-product-preview .preview-variant-wrap {
  margin-top: 6px;
}
.selected-product-preview .preview-variant-wrap label {
  display: block;
  font-size: 0.7rem;
  color: var(--text-dim);
  margin-bottom: 2px;
}
/* Make the variant select look like a real dropdown — beta tester didn't
   realize it was clickable. Adds a chevron, hover/focus affordances, and
   purple accent border to match the editor's interactive controls. */
.selected-product-preview .preview-variant-wrap select {
  width: 100%;
  padding: 8px 32px 8px 12px;
  font-size: 0.85rem;
  font-weight: 500;
  background-color: var(--bg-deep);
  /* Inline SVG chevron — no font-awesome dependency, scales cleanly. */
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path fill='none' stroke='%23d9a91a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='M1 1l5 5 5-5'/></svg>");
  background-repeat: no-repeat;
  background-position: right 12px center;
  background-size: 12px 8px;
  border: 1.5px solid var(--accent-corona, #d9a91a);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  -moz-appearance: none;
  transition: background-color 0.12s ease, border-color 0.12s ease, box-shadow 0.12s ease;
}
.selected-product-preview .preview-variant-wrap select:hover {
  background-color: rgba(217, 169, 26, 0.08);
}
.selected-product-preview .preview-variant-wrap select:focus {
  outline: none;
  box-shadow: 0 0 0 2px rgba(217, 169, 26, 0.3);
}
.selected-product-preview .preview-variant-note {
  font-size: 0.7rem;
  color: var(--text-dim);
  margin: 4px 0 0;
  font-style: italic;
}

/* Dual-panel layout toggle (throw pillow + hardcover journal).
   Two radio chips that look like the variant select: small label
   on top, two side-by-side pill buttons under it. */
.selected-product-preview .preview-layout-wrap {
  margin-top: 6px;
}
.selected-product-preview .preview-layout-label {
  display: block;
  font-size: 0.7rem;
  color: var(--text-dim);
  margin-bottom: 2px;
}
.selected-product-preview .preview-layout-options {
  display: flex;
  gap: 6px;
}
.selected-product-preview .preview-layout-opt {
  flex: 1 1 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 6px 8px;
  font-size: 0.75rem;
  font-weight: 500;
  background-color: var(--bg-deep);
  border: 1.5px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-primary);
  cursor: pointer;
  transition: background-color 0.12s ease, border-color 0.12s ease;
}
.selected-product-preview .preview-layout-opt:hover {
  background-color: rgba(217, 169, 26, 0.08);
}
.selected-product-preview .preview-layout-opt input[type="radio"] {
  /* Hide the native input; the label itself becomes the chip. */
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.selected-product-preview .preview-layout-opt:has(input:checked) {
  background-color: rgba(217, 169, 26, 0.18);
  border-color: var(--accent-corona, #d9a91a);
}

/* Generate-real-mockup button — styled the same way whether it
   lives in the preview pane (legacy) or the new sticky action
   bar at the bottom of the editor frame. The action bar handles
   sizing via flex: 1 on its children, so `width: 100%` here
   degrades gracefully to "full width" inside the preview pane
   and "fill flex slot" inside the action bar. */
.btn-preview-mockup {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 100%;
  padding: 10px 14px;
  font-size: 0.86rem;
  font-weight: 600;
  background: var(--bg-deep);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-secondary);
  cursor: pointer;
  transition: all var(--transition);
}

.btn-preview-mockup:hover {
  background: var(--accent-sun);
  color: #fff;
  border-color: var(--accent-sun);
}

/* ── In-flight spinner state for "Generate real mockup" ─────────
   The button shows one of three faces at any time:
     default → rocket icon + "Generate real mockup"
     loading → spinner + "Generating mockup…"   (disabled, no hover)
     done    → rocket icon + "Reset to mock mockup"
   .btn-preview-mockup-spinner is hidden by default and only shown
   when the button carries the .is-loading class. The icon is
   hidden in the same state so the spinner takes its slot.
   The status-msg spinner ring shares this style — keeping it
   inline here avoids a forward dependency on .status-msg. */
.btn-preview-mockup-spinner {
  display: none;
  width: 14px;
  height: 14px;
  border: 2px solid var(--border);
  border-top-color: var(--accent-corona);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

.btn-preview-mockup.is-loading {
  pointer-events: none;
  opacity: 0.85;
  cursor: progress;
}

.btn-preview-mockup.is-loading .btn-preview-mockup-icon {
  display: none;
}

.btn-preview-mockup.is-loading .btn-preview-mockup-spinner {
  display: inline-block;
}

.btn-preview-mockup.is-loading:hover {
  /* don't flash the sun-accent hover while disabled */
  background: var(--bg-deep);
  color: var(--text-secondary);
  border-color: var(--border);
}

.selected-product-preview .preview-mockup img.preview-real-mockup {
  object-fit: contain;
  width: 100%;
  height: auto;
  display: block;
}

/* ── Mockup slideshow (preview pane) ── */
.mockup-slideshow {
  position: relative;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
}

.slide-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background: rgba(0, 0, 0, 0.55);
  border: none;
  color: #fff;
  font-size: 1.5rem;
  line-height: 1;
  width: 28px;
  height: 48px;
  cursor: pointer;
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0.65;
  transition: opacity 0.15s, background 0.15s;
  z-index: 2;
  padding: 0;
  user-select: none;
}

.slide-nav:hover {
  opacity: 1;
  background: rgba(0, 0, 0, 0.82);
}

.slide-prev { left: 4px; }
.slide-next { right: 4px; }

.slide-counter {
  position: absolute;
  bottom: 6px;
  left: 50%;
  transform: translateX(-50%);
  background: rgba(0, 0, 0, 0.6);
  color: #fff;
  font-size: 0.68rem;
  padding: 2px 8px;
  border-radius: 10px;
  pointer-events: none;
  font-family: 'JetBrains Mono', monospace;
  white-space: nowrap;
}

/* Loading overlay for the big selected-product preview pane's mockup
   slideshow. Shown while the next/prev Printify mockup image is fetching
   from the CDN, hidden when img load/error fires. Sized larger than the
   product-card variant since this pane is the hero preview. Nav buttons
   remain clickable (z-index:3 above this layer's z-index:2). */
.mockup-slideshow .slide-loader {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10, 12, 22, 0.55);
  backdrop-filter: blur(1.5px);
  -webkit-backdrop-filter: blur(1.5px);
  z-index: 2;
  pointer-events: none;
  transition: opacity 140ms ease-out;
  border-radius: inherit;
}
.mockup-slideshow .slide-loader.hidden {
  opacity: 0;
}
.mockup-slideshow .slide-spinner {
  display: inline-block;
  width: 44px;
  height: 44px;
  border: 4px solid rgba(255, 255, 255, 0.22);
  border-top-color: rgba(255, 255, 255, 0.92);
  border-radius: 50%;
  animation: mockup-slide-spin 0.75s linear infinite;
}
@keyframes mockup-slide-spin {
  to { transform: rotate(360deg); }
}
.mockup-slideshow .slide-nav { z-index: 3; }

/* ── Mockup slideshow (product card grid) ── */
.card-slide-counter {
  position: absolute;
  top: 5px;
  right: 5px;
  background: rgba(0, 0, 0, 0.62);
  color: #fff;
  font-size: 0.6rem;
  padding: 1px 5px;
  border-radius: 8px;
  pointer-events: none;
  font-family: 'JetBrains Mono', monospace;
  z-index: 2;
}

.card-slide-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background: rgba(0, 0, 0, 0.52);
  border: none;
  color: #fff;
  font-size: 1.1rem;
  line-height: 1;
  width: 22px;
  height: 36px;
  cursor: pointer;
  border-radius: 3px;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  transition: opacity 0.15s;
  z-index: 2;
  padding: 0;
  user-select: none;
}

.product-preview:hover .card-slide-nav {
  opacity: 0.72;
}

.card-slide-nav:hover {
  opacity: 1 !important;
  background: rgba(0, 0, 0, 0.82);
}

.card-slide-prev { left: 2px; }
.card-slide-next { right: 2px; }

/* Loading overlay shown while the next/prev mockup image is fetching from
   Printify's CDN. Covers only the preview square (the nav buttons sit at
   z-index 3 so they stay clickable above the overlay). Kept deliberately
   subtle — thin translucent gray, small centered spinner — so it reads as
   "fetching" not "the app is broken." */
.card-slide-loader {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10, 12, 22, 0.52);
  backdrop-filter: blur(1px);
  -webkit-backdrop-filter: blur(1px);
  z-index: 2;
  pointer-events: none;
  transition: opacity 120ms ease-out;
}
.card-slide-loader.hidden {
  opacity: 0;
}
.card-slide-spinner {
  display: inline-block;
  width: 28px;
  height: 28px;
  border: 3px solid rgba(255, 255, 255, 0.22);
  border-top-color: rgba(255, 255, 255, 0.88);
  border-radius: 50%;
  animation: card-slide-spin 0.7s linear infinite;
}
@keyframes card-slide-spin {
  to { transform: rotate(360deg); }
}
/* Nav buttons must sit above the loader so users can re-click during load. */
.card-slide-nav { z-index: 3; }

@media (max-width: 740px) {
  /* Single-column grid: canvas → preview → toolbar → actions. The
     canvas + preview pair sticks to the top as a unit (canvas pinned
     at top:0, preview pinned just below it via top:<canvas-max-h>)
     so the user can adjust sliders below without losing sight of
     either visual reference. */
  .editor-with-preview {
    grid-template-columns: 1fr;
    grid-template-areas:
      "canvas"
      "preview"
      "toolbar"
      "actions";
  }
  .editor-with-preview .image-stage {
    position: sticky;
    top: 0;
    max-height: 30vh;
    z-index: 6;
  }
  .editor-with-preview .selected-product-preview {
    position: sticky;
    /* Sits directly below the sticky canvas so both stay onscreen
       together. Matches the canvas max-height above. */
    top: 30vh;
    z-index: 5;
    max-height: 35vh;
    overflow-y: auto;
  }
}

/* ── Touchscreen tap-target bumps ──────────────────────────────
   `(pointer: coarse)` matches touch-primary devices (phones,
   tablets, touch laptops) regardless of viewport width. WCAG and
   Apple HIG both call for ~44×44pt minimum tap targets; the default
   styling on chips and the modal close button is below that, which
   is fine for mouse-driven UI but cramped for thumbs. Desktop hover
   devices are left untouched so the layout doesn't get oversized
   on the editor's tight slider rows. */
@media (pointer: coarse) {
  /* Modal close (✕) goes from 32×32 to 44×44. */
  .feedback-close-btn,
  .confirm-modal-close,
  .preview-close-btn {
    width: 44px;
    height: 44px;
    font-size: 1.15rem;
  }
  /* Preset chips (Crop / Vig.Edge / Vig.Radius / Crop Edge / X·Y)
     get more vertical room. We don't push to a strict 44 because the
     Geometry tab packs four preset rows in a small space; 36px is
     the comfort floor that keeps the rows compact and still reaches
     a thumb. The `!important` overrides the `font-size:0.72rem !important`
     on the base class. */
  .preset-btn {
    padding: 8px 12px !important;
    min-height: 36px;
    font-size: 0.8rem !important;
  }
  /* FAB pills sit on a corner — taller padding helps thumb-reach. */
  .feedback-fab {
    padding: 14px 18px;
  }
  /* Feedback layout-toggle pills (Same both sides / Spans across). */
  .selected-product-preview .preview-layout-opt {
    padding: 10px 10px;
    font-size: 0.85rem;
  }
}

/* ───────────────────────────────────────────────────────────────
   Feedback widget — floating action button + modal. Sits above all
   other app chrome (z-index above the catalog modal) so users can
   reach it from any state of the flow.
   ─────────────────────────────────────────────────────────────── */
/* Two-pill FAB group — Request on the left, Comment on the right.
   Stays bottom-right via the group container; each pill is a normal
   inline-flex button so they read as siblings rather than a stack. */
.feedback-fab-group {
  position: fixed;
  right: 18px;
  bottom: 18px;
  z-index: 9990;
  display: flex;
  align-items: center;
  gap: 8px;
}
.feedback-fab {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 16px;
  border-radius: 999px;
  border: 0;
  color: var(--text-on-gold);
  font-weight: 600;
  font-size: 0.85rem;
  letter-spacing: 0.02em;
  cursor: pointer;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.3);
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.feedback-fab-request {
  /* Warmer gradient so the "request" CTA reads as the action-y one */
  background: linear-gradient(135deg, var(--accent-flare), #f0c75e);
}
.feedback-fab-comment {
  background: linear-gradient(135deg, var(--accent-corona, #d9a91a), #f0c75e);
}
.feedback-fab:hover {
  transform: translateY(-1px);
  box-shadow: 0 8px 24px rgba(217, 169, 26, 0.45), 0 2px 6px rgba(0, 0, 0, 0.35);
}
.feedback-fab:active { transform: translateY(0); }
.feedback-fab i { font-size: 1rem; }

/* STRESS-004: same treatment on step "product" — agent 1 reported the
   two pills covered the third-column product cards' title / price /
   "Pick a variant" button on the top product row at desktop, and
   overlapped card text on mobile. Shrink to icon-only + nudge up so
   they clear the grid's bottom-right cell. */
body.step-product .feedback-fab-group {
  right: 18px;
  bottom: 88px;
}
body.step-product .feedback-fab-label { display: none; }
body.step-product .feedback-fab { padding: 11px; }

/* Bug (reported 2026-07-15): the docked editor also has a sticky
   bottom-0 CTA bar (.editor-action-bar — Generate real mockup / Buy
   now), same as the rail and product-picker cases above, but this
   one was never given the clearance fix — the fixed FAB pills sat
   permanently on top of the Buy button. Shrink to icon-only and
   shift clear of the CTA bar on the editor step. */
body.step-editor .feedback-fab-group {
  right: 18px;
  bottom: 88px;
}
body.step-editor .feedback-fab-label { display: none; }
body.step-editor .feedback-fab { padding: 11px; }
@media (max-width: 740px) {
  /* .editor-action-bar switches to flex-direction:column here (two
     stacked buttons instead of one row) and grows to roughly double
     the height — clear that taller bar instead of the desktop 88px. */
  body.step-editor .feedback-fab-group {
    bottom: 148px;
  }
}

@media (max-width: 600px) {
  /* On narrow screens, hide labels so the two pills stay icon-only and
     don't crowd each other — full label still appears in the title attr. */
  .feedback-fab-label { display: none; }
  .feedback-fab { padding: 12px; }
}

.feedback-modal {
  position: fixed;
  inset: 0;
  z-index: 9995;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  /* When iOS pushes the keyboard up, the visual viewport shrinks but
     `vh` keeps the old document height — the modal would stay full-
     viewport-tall and the panel could end up centered behind the
     keyboard. `dvh` (dynamic viewport height) tracks the visual
     viewport, so when the keyboard opens the modal collapses to the
     visible region and the centered panel reflows above it. Falls
     back to `vh` on older browsers (Safari < 15.4, Chrome < 108) —
     they keep the old behaviour, which is the status quo. */
  height: 100vh;
  height: 100dvh;
}
.feedback-modal.hidden { display: none; }
.feedback-modal-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(8, 10, 20, 0.72);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
}
.feedback-modal-panel {
  position: relative;
  width: 100%;
  max-width: 520px;
  /* Same dvh fallback chain so the panel itself never exceeds the
     visible area when the keyboard is up. */
  max-height: calc(100vh - 40px);
  max-height: calc(100dvh - 40px);
  overflow-y: auto;
  /* Theme-aware (2026-08-08 palette sweep) — see the confirm-modal
     note: the forced-dark scope predated the light palette. */
  background: var(--bg-card);
  border: 1px solid rgba(128, 128, 128, 0.2);
  border-radius: 14px;
  padding: 28px 24px 24px;
  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
  color: var(--text-primary);
}
.feedback-close-btn {
  position: absolute;
  top: 10px;
  right: 12px;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: transparent;
  color: var(--text-secondary, #9898b8);
  border: 0;
  cursor: pointer;
  font-size: 1rem;
  line-height: 1;
  transition: background 0.15s ease, color 0.15s ease;
}
.feedback-close-btn:hover {
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
}

.feedback-modal-title {
  margin: 0 0 4px;
  font-size: 1.15rem;
  font-weight: 700;
}
.feedback-modal-sub {
  margin: 0 0 18px;
  font-size: 0.82rem;
  color: var(--text-secondary, #9898b8);
}

.feedback-tabs {
  display: flex;
  gap: 4px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
  margin-bottom: 18px;
}
.feedback-tab {
  flex: 1;
  padding: 10px 12px;
  background: transparent;
  border: 0;
  border-bottom: 2px solid transparent;
  color: var(--text-secondary, #9898b8);
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.feedback-tab:hover { color: var(--text-primary, #e9eaf5); }
.feedback-tab.active {
  /* Tab background stays transparent (the themed .feedback-modal-panel
     shows through), so this needs the themed ink token, not a fixed
     white — white-on-white on the light panel (bridge-bug family). */
  color: var(--text-primary);
  border-bottom-color: var(--accent-corona, #d9a91a);
}
.feedback-tab i { margin-right: 6px; }

.feedback-tab-panel { display: block; }
.feedback-tab-panel.hidden { display: none; }

.feedback-field-label {
  display: block;
  margin: 10px 0 6px;
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--text-secondary, #9898b8);
  letter-spacing: 0.02em;
}
.feedback-field-opt {
  font-weight: 400;
  color: var(--text-dim, #6a6a8a);
}

.feedback-modal textarea,
.feedback-modal input[type="text"],
.feedback-modal input[type="email"],
.feedback-modal select {
  width: 100%;
  box-sizing: border-box;
  background: var(--bg-deep);
  border: 1px solid rgba(128, 128, 128, 0.25);
  border-radius: 8px;
  padding: 10px 12px;
  /* Same "black-on-black if system is in light mode" bug — the
     input has a hardcoded dark background, so hardcode the typed
     text colour to a light tone too. `!important` defends against
     Shopify storefront themes that might cascade `input { color:
     black }` over our embedded iframe. */
  color: var(--text-primary, #e9eaf5) !important;
  /* iOS Safari auto-zooms when any focused input is < 16px. The page
     was zooming in every time the feedback modal opened on mobile —
     visually jarring and made the input field jump out of view.
     16px is the minimum that keeps Safari at the natural zoom. */
  font-size: 16px;
  font-family: inherit;
  outline: none;
  transition: border-color 0.15s ease, background 0.15s ease;
}
.feedback-modal textarea::placeholder,
.feedback-modal input::placeholder {
  color: #6f6f8c;
}
/* No drag-handle resize — JS auto-grows the textarea on input, which
   works on touch (where the resize grip is too small to grab) and on
   desktop (no manual sizing needed). overflow-y stays hidden so we
   don't flash a scrollbar mid-grow; the JS caps height at 60vh so
   the modal still scrolls past the textarea on overflow. */
.feedback-modal textarea {
  resize: none;
  min-height: 88px;
  overflow-y: hidden;
  line-height: 1.4;
}
.feedback-modal textarea:focus,
.feedback-modal input:focus,
.feedback-modal select:focus {
  border-color: var(--accent-corona, #d9a91a);
  background: #12152a;
}
.feedback-field-error {
  border-color: #ff5a5f !important;
  animation: feedback-shake 0.24s ease;
}
@keyframes feedback-shake {
  0%, 100% { transform: translateX(0); }
  25% { transform: translateX(-4px); }
  75% { transform: translateX(4px); }
}

.feedback-context-note {
  display: flex;
  gap: 8px;
  margin-top: 8px;
  padding: 8px 10px;
  background: rgba(217, 169, 26, 0.08);
  border-radius: 6px;
  font-size: 0.72rem;
  color: var(--text-secondary, #9898b8);
  line-height: 1.4;
}
.feedback-context-note i { color: var(--accent-corona, #d9a91a); margin-top: 2px; }

/* Include-canvas opt-out — checked by default. Looks like a regular
   checkbox row, sits above the submit button. Sized to match the
   field-label scale so it doesn't shout for attention. */
.feedback-include-canvas {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 10px 0 0;
  font-size: 0.78rem;
  color: var(--text-secondary, #9898b8);
  cursor: pointer;
  user-select: none;
}
.feedback-include-canvas input[type="checkbox"] {
  width: 14px;
  height: 14px;
  cursor: pointer;
  accent-color: var(--accent-corona, #d9a91a);
}
.feedback-include-canvas:hover { color: var(--text-primary, #e9eaf5); }

.feedback-submit-btn {
  width: 100%;
  margin-top: 14px;
  padding: 11px 16px;
  border: 0;
  border-radius: 8px;
  background: linear-gradient(135deg, var(--accent-corona, #d9a91a), #f0c75e);
  color: var(--text-on-gold);
  font-size: 0.9rem;
  font-weight: 600;
  cursor: pointer;
  transition: opacity 0.15s ease, transform 0.12s ease;
}
.feedback-submit-btn:hover:not(:disabled) { transform: translateY(-1px); }
.feedback-submit-btn:disabled {
  opacity: 0.6;
  cursor: wait;
}
.feedback-submit-btn i { margin-right: 6px; }
.feedback-submit-secondary {
  background: transparent;
  /* Overrides the --text-on-gold inherited from .feedback-submit-btn —
     this variant's background stays transparent (the themed modal
     panel shows through), so it needs the themed ink token instead. */
  color: var(--text-primary);
  border: 1px solid rgba(255, 255, 255, 0.15);
}
/* Light theme: the border above (white-alpha) is invisible on the
   light/white panel — swap to the opaque light border token. */
html:not(.dark) .feedback-submit-secondary { border-color: var(--border); }

.feedback-product-hint {
  margin-top: 10px;
  font-size: 0.78rem;
  color: var(--text-secondary, #9898b8);
}
.feedback-product-hint.hidden { display: none; }

/* Category chips — horizontal scroll on narrow modals so all categories
   stay reachable without wrapping into 4+ rows that crowd the search. */
.feedback-category-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 10px 0 4px;
}
.feedback-category-row.hidden { display: none; }
.feedback-category-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 10px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.06);
  color: var(--text-secondary, #9898b8);
  font-size: 0.74rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.feedback-category-chip:hover {
  background: rgba(217, 169, 26, 0.08);
  color: var(--text-primary, #e9eaf5);
}
.feedback-category-chip.active {
  background: linear-gradient(135deg, rgba(217, 169, 26, 0.18), rgba(240, 199, 94, 0.18));
  border-color: rgba(217, 169, 26, 0.5);
  color: #fff;
}
.feedback-category-chip i { font-size: 0.78rem; }
.feedback-category-chip-count {
  font-weight: 500;
  color: var(--text-dim, #6a6a8a);
  font-size: 0.7rem;
}
.feedback-category-chip.active .feedback-category-chip-count { color: rgba(255, 255, 255, 0.7); }

.feedback-product-results {
  margin-top: 8px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: 220px;
  overflow-y: auto;
}
.feedback-product-placeholder {
  padding: 10px 12px;
  font-size: 0.8rem;
  color: var(--text-dim, #6a6a8a);
  text-align: center;
}
.feedback-product-hit {
  position: relative; /* anchor for the hover-mockup popover */
  display: grid;
  grid-template-columns: 36px 1fr auto;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  background: var(--bg-deep);
  border: 1px solid rgba(128, 128, 128, 0.2);
  border-radius: 6px;
  color: var(--text-primary, #e9eaf5);
  font-size: 0.85rem;
  text-align: left;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.feedback-product-hit:hover {
  background: #151832;
  border-color: rgba(217, 169, 26, 0.3);
}
.feedback-product-hit-thumb {
  width: 36px;
  height: 36px;
  border-radius: 6px;
  background: var(--bg-card); /* was a cool blue-black remnant of the pre-2026-07-29 palette */
  object-fit: cover;
  flex: 0 0 auto;
}
.feedback-product-hit-thumb-empty {
  width: 36px;
  height: 36px;
  border-radius: 6px;
  background: var(--bg-card); /* was a cool blue-black remnant of the pre-2026-07-29 palette */
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-dim, #6a6a8a);
  font-size: 0.9rem;
}
.feedback-product-hit-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
.feedback-product-hit-title {
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.feedback-product-hit-brand {
  font-size: 0.72rem;
  color: var(--text-dim, #6a6a8a);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.feedback-product-hit-price {
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--accent-corona, #d9a91a);
  white-space: nowrap;
}
.feedback-product-hit-price-empty {
  font-size: 0.7rem;
  color: var(--text-dim, #6a6a8a);
  white-space: nowrap;
  font-style: italic;
}
/* Hover popover — JS-driven (appended to <body>) so it floats above
   the search-results container without getting clipped by its
   overflow:auto. Anchored next to the hovered row by JS. */
.feedback-hit-popover {
  position: fixed;
  width: 200px;
  height: 200px;
  background: var(--bg-card); /* was a cool blue-black remnant of the pre-2026-07-29 palette */
  border: 1px solid rgba(217, 169, 26, 0.3);
  border-radius: 8px;
  box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
  padding: 4px;
  pointer-events: none;
  z-index: 10000;
  opacity: 0;
  transform: scale(0.9);
  transition: opacity 0.12s ease, transform 0.12s ease;
}
.feedback-hit-popover.visible {
  opacity: 1;
  transform: scale(1);
}
.feedback-hit-popover img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  border-radius: 5px;
}

/* When a blueprint has a single print provider, we hide the dropdown
   and surface the picked provider as a small read-only line so the user
   still sees who'll fulfill the order without an extra interaction. */
.feedback-provider-row.hidden { display: none; }
.feedback-provider-auto {
  margin-top: 10px;
  padding: 8px 10px;
  background: rgba(217, 169, 26, 0.06);
  border: 1px dashed rgba(217, 169, 26, 0.18);
  border-radius: 6px;
  font-size: 0.78rem;
  color: var(--text-secondary, #9898b8);
}
.feedback-provider-auto.hidden { display: none; }
.feedback-provider-auto strong { color: var(--text-primary, #e9eaf5); font-weight: 600; }

/* Print-area shape picker on the chosen-blueprint pane. Two radios laid
   out side-by-side; the active option highlights so the choice reads
   clearly. */
.feedback-shape-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  margin: 12px 0 4px;
}
.feedback-shape-row .feedback-field-label { margin: 0 8px 0 0; }
.feedback-shape-opt {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.06);
  color: var(--text-secondary, #9898b8);
  font-size: 0.78rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.feedback-shape-opt input[type="radio"] { display: none; }
.feedback-shape-opt:hover { color: var(--text-primary, #e9eaf5); }
.feedback-shape-opt:has(input:checked) {
  background: linear-gradient(135deg, rgba(217, 169, 26, 0.18), rgba(240, 199, 94, 0.18));
  border-color: rgba(217, 169, 26, 0.5);
  color: #fff;
}
.feedback-shape-opt i { font-size: 0.72rem; opacity: 0.85; }

/* Big mockup on the chosen-blueprint pane — confirms which product was
   picked once the search list collapses. */
.feedback-chosen-mockup {
  width: 100%;
  max-height: 200px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-card); /* was a cool blue-black remnant of the pre-2026-07-29 palette */
  border-radius: 8px;
  margin: 10px 0;
  padding: 8px;
  box-sizing: border-box;
}
.feedback-chosen-mockup img {
  max-width: 100%;
  max-height: 184px;
  object-fit: contain;
  border-radius: 6px;
}
.feedback-chosen-mockup-empty {
  color: var(--text-dim, #6a6a8a);
  font-size: 0.78rem;
  padding: 24px 0;
}

.feedback-product-chosen {
  margin-top: 14px;
  padding: 12px;
  background: rgba(217, 169, 26, 0.06);
  border: 1px solid rgba(217, 169, 26, 0.2);
  border-radius: 8px;
}
.feedback-product-chosen.hidden { display: none; }
.feedback-chosen-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.feedback-chosen-name {
  font-weight: 700;
  font-size: 0.95rem;
}
.feedback-chosen-brand {
  font-size: 0.72rem;
  color: var(--text-dim, #6a6a8a);
  margin-top: 2px;
}
.feedback-chosen-clear {
  background: transparent;
  border: 0;
  color: var(--text-secondary, #9898b8);
  cursor: pointer;
  font-size: 0.9rem;
  padding: 4px 6px;
  border-radius: 4px;
}
.feedback-chosen-clear:hover {
  background: rgba(255, 255, 255, 0.06);
  color: #fff;
}

.feedback-thanks {
  text-align: center;
  padding: 20px 10px;
}
.feedback-thanks i {
  font-size: 2.6rem;
  color: #3ddc84;
  margin-bottom: 10px;
}
.feedback-thanks h3 {
  margin: 0 0 6px;
  font-size: 1.1rem;
}
.feedback-thanks p {
  margin: 0 0 16px;
  font-size: 0.85rem;
  color: var(--text-secondary, #9898b8);
}

/* ───────────────────────────────────────────────────────────────
   Pre-editor confirmation modal — previews the product & variant
   after the user clicks "Select this product" and before the editor
   loads. Smaller than the feedback modal since it's a single action
   with a compact metadata readout.
   ─────────────────────────────────────────────────────────────── */
.confirm-modal {
  position: fixed;
  inset: 0;
  z-index: 9994;
  display: flex;
  /* flex-start instead of center: when the modal panel is taller than
     the viewport the centered layout pushed the panel header above the
     fold, making it look like the page "scrolled down to the variant
     picker" rather than overlaying. Anchoring to the top keeps the
     title + close button always visible. */
  align-items: flex-start;
  justify-content: center;
  padding: 24px 20px;
  /* Internal scroll: the panel + its own overflow handles tall content
     without pushing the page itself. */
  overflow-y: auto;
}
.confirm-modal.hidden { display: none; }

/* Colour chooser (merged-product, e.g. mug White/Black) ─────────── */
.color-chooser-modal {
  position: fixed;
  inset: 0;
  z-index: 9995;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}
.color-chooser-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(6, 7, 14, 0.66);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
}
.color-chooser-panel {
  position: relative;
  width: 100%;
  max-width: 460px;
  max-height: calc(100dvh - 32px);
  overflow-y: auto;
  background: var(--bg-card);
  border: 1px solid rgba(128, 128, 128, 0.2);
  border-radius: 14px;
  padding: 24px 22px 22px;
  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
  color: var(--text-primary, #e9eaf5);
}
.color-chooser-close {
  position: absolute;
  top: 10px; right: 12px;
  width: 30px; height: 30px;
  border-radius: 50%;
  background: transparent;
  color: var(--text-secondary, #a8a8c8);
  border: 0; cursor: pointer;
}
.color-chooser-close:hover { background: rgba(128,128,128,0.15); color: var(--text-primary, #fff); }
.color-chooser-title { margin: 0 0 4px; font-size: 1.1rem; font-weight: 700; }
.color-chooser-sub { margin: 0 0 16px; font-size: 0.85rem; color: var(--text-secondary, #a8a8c8); }
.color-chooser-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}
.color-chooser-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 12px;
  background: var(--bg-deep, #0a0a12);
  border: 2px solid var(--border, #2a2a44);
  border-radius: 12px;
  cursor: pointer;
  transition: border-color 0.15s ease, transform 0.1s ease;
}
.color-chooser-tile:hover,
.color-chooser-tile:focus-visible { border-color: var(--border-active, #d9a91a); }
.color-chooser-tile:active { transform: translateY(1px); }
/* "You picked this" confirmation flash — ~220ms window between click
   and modal-close so the user sees their selection register. */
.color-chooser-tile.is-picking {
  border-color: var(--accent-corona, #d9a91a);
  box-shadow: 0 0 0 4px rgba(217, 169, 26, 0.35);
  transform: scale(0.98);
}
.color-chooser-tile.is-picking::after {
  content: "\f00c";  /* fa-check */
  font-family: "Font Awesome 6 Free", "Font Awesome 5 Free", sans-serif;
  font-weight: 900;
  position: absolute;
  top: 8px; right: 10px;
  width: 26px; height: 26px;
  border-radius: 50%;
  background: var(--accent-corona, #d9a91a);
  color: #fff;
  display: flex; align-items: center; justify-content: center;
  font-size: 0.85rem;
  animation: tilePickPop 0.22s ease-out;
}
.color-chooser-tile { position: relative; }
@keyframes tilePickPop {
  from { transform: scale(0.6); opacity: 0; }
  to   { transform: scale(1);   opacity: 1; }
}
.color-chooser-preview {
  width: 120px; height: 120px;
  display: flex; align-items: center; justify-content: center;
}
.color-chooser-preview canvas { max-width: 100%; max-height: 100%; }
.color-chooser-label {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 0.95rem; font-weight: 600; color: var(--text-primary, #e9eaf5);
}
.color-chooser-swatch {
  width: 16px; height: 16px;
  border-radius: 50%;
  border: 1px solid rgba(255,255,255,0.25);
  display: inline-block;
}
.confirm-modal-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(8, 10, 20, 0.72);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
}
.confirm-modal-panel {
  position: relative;
  width: 100%;
  max-width: 460px;
  /* Cap to the VISIBLE viewport so a tall variant list (canvas
     stretched has 78+ variants) can't push the Continue button
     off-screen. `100vh` on iOS includes the area behind the URL
     bar, which sent the bottom of the panel below the fold with
     no way to scroll there — `100dvh` tracks the visual viewport.
     Fallback `100vh` keeps the previous behaviour on browsers
     without dvh (Safari < 15.4). */
  max-height: calc(100vh - 32px);
  max-height: calc(100dvh - 32px);
  /* Plain block layout instead of flex — flex containers with
     overflow-y: auto have iOS-Safari quirks where the scrollHeight
     gets miscalculated and the scrollbar never appears. Plain
     block + overflow-y: auto always works. */
  display: block;
  background: var(--bg-card);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 14px;
  padding: 26px 22px 20px;
  box-shadow: 0 18px 60px rgba(0, 0, 0, 0.6);
  /* NO local token re-scope here (removed 2026-08-08). The old
     dark-literal override dated from when this panel had a
     hardcoded dark background; PR #49 retokenized the background
     to var(--bg-card) — white in light mode — which made the
     still-forced dark text illegible (cream on white, the light-
     mode report). Theme tokens now flow through untouched:
     navy-on-white in light, cream-on-dark-card in dark. */
  color: var(--text-primary);
  overflow-y: auto;
  /* iOS momentum scrolling — without this the user has to drag-
     scroll without flick inertia, which feels broken on touch. */
  -webkit-overflow-scrolling: touch;
}
.confirm-modal-close {
  position: absolute;
  top: 10px;
  right: 12px;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: transparent;
  color: var(--text-secondary, #9898b8);
  border: 0;
  cursor: pointer;
  font-size: 0.95rem;
  transition: background 0.15s ease, color 0.15s ease;
}
.confirm-modal-close:hover { background: rgba(128, 128, 128, 0.15); color: var(--text-primary, #fff); }
.confirm-modal-title {
  margin: 0 0 4px;
  font-size: 1.1rem;
  font-weight: 700;
}
.confirm-modal-sub {
  margin: 0 0 16px;
  font-size: 0.78rem;
  color: var(--text-secondary, #9898b8);
}
/* Mock-mockup preview inside the "Ready to customize?" confirm modal.
   Renders the product silhouette + user's current solar image so the
   variant pick commits with visual context, not just a text summary. */
.confirm-mockup {
  margin: 0 auto 14px;
  /* Sized by the canvas child (_renderMockup writes c.style.width/
     height per the variant's aspect ratio). Width/height auto means
     the container shrinks/expands with the canvas. Caps stop the box
     from ever exceeding the modal panel. */
  max-width: 240px;
  max-height: 240px;
  width: auto;
  height: auto;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-deep);
  border: 1px solid rgba(128, 128, 128, 0.2);
  border-radius: 10px;
  overflow: hidden;
}
.confirm-mockup.empty {
  display: none;
}
/* Photoreal Printify default mockup at the variant-modal preview slot
   when no source image is loaded yet — replaces the cute sun-icon
   placeholder per Gilly's "I liked it better when I could see the
   aspect ratio and how it was going to crop" feedback. */
.confirm-mockup-default {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  border-radius: 6px;
}
/* Fallback placeholder (no default mockup on disk for this product) —
   kept so the slot doesn't collapse to zero when the manifest is
   sparse. */
.confirm-mockup-placeholder {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 18px;
  text-align: center;
}
.confirm-mockup-placeholder-icon {
  width: 56px; height: 56px;
  border-radius: 50%;
  background: radial-gradient(circle at 50% 50%, rgba(255,179,71,0.20), rgba(255,179,71,0.04) 70%);
  border: 1px dashed rgba(255,179,71,0.35);
  display: flex; align-items: center; justify-content: center;
  font-size: 1.4rem;
  color: var(--accent-sun, var(--accent-sun));
}
.confirm-mockup-placeholder-text {
  font-size: 0.82rem;
  color: var(--text-secondary, #9898b8);
  line-height: 1.35;
}
.confirm-mockup-placeholder-text span {
  display: block;
  margin-top: 4px;
  font-size: 0.72rem;
  color: var(--text-dim, #6a6a8a);
}
.confirm-mockup .confirm-mockup-canvas {
  width: 220px;
  height: 220px;
  image-rendering: -webkit-optimize-contrast;
}
.confirm-metadata {
  margin: 0 0 14px;
  padding: 12px 14px;
  background: var(--bg-deep);
  border: 1px solid rgba(128, 128, 128, 0.2);
  border-radius: 8px;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 6px 12px;
}
.confirm-metadata dt {
  margin: 0;
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--text-dim, #6a6a8a);
  letter-spacing: 0.02em;
  text-transform: uppercase;
}
.confirm-metadata dd {
  margin: 0;
  font-size: 0.86rem;
  color: var(--text-primary, #e9eaf5);
  font-weight: 500;
}
.confirm-modal-note a {
  color: var(--accent-corona-text);
}
.confirm-modal-note {
  margin: 0 0 14px;
  font-size: 0.75rem;
  color: var(--text-secondary, #9898b8);
  line-height: 1.4;
}
.confirm-modal-actions {
  display: flex;
  gap: 8px;
  /* Continue button sticks to the bottom of the scrolling panel so
     it's always reachable, no matter how tall the variant list is
     or how short the viewport is. The mid-modal variant picker has
     its own 240-px scroll on desktop — without this sticky, wheel
     events over the picker scroll only the picker, and users hit
     the picker's bottom thinking they've reached the end of the
     modal. With sticky, Continue is pinned at the panel's bottom
     edge regardless of scroll position. Mobile gets the same
     guarantee for the same reason. */
  position: sticky;
  bottom: 0;
  z-index: 1;
  /* Solid background + a real upward fade so list content scrolling
     under the sticky CTA visibly dissolves into the button row
     instead of half-tiles being chopped off. Beta-tester crew
     filed: on mobile the previous 12-px box-shadow wasn't enough,
     so "16.0\" tall" text leaked out from underneath the button. */
  /* var(--bg-card), not the old hardcoded navy #17192a — the strip
     must match the panel it's stuck to in BOTH themes. */
  background: var(--bg-card);
  padding-top: 14px;
  margin-top: 4px;
}
.confirm-modal-actions::before {
  /* Gradient fade above the sticky actions row. Transparent at top
     → solid panel background at bottom, sitting just above the
     button so content scrolling under it dissolves smoothly. */
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  height: 28px;
  pointer-events: none;
  background: linear-gradient(to bottom, transparent 0%, var(--bg-card) 100%);
}
.confirm-modal-btn {
  flex: 1;
  padding: 10px 14px;
  border: 0;
  border-radius: 8px;
  font-size: 0.88rem;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.12s ease, opacity 0.15s ease;
}
.confirm-modal-btn-primary {
  background: linear-gradient(135deg, var(--accent-corona, #d9a91a), #f0c75e);
  color: var(--text-on-gold);
}
.confirm-modal-btn-primary:hover { transform: translateY(-1px); }
.confirm-modal-btn-secondary {
  background: transparent;
  color: var(--text-primary, #e9eaf5);
  border: 1px solid rgba(255, 255, 255, 0.15);
}
.confirm-modal-btn-secondary:hover { background: rgba(255, 255, 255, 0.06); }
/* Light theme: the dark-mode border above (white-alpha) is invisible
   on the light/white panel — swap to the opaque light border token. */
html:not(.dark) .confirm-modal-btn-secondary { border-color: var(--border); }

/* ───────────────────────────────────────────────────────────────
   Variant picker inside the confirm modal (single-step selection).
   Replaces the older inline collapsible pane on product cards as the
   primary flow — testers found two-step "expand → row-Select → confirm"
   too cluttered.
   ─────────────────────────────────────────────────────────────── */
.confirm-variant-summary {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  margin: 0 0 12px;
  padding: 8px 12px;
  background: rgba(255, 255, 255, 0.04);
  border-radius: 8px;
  font-size: 0.82rem;
  color: var(--text-secondary, #9898b8);
}
.confirm-variant-summary strong { color: var(--text-primary, #e9eaf5); font-weight: 600; }
.confirm-variant-summary .confirm-summary-dot { opacity: 0.5; }
.confirm-variant-summary .confirm-summary-price {
  /* Brightened purple variant — see `--accent-corona-text` in :root.
     The original #d9a91a measured ~3.7:1 against #1a1a2e (sub-AA);
     `--accent-corona-text` lands at ~6:1. */
  color: var(--accent-corona-text, #f3d385);
  font-weight: 600;
}

/* Colour-swatch row in the variant picker. Each swatch is a tiny
   coloured square with a hover state and a thicker outline when the
   currently-active variant matches that colour. Hidden via .hidden
   when the product's variants don't carry resolvable colours. */
/* Label naming what the swatch row affects (e.g. "Frame color: White").
   `cursor:help` + the JS-set title surface the "physical product, not
   the image" clarification on hover. */
.confirm-axis-label {
  margin: 6px 2px 2px;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-secondary, #a8a8c8);
  cursor: help;
}
.confirm-axis-label.hidden { display: none; }
.confirm-color-swatches {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 4px 0 12px;
  padding: 0 2px;
}
.confirm-color-swatches.hidden { display: none; }
.confirm-color-swatch {
  width: 26px;
  height: 26px;
  border-radius: 6px;
  border: 1px solid rgba(255, 255, 255, 0.18);
  cursor: pointer;
  padding: 0;
  background-clip: padding-box;
  transition: transform 0.08s ease, box-shadow 0.12s ease, border-color 0.12s ease;
  position: relative;
}
.confirm-color-swatch:hover {
  transform: scale(1.08);
  border-color: rgba(255, 255, 255, 0.45);
}
.confirm-color-swatch.active {
  border-color: var(--accent-corona, #b692ff);
  box-shadow: 0 0 0 2px var(--accent-corona, #b692ff), 0 0 0 4px rgba(217, 169, 26, 0.25);
}
/* When the resolved hex is very dark, ring the swatch with a subtle
   light line so it doesn't disappear into the modal background. */
.confirm-color-swatch[data-tone="dark"] {
  border-color: rgba(255, 255, 255, 0.35);
}

/* Size chips — same row pattern as the colour swatches, but textual.
   Each chip carries one size value (S/M/L/XL/2XL/...); the active
   chip matches the currently-selected variant's size. */
.confirm-size-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 0 0 12px;
  padding: 0 2px;
}
.confirm-size-chips.hidden { display: none; }
.confirm-size-chip {
  min-width: 34px;
  height: auto;
  min-height: 36px;
  padding: 4px 10px;
  border-radius: 6px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.12);
  color: var(--text-secondary, #9898b8);
  font-size: 0.78rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
  /* STRESS-016: chip now hosts label + price stacked. */
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1px;
  line-height: 1.05;
}
.confirm-size-chip-price {
  font-size: 0.66rem;
  font-weight: 500;
  opacity: 0.78;
  letter-spacing: 0.01em;
}
.confirm-size-chip.active .confirm-size-chip-price { opacity: 0.9; }
.confirm-size-chip:hover {
  color: var(--text-primary, #e9eaf5);
  background: rgba(217, 169, 26, 0.12);
}
.confirm-size-chip.active {
  background: linear-gradient(135deg, rgba(217, 169, 26, 0.35), rgba(240, 199, 94, 0.35));
  border-color: rgba(217, 169, 26, 0.6);
  color: #fff;
}
/* Size chips that are unavailable in the currently-selected color
   appear dimmed — keeps the layout consistent but flags that picking
   it will lose color match. */
.confirm-size-chip[data-unavailable="true"] {
  opacity: 0.35;
}

.confirm-variant-picker {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
  gap: 8px;
  max-height: 240px;
  overflow-y: auto;
  margin: 0 0 14px;
  padding: 4px 2px;
  /* Subtle inset so the scroll area reads as its own region. */
  border-radius: 10px;
  background: rgba(0, 0, 0, 0.18);
  border: 1px solid rgba(255, 255, 255, 0.06);
  padding: 8px;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
/* On narrow screens (mobile portrait) the nested scroll inside the
   modal competes with the modal's own scroll — the variant picker
   shows ~3 rows in its 240-px window and the user reasonably
   assumes that's the whole list, never realising the modal itself
   scrolls past it to the Continue button below. Drop the inner cap
   so the variant list grows naturally and the WHOLE modal scrolls
   as one continuous flow. */
@media (max-width: 600px) {
  .confirm-variant-picker {
    max-height: none;
    overflow-y: visible;
  }
}
.confirm-variant-picker::-webkit-scrollbar { width: 8px; }
.confirm-variant-picker::-webkit-scrollbar-thumb {
  background: rgba(255, 255, 255, 0.18);
  border-radius: 4px;
}

.confirm-variant-tile {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  gap: 4px;
  padding: 10px 12px;
  border: 1.5px solid rgba(255, 255, 255, 0.1);
  border-radius: 8px;
  background: rgba(26, 24, 20, 0.6);
  color: var(--text-primary, #e9eaf5);
  cursor: pointer;
  text-align: left;
  font-family: inherit;
  font-size: 0.84rem;
  line-height: 1.25;
  transition: border-color 0.12s ease, background 0.12s ease, transform 0.08s ease;
}
.confirm-variant-tile:hover {
  border-color: rgba(217, 169, 26, 0.55);
  background: rgba(48, 43, 35, 0.55);
}
.confirm-variant-tile.active {
  border-color: var(--accent-corona, #d9a91a);
  background: rgba(217, 169, 26, 0.16);
  box-shadow: 0 0 0 1px var(--accent-corona, #d9a91a) inset;
}
.confirm-variant-tile:active { transform: scale(0.98); }
.confirm-variant-tile-label {
  font-weight: 600;
  /* Allow long labels to wrap to a second line rather than truncate — beta
     reported truncated labels (Athletic Heather / XL Long T → "Athletic He…")
     hid critical information. The tile grows in height; the grid flexes. */
  white-space: normal;
  overflow-wrap: anywhere;
}
.confirm-variant-tile-price {
  font-size: 0.74rem;
  color: var(--text-secondary, #9898b8);
  font-family: 'JetBrains Mono', monospace;
}
.confirm-variant-tile.active .confirm-variant-tile-price { color: var(--accent-corona-text, #f3d385); }
.confirm-variant-tile-dims {
  font-size: 0.7rem;
  color: var(--text-dim, #6b6b85);
  font-family: 'JetBrains Mono', monospace;
  letter-spacing: 0.02em;
}

.confirm-variant-loading,
.confirm-variant-empty {
  grid-column: 1 / -1;
  padding: 16px;
  text-align: center;
  color: var(--text-secondary, #9898b8);
  font-size: 0.85rem;
}

/* ───────────────────────────────────────────────────────────────
   Session-only "Your Requests" section — cards the user submitted
   via the feedback modal, visible immediately in their session
   before admin approval.
   ─────────────────────────────────────────────────────────────── */
.user-requests-section {
  margin-top: 18px;
  padding-top: 14px;
  border-top: 1px dashed rgba(255, 255, 255, 0.08);
}
.user-requests-section.hidden { display: none; }
.user-requests-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 6px;
}
.user-requests-title {
  font-size: 0.95rem;
  font-weight: 700;
  color: var(--text-primary, #e9eaf5);
}
.user-requests-sub {
  margin: 0 0 10px;
  font-size: 0.75rem;
  color: var(--text-secondary, #9898b8);
}
.product-card.product-card-requested {
  border-color: rgba(217, 169, 26, 0.3);
}
.product-card-requested .product-name::before {
  content: "Requested";
  display: inline-block;
  margin-right: 6px;
  padding: 1px 6px;
  background: rgba(217, 169, 26, 0.15);
  border: 1px solid rgba(217, 169, 26, 0.35);
  border-radius: 4px;
  font-size: 0.6rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: #c0afff;
  vertical-align: middle;
}

/* ===============================================================
   MOBILE-ONLY OVERRIDES  (≤ 749 px)
   ---------------------------------------------------------------
   Single place to tune the phone experience. Every rule here is
   scoped inside `@media (max-width: 749px)` so desktop is never
   touched by changes in this block. Audit context: real-device
   inspection at iPhone 14 viewport (390 × 844) on 2026-05-22.
   Keep this block as the LAST thing in the file so it always wins.
   =============================================================== */
@media (max-width: 749px) {

  /* ── F1 · Wavelength thumbnail clip fix ────────────────────────
     `.wl-thumb` is a fixed 120 × 120 px disc. At 390 px viewport the
     `.wavelength-grid` drops to 3 columns and each `.wl-card` is
     only ~96 px wide with `overflow:hidden` and 10 px side padding —
     so the right ~36 px of every sun preview was being clipped.
     Make the thumb fluid: fill the card's content box but cap at
     the original 120 so wider phones don't blow it up. */
  .wl-thumb {
    width: 100%;
    max-width: 120px;
    height: auto;
    aspect-ratio: 1;
  }

  /* ── F2 · Product grid: 2 columns on phones ────────────────────
     Default grid is `repeat(auto-fill, minmax(240px, 1fr))` which
     collapses to ONE column at 390 px → 24 cards × ~450 px = ~11k
     px of scroll. Force a 2-up so the chooser is half as tall.
     Tighter gap because each card is now ~167 px wide. */
  .product-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 10px;
  }
  /* Product-card font tweaks for the narrower 2-col cell. */
  .product-name { font-size: 0.92rem; }
  .product-desc { font-size: 0.72rem; }
  .product-price { font-size: 0.85rem; }
  .product-info { padding: 10px; }

  /* ── F2b · Clear the feedback-FAB pair at the bottom of the grid ──
     On step-product the FAB pair is fixed at bottom:88px, icon-only
     (11px padding + 16px icon glyph = ~38px tall), so it occupies the
     88-126px band above the viewport's bottom edge. .section's base
     padding-bottom (24px) doesn't reach that band, so once the page
     is scrolled to the true bottom the last grid row's price and
     Choose-variant button land under the pills. Pad past the FAB. */
  body.step-product #productSection {
    padding-bottom: 130px;
  }

  /* ── F3 · Product preview wastes pixels (square aspect) ────────
     `.product-preview { aspect-ratio: 1 }` reserves a full square
     even though the rendered canvas is `max-width/height: 70%` so
     ~30% of the box is empty. Use 4:3 on mobile — saves ~25% of
     each card's preview height (×24 cards = a lot of scroll). */
  .product-preview { aspect-ratio: 4 / 3; }
  .product-preview canvas { max-width: 80%; max-height: 80%; }
  /* Shrink the corner icon-badge and stats-badge proportionally so
     they don't dominate the smaller preview area. */
  .product-preview .product-icon-badge {
    width: 32px; height: 32px;
    font-size: 16px;
    border-radius: 8px;
    top: 6px; left: 6px;
  }
  .product-preview .product-stats-badge {
    font-size: 9px;
    padding: 1px 5px;
    bottom: 6px; right: 6px;
  }

  /* ── F4 · Touch-target heights (Apple HIG 44 px) ───────────────
     Audited under-44 buttons: `.product-buy-btn` (37 px) and the
     birthday CTA `.birthday-cta-btn` (39 px). Bump both to 44 by
     widening vertical padding, keeping the text size where it is. */
  .product-buy-btn { min-height: 44px; padding: 12px 10px; }
  /* Hide the arrow icon on narrow viewports so the "Pick a variant"
     label has the full button width to lay out on one line. Beta-
     tester crew filed: 2-column grid + arrow + 12 chars wrapped to
     two lines ("Pick a / variant"), reducing predictability of the
     tap target. Label-only on phone is readable + tappable. */
  .product-buy-btn .fa-arrow-right,
  .product-buy-btn .fas.fa-arrow-right { display: none; }
  .product-buy-btn { white-space: nowrap; }
  .birthday-cta-btn { min-height: 44px; padding: 12px 16px; }

  /* ── F5 · Section headers larger on mobile ─────────────────────
     `.section-title` and `.birthday-cta-title` were 18.4 / 18.88 px
     — fine on desktop, undersized for a primary CTA on a phone. */
  .section-title { font-size: 1.25rem; }
  .birthday-cta-title { font-size: 1.3rem; }

  /* ── F5b · Step-1 heading avoids a ragged wrap ──────────────────
     "Pick a date - a famous moment, your birthday, or any day since
     2010" wraps to 5 ragged lines at 390px once F5 bumps it to
     1.25rem, orphaning "2010" alone on the last line. Same fix as
     .hero p above: let the browser balance the line breaks. */
  #vibeGridTitle { text-wrap: balance; }

  /* ── F6 · Wavelength labels readable on phone ──────────────────
     Default `.wl-label` is 0.72 rem (~11 px). Bump for legibility;
     the descriptor still wraps to 2 lines but is now scannable. */
  .wl-label {
    font-size: 0.78rem;
    line-height: 1.25;
    /* Friction audit: at ~390 px the second word ("Chromosphere",
       "Photosphere") was being hard-truncated mid-word ("Chromosphe")
       because .wl-card has overflow:hidden and a fixed height. Allow
       hyphenated soft-wraps + give the card room to breathe. */
    overflow-wrap: anywhere;
    hyphens: auto;
  }
  .wl-value { font-size: 1rem; }
  .wl-card {
    /* Let the wrapped label fit without clipping. The ::before tinted
       bar at the top still clips since it's anchored to the card edge
       (overflow:hidden on its own pseudo). */
    overflow: visible;
    min-height: 120px;
  }

  /* ── F7 · Beta banner: collapse to a slim chip on phone ────────
     The full sentence eats 52 px above the picker. The H1 already
     carries a "BETA" badge, so on mobile we keep the banner only
     as an iconified pill — tap-target preserved, vertical cost
     halved. Pair with JS that lets the user dismiss; CSS handles
     the size shrink unconditionally. */
  body.beta-mode-active .beta-banner {
    font-size: 0.72rem;
    padding: 4px 10px;
    margin-top: 8px;
  }

  /* ── F8 · Hero credit + hero padding ───────────────────────────
     The "Courtesy of NASA/SDO…" credit + tagline collectively push
     the picker below the fold. Trim hero vertical padding so the
     date input lands sooner. */
  .hero { padding-top: 14px; padding-bottom: 8px; }
  .hero-credit { font-size: 0.7rem; line-height: 1.3; }
  /* STRESS-005: friction agent 1 measured firstCard.top = 380 px on
     a 667 px-tall mobile viewport — hero alone consumed >55 % of
     the vertical space, so only a sliver of the first product card
     showed above the fold. Defeat the point of product-first.
     Mobile-only treatment:
       * Shrink H1 + tagline, drop the "Solar Archive" line height
       * Move the SDO acknowledgement to a smaller second-line
       * Hide the beta banner here (#betaBanner) — its content is
         duplicated in the global backend banner and the H1's BETA
         badge already, so the third repetition is pure occupy. */
  .hero h1 { font-size: 1.4rem; line-height: 1.1; margin-bottom: 4px; }
  .hero p { font-size: 0.78rem; line-height: 1.25; margin-bottom: 6px; }
  .hero-credit { font-size: 0.62rem; margin-top: 2px; } /* opacity:0.7 broke AA on cream */
  .hero-icon { width: 36px; height: 36px; margin-bottom: 6px; }
  body.step-product .beta-banner { display: none; }
  /* Product section: trim header padding so the first card lands
     above the fold on a 667 px-tall viewport. */
  body.step-product #productSection .section-header { padding-bottom: 4px; }
  body.step-product #productSectionIntro { font-size: 0.72rem; margin-bottom: 6px; }

  /* ── F9 · Confirm-modal & feedback area form fields ────────────
     iOS Safari auto-zooms any <input> with font-size < 16 px on
     focus, then never zooms back — disorienting. Force ≥16 px on
     all text inputs so the OS leaves the viewport alone. */
  input[type="text"],
  input[type="email"],
  input[type="number"],
  input[type="search"],
  input[type="date"],
  input[type="time"],
  select,
  textarea {
    font-size: 16px;
  }

  /* ── F10 · Editor: drop sticky on the image-stage only ─────────
     The existing rule at line ~3106 keeps `.image-stage` sticky at
     top:0 AND `.selected-product-preview` sticky at top:30vh — on a
     390 × 844 viewport that's 65 vh of pinned chrome (≈ 549 px),
     leaving ~295 px for the toolbar/sliders. Since F15 hides the
     image-stage entirely on mobile, only the preview pane needs to
     stick — and F16 (below) handles that with a single sticky-at-
     top pinning the canvas while the toolbar scrolls underneath.
     The !important is needed to beat the more-specific
     `.editor-with-preview .image-stage` selectors above. */
  .editor-with-preview .image-stage {
    position: static !important;
    top: auto !important;
    max-height: none !important;
    overflow: visible !important;
  }

  /* ── F12 · Toolbar overflow (the showstopper) ──────────────────
     `.edit-toolbar-row` flex-wraps, but its children
     (`.filter-timeline.editor-filter`, `.edit-toolbar-nav-group`)
     refuse to shrink below their content's natural width — each
     reports 446 px inside a 308 px column, bleeding ~138 px off the
     right edge of the viewport (what looks like a phantom second
     column with "RHE…C" peeking in the screenshot). Force each row
     child to take the full row and shrink to fit, and let their own
     inner items wrap. */
  .editor-with-preview .editor-main,
  .editor-with-preview .edit-toolbar,
  .editor-with-preview .edit-toolbar-row {
    min-width: 0;
    max-width: 100%;
  }
  .edit-toolbar-row > .filter-timeline,
  .edit-toolbar-row > .edit-toolbar-nav-group {
    flex: 1 1 100%;
    min-width: 0;
    max-width: 100%;
    width: 100%;
  }
  /* The "Change wavelength / product / variant" trio: stack vertical
     if they still won't fit in a single row at narrow widths. They
     already have `flex: 1 1 0; min-width: 0`, so wrapping is enough. */
  .edit-toolbar-nav-group { flex-wrap: wrap; }
  .edit-toolbar-nav-group .edit-btn-nav { flex: 1 1 auto; }

  /* Final safety net: clip anything inside the editor that somehow
     still escapes the column — keeps the visible viewport tidy even
     if a new toolbar widget lands with the same min-width pattern. */
  .editor-with-preview { overflow-x: clip; min-width: 0; }

  /* ── F13 · Canvas/preview reasonable mobile heights ────────────
     Without sticky pinning we don't need vh caps; instead cap the
     intrinsic heights so the canvas + preview don't dominate the
     scroll. */
  .editor-with-preview .image-stage {
    max-height: 50vh;
    margin-inline: auto;
  }
  .editor-with-preview .selected-product-preview {
    /* drop the absolute-positioned close button anchor weirdness */
    width: 100%;
    max-width: 100%;
  }

  /* ── F14 · Edit tab bar: wrap so "Reset" doesn't overflow ──────
     Tabs (Geometry / Adjust / Tools) + the right-aligned Reset
     button total ~310 px in a 308 px column → 14 px clip on the
     right. Letting the bar wrap pushes Reset to its own row,
     where `margin-left: auto` still right-aligns it cleanly. */
  .edit-tab-bar { flex-wrap: wrap; row-gap: 4px; }

  /* ── F15 · Single-canvas mobile editor ─────────────────────────
     Gilly + beta feedback (2026-05-22): seeing TWO canvases on a
     phone (raw image-stage on top + mockup preview below) is
     redundant — the mockup IS the WYSIWYG view of what you're
     buying. Hide image-stage on mobile and promote the live mockup
     preview as the primary visual. Edits cascade automatically:
     renderCanvas() (the editor's source of truth) calls
     refreshLivePreview() at the end of every redraw, so cropping /
     wavelength / colour / sliders all update the preview live.
     The "Generate real mockup" button already toggles back to the
     live canvas when re-pressed, so the back-out flow is built in. */
  .editor-with-preview .image-stage,
  /* Hide the editor canvas's resize handle (it lives inside
     image-stage but renders absolutely outside on some browsers). */
  .editor-resize-handle { display: none !important; }

  /* Promote the preview to hero size. Canvas is drawn at 260×260
     pixels but stretches up cleanly via CSS — the underlying art is
     vector-style shapes + a bitmap sun overlay, so a moderate scale
     reads sharp enough on a Retina phone. (For maximum sharpness
     also bump the JS canvas pixel size — see solar-archive.js
     `pw = ph = 260` → 360 on mobile.) */
  .selected-product-preview .preview-mockup {
    max-width: 340px;
    margin: 4px auto 12px;
  }
  .selected-product-preview .preview-mockup canvas.live-preview-canvas,
  .selected-product-preview .preview-mockup img.preview-real-mockup {
    width: 100%;
    height: auto;
    max-width: 340px;
  }
  /* The product preview pane is now the visual centerpiece; give it
     a bit more breathing room and bolder typography for the name. */
  .selected-product-preview { padding: 16px; }
  .selected-product-preview .preview-product-name {
    font-size: 1.1rem;
    margin-bottom: 6px;
  }
  /* The "Generate real mockup" / "Reset to mock mockup" toggle —
     rename via CSS pseudo-content. The default label text is
     wrapped in `.btn-preview-mockup-label`; we hide it and inject a
     mobile-clearer string. (JS still updates the inner label on
     state change, so we keep both in sync by re-asserting both.) */

  /* ── F16 · Sticky mobile preview pane + drag-to-resize ─────────
     Gilly + beta (2026-05-22, round 3): with image-stage hidden
     (F15) the mockup preview IS the canvas — let it float pinned
     at the top of the viewport so edits made in the toolbar below
     are visible in real time without scrolling back up. Drag the
     bottom-right corner grip to scale the canvas down (free up
     viewport for sliders) or back up (see detail). Persisted in
     localStorage so the choice survives page loads.
     Implementation: pane goes position:sticky with a solid
     background so toolbar contents scrolling underneath don't
     bleed through. The canvas itself is scaled via the
     --preview-scale CSS var, written by JS setupMobilePreviewResize. */
  .editor-with-preview .selected-product-preview {
    position: sticky;
    top: 0;
    z-index: 6;
    background: var(--bg-elevated, #1a1a2e);
    /* Hard cap on the pane's height so the toolbar isn't pushed
       offscreen even when the user maxes the canvas. */
    max-height: 70vh;
    /* Reviewer M6: dropped overflow-y:auto here — a sticky child with
       its own scroll container intercepted touch deltas when users
       thumb-dragged the toolbar below, leaving the page unable to
       scroll. Let the page scroll; the pane caps height via max-height
       and the canvas scales via --preview-scale instead. */
    overflow: visible;
    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
  }
  /* Canvas + real-mockup img both ride on --preview-scale. Default
     1 = the 340px cap from F15. */
  .editor-with-preview .selected-product-preview .preview-mockup {
    max-width: calc(340px * var(--preview-scale, 1));
    transition: max-width 0.05s linear; /* a hair, so dragging feels live */
  }
  .editor-with-preview .selected-product-preview .preview-mockup
    canvas.live-preview-canvas,
  .editor-with-preview .selected-product-preview .preview-mockup
    img.preview-real-mockup {
    max-width: 100%;
    width: 100%;
    height: auto;
  }
  /* Corner grip — bottom-right, a "≡"-ish chevron stack so it
     reads as draggable rather than decorative. Touch-action:none
     keeps page scroll from stealing the drag gesture. */
  .preview-resize-handle {
    position: absolute;
    bottom: 4px; right: 4px;
    width: 30px; height: 30px;
    cursor: nwse-resize;
    touch-action: none;
    z-index: 7;
    border-radius: 6px;
    background:
      linear-gradient(135deg,
        transparent 0 38%,
        var(--accent-corona, #d9a91a) 38% 46%,
        transparent 46% 56%,
        var(--accent-corona, #d9a91a) 56% 64%,
        transparent 64% 74%,
        var(--accent-corona, #d9a91a) 74% 82%,
        transparent 82% 100%);
    opacity: 0.65;
    transition: opacity 0.15s ease;
  }
  .preview-resize-handle:hover,
  .preview-resize-handle:focus-visible,
  .preview-resize-handle.dragging { opacity: 1; outline: none; }
  /* Move the close × inboard a touch so it doesn't crowd the handle
     when the user drags the pane to its smallest size. */
  .selected-product-preview .preview-close-btn {
    /* keep top-right — handle is bottom-right, so they're far apart */
  }
}

/* ── F11 · Very-narrow phones (≤ 389 px, e.g. iPhone SE 1) ──────
   Drop wavelength grid to 2 columns so each tile is wide enough
   that the descriptor fits without breaking into 3 lines. */
@media (max-width: 389px) {
  .wavelength-grid { grid-template-columns: repeat(2, 1fr); }
  /* Product grid stays 2-up but tighten gap further. */
  .product-grid { gap: 8px; }
}




/* Share button riding in the vibe-grid section header. */
.share-image-step-btn { margin-left: auto; }
.share-image-step-btn.hidden { display: none; }

/* ── Light-mode contrast repairs (launch audit §6.2) ─────────────────
   The confirm/size modal's chips + tiles were styled with white-based
   translucency that assumes a dark surface; on the light (paper) theme
   the selected chip rendered near-white text on a near-white wash — the
   selected state read WORSE than the unselected ones. Gold-as-text call
   sites now use --accent-corona-text (10.9:1 dark / #806200 light), and
   the blocks below re-ground the modal surfaces for light mode. */
html:not(.dark) .confirm-size-chip {
  background: rgba(0, 0, 0, 0.05);
  border-color: rgba(0, 0, 0, 0.18);
}
html:not(.dark) .confirm-size-chip:hover {
  color: var(--text-primary);
}
html:not(.dark) .confirm-size-chip.active {
  background: rgba(217, 169, 26, 0.22);
  border-color: var(--accent-corona-text, #806200);
  color: var(--text-primary);
}
html:not(.dark) .confirm-variant-picker {
  background: rgba(0, 0, 0, 0.05);
  border-color: rgba(0, 0, 0, 0.08);
  scrollbar-color: rgba(0, 0, 0, 0.25) transparent;
}
html:not(.dark) .confirm-variant-tile {
  background: #ffffff;
  border-color: rgba(0, 0, 0, 0.15);
}
html:not(.dark) .confirm-variant-tile:hover {
  background: rgba(217, 169, 26, 0.10);
}
html:not(.dark) .confirm-variant-tile.active {
  border-color: var(--accent-corona-text, #806200);
  background: rgba(217, 169, 26, 0.16);
  box-shadow: 0 0 0 1px var(--accent-corona-text, #806200) inset;
}
/* Colour-swatch borders (rgba(255,255,255,.18) resting / .35 for
   data-tone="dark") are invisible on a white panel regardless of the
   swatch's own fill — White/White-Base swatches vanished entirely. */
html:not(.dark) .confirm-color-swatch { border-color: rgba(0, 0, 0, 0.18); }
html:not(.dark) .confirm-color-swatch[data-tone="dark"] { border-color: rgba(0, 0, 0, 0.35); }

/* Citation link in the hero fineprint: inherit the fineprint gray
   (default UA blue looked unstyled and off-palette). */
.quality-strip-fineprint a {
  color: inherit;
  text-decoration: underline;
}

/* ── Grouped landing grid (curated top row + categories) ─────────── */
#productGrid.product-grid-grouped {
  display: block;
}
.product-group + .product-group {
  margin-top: 30px;
}
.product-group-header {
  font-size: 1.02rem;
  font-weight: 600;
  margin: 0 0 12px;
  padding-bottom: 6px;
  color: var(--text-primary);
  border-bottom: 1px solid var(--border);
}
.product-group[data-group="_featured"] .product-group-header {
  color: var(--accent-corona-text, #f3d385);
}

/* ── Data-credits inline card (was a blocking modal — audit §6.5) ── */
.data-credits-card {
  position: relative;
  margin: 18px 0 4px;
  padding: 16px 40px 16px 16px;
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent-corona-text, #f3d385);
  border-radius: var(--radius-md);
}
.data-credits-card-title {
  font-weight: 600;
  font-size: 0.92rem;
  margin-bottom: 10px;
  color: var(--text-primary);
}
.data-credits-card-close {
  position: absolute;
  top: 8px;
  right: 10px;
  width: 28px;
  height: 28px;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
  font-size: 0.9rem;
}
.data-credits-card-close:hover {
  background: rgba(128, 128, 128, 0.15);
  color: var(--text-primary);
}

/* ═══════════════════════════════════════════════════════════════════
   Restyle-to-match + 3D-experience handoff (2026-08-13)
   Matches web3d/src/styles.css: Fraunces serif headings, Inter body, on
   the store's existing warm-dark palette. Self-hosted variable fonts
   (the store isn't Vite-built, so it can't import @fontsource). Fonts are
   copied to /asset/fonts/ by infra/worker/build-public.sh.
   ═══════════════════════════════════════════════════════════════════ */
@font-face {
  font-family: "Inter Variable";
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url("/asset/fonts/inter.woff2") format("woff2");
}
@font-face {
  font-family: "Fraunces Variable";
  font-style: normal;
  font-display: swap;
  font-weight: 100 900;
  src: url("/asset/fonts/fraunces.woff2") format("woff2");
}
:root {
  --sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
  --serif: "Fraunces Variable", ui-serif, Georgia, serif;
}
/* serif for the display headings, to echo the 3D experience */
#appTitle,
.hero-tagline,
.section-title {
  font-family: var(--serif);
  font-optical-sizing: auto;
  letter-spacing: -0.01em;
}

/* ── Handoff: suppress marketing so an arriving buyer lands on intent ── */
body.fromHandoff .hero,
body.fromHandoff #qualityStrip {
  display: none !important;
}
/* lock scroll under the confirm overlay + hide any floating store chrome so the
   "confirm your Sun" screen reads as a clean, branded bridge */
body.handoff-confirm {
  overflow: hidden;
}
body.handoff-confirm #vibeRevealCta,
body.handoff-confirm #vibeMasterToggle,
body.handoff-confirm #feedbackFabGroup,
body.handoff-confirm #workflowBreadcrumb {
  display: none !important;
}

/* ── "Confirm your Sun" bridge overlay ── */
.handoff-overlay {
  position: fixed;
  inset: 0;
  z-index: 4000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 6vh 6vw;
  background: radial-gradient(ellipse at 50% 38%, #141210 0%, #0a0908 70%, #050505 100%);
  /* This gradient is always near-black in both themes, but --text-primary
     and --text-secondary flip to dark-navy/grey under html:not(.dark) (see
     the light-theme block near the top of this file); that made every
     label here read as near-invisible dark-on-dark for light-mode visitors
     (contrast audit). Pin explicit light colors instead of inheriting the
     theme tokens — matched to web3d's ink pair (Tier 3, 2026-08-17):
     #f4efe6 measures 16.3-17.8:1 and #c3b9a9 measures 9.6-10.5:1 against
     this gradient's #141210-#050505 range, both well past the 4.5:1 floor. */
  --handoff-text: #f4efe6;
  --handoff-text-dim: #c3b9a9;
}
.handoff-overlay[hidden] {
  display: none;
}
.handoff-card {
  max-width: 26rem;
  width: 100%;
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.9rem;
  animation: handoff-rise 0.6s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes handoff-rise {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: translateY(0); }
}
#confirmOverlay.is-leaving { animation: handoff-fade-out 320ms ease forwards; }
@keyframes handoff-fade-out {
  from { opacity: 1; transform: translateY(0); }
  to { opacity: 0; transform: translateY(-8px); }
}
.handoff-eyebrow {
  margin: 0;
  font-family: var(--sans);
  text-transform: uppercase;
  letter-spacing: 0.34em;
  font-size: 0.66rem;
  color: var(--handoff-text-dim);
}
.handoff-thumb {
  width: min(58vw, 20rem);
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: 50%;
  box-shadow: 0 0 60px -10px rgba(217, 169, 26, 0.5), 0 10px 40px rgba(0, 0, 0, 0.6);
  background: #0a0908;
}
.handoff-meta {
  margin: 0;
  font-family: var(--serif);
  font-size: clamp(1.15rem, 3.4vw, 1.6rem);
  color: var(--handoff-text);
}
.handoff-actions {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.7rem;
  margin-top: 0.4rem;
}
/* Aligned to web3d's .cta--bar (Tier 3 polish pass, 2026-08-17): this is
   the literal same button on both sides of the handoff and had drifted on
   six axes. border/backdrop-filter deliberately NOT imported: web3d's glass
   treatment answers to a constantly-repainting WebGL canvas behind it; the
   store sits on a static image and doesn't need it. */
.handoff-continue {
  font-family: var(--sans);
  font-size: 0.82rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: #0a0908;
  background: var(--text-primary);
  border: 0;
  border-radius: 999px;
  padding: 0.7rem 2.4rem;
  min-height: 44px;
  cursor: pointer;
  transition: background var(--transition);
}
.handoff-continue:hover {
  background: #fffdf7;
}
/* Same visual weight as .handoff-continue (it replaces the old single-click
   commit as the funnel's real action button) plus a disabled state, since
   Submit starts disabled until a quad cell is selected. */
.handoff-submit-row {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.9rem;
}
.handoff-submit-btn {
  font-family: var(--sans);
  font-size: 0.82rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: #0a0908;
  background: var(--text-primary);
  border: 0;
  border-radius: 999px;
  padding: 0.7rem 2.4rem;
  min-height: 44px;
  cursor: pointer;
  transition: background var(--transition), opacity var(--transition);
}
.handoff-submit-btn:hover:not(:disabled) {
  background: #fffdf7;
}
.handoff-submit-btn:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}
.handoff-submit-hint {
  margin: 0;
  font-size: 0.72rem;
  color: var(--handoff-text-dim);
  text-align: center;
  max-width: 40ch;
}
.handoff-edit {
  font-family: var(--sans);
  font-size: 0.74rem;
  letter-spacing: 0.06em;
  color: var(--handoff-text-dim);
  background: transparent;
  border: 0;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
  min-height: 40px;
}
.handoff-edit:hover {
  color: var(--text-primary);
}
/* Opens the reused compare-slider (see #confirmCompareSlider) — a distinct
   pill rather than another underlined text link, since it toggles a mode
   rather than navigating away. */
.handoff-compare-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0.6rem auto 0;
  padding: 0.45rem 1rem;
  font-family: var(--sans);
  font-size: 0.74rem;
  letter-spacing: 0.04em;
  color: var(--handoff-text-dim);
  background: transparent;
  border: 1px solid rgba(232, 226, 208, 0.2);
  border-radius: 999px;
  cursor: pointer;
  transition: border-color var(--transition), color var(--transition);
}
.handoff-compare-btn:hover,
.handoff-compare-btn:focus-visible {
  color: var(--text-primary);
  border-color: var(--accent-corona-text, #f3d385);
}
#confirmCompareSlider {
  margin: 0.8rem auto 0;
}

/* ── Persistent editable Sun summary chip (rides after Continue) ── */
.sun-summary-chip {
  position: fixed;
  top: max(0.8rem, env(safe-area-inset-top));
  right: max(0.8rem, env(safe-area-inset-right));
  z-index: 900;
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  padding: 0.35rem 0.7rem 0.35rem 0.4rem;
  font-family: var(--sans);
  font-size: 0.72rem;
  color: var(--text-primary);
  background: rgba(37, 34, 28, 0.92);
  border: 1px solid rgba(232, 226, 208, 0.16);
  border-radius: 999px;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
  cursor: pointer;
  backdrop-filter: blur(6px);
}
.sun-summary-chip[hidden] {
  display: none;
}
.chip-thumb {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  object-fit: cover;
  background: #0a0908;
}
.chip-meta {
  white-space: nowrap;
  letter-spacing: 0.02em;
  /* Pinned, not var(--text-primary) — .sun-summary-chip's background
     is a hard-pinned dark rgba (never themes), so the theme-switching
     ink token went navy-on-dark and disappeared in light mode (the
     bridge invisible-text bug, 2026-08-15). */
  color: #e8e2d0;
}
.chip-pen {
  color: var(--accent-sun);
  font-size: 0.7rem;
}
.sun-summary-chip:hover {
  border-color: rgba(217, 169, 26, 0.5);
}

/* ═══════════════════════════════════════════════════════════════════════
   MOLTEN RECORD — Phase 1: the continuous field, anchoring, and the pour
   (2026-08-13). Concept: "the light stays liquid until it's yours."

   Phase 1 is deliberately ZERO-JAVASCRIPT. Everything below works with no
   library loaded, so the store is complete and beautiful before GSAP arrives
   and simply gets upgraded when it does. If the vendored bundle never loads,
   nothing here is missing.

   THE ONE PERFORMANCE RULE for this whole system: move elements, never
   animate gradients. Animating a gradient's stops or position repaints the
   whole surface every frame. A pre-rasterized layer moved with translate3d
   or cross-faded with opacity is compositor-only: same picture, roughly an
   order of magnitude cheaper.
   ═══════════════════════════════════════════════════════════════════════ */

/* ── Registered custom properties ──────────────────────────────────────
   Registration is MANDATORY, not cosmetic. An unregistered custom property
   is a string token to the browser, so it STEPS between values instead of
   interpolating, and every animation built on these would jump rather than
   flow. */
@property --field-temp { syntax: "<number>"; initial-value: 0; inherits: true; }
@property --motion-amp { syntax: "<number>"; initial-value: 1; inherits: true; }
@property --heat       { syntax: "<number>"; initial-value: 0; inherits: false; }
/* --rhef defaults to 1, the FINISHED state, not 0. The reveal animates 0->1,
   but if it never runs — no JavaScript, tier 3, vendor missing, visitor never
   scrolls there — the panel must still rest on the equalized image. Defaulting
   to 0 would leave the store permanently advertising the inferior version of
   its own product. Safe direction only.

   inherits:true is REQUIRED, not incidental: the value is animated on
   #qualityStrip but consumed by a descendant image. With inherits:false the
   descendant silently reads the initial value forever and the reveal does
   nothing at all — which is exactly how it first failed. */
@property --rhef       { syntax: "<number>"; initial-value: 1; inherits: true; }
@property --ig         { syntax: "<number>"; initial-value: 0; inherits: false; }

/* ── The field ─────────────────────────────────────────────────────────
   One continuous surface behind the entire document, replacing per-section
   backgrounds as the thing that defines depth. Two stacked static gradient
   layers cross-fade warm to cool as you descend (Phase 3 drives --field-temp
   from scroll); two blurred convection cells drift on coprime periods so the
   ambience never visibly loops.

   The palette here is a TEMPERATURE SCALE, not a color scheme: corona gold at
   the furnace, cool blue by the time the object is cooled and paid for. */
.field {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
  contain: layout paint style;
}
.field-warm,
.field-cool {
  position: absolute;
  inset: -25%;
}
/* The source is composed just OFF the top-left corner, so the page reads as
   standing inside the light rather than looking at a decorative blob. Three
   stops rather than two: a hot core, a mid falloff, then the long tail. Two
   stops produce a visible ring edge at this intensity. */
.field-warm {
  background:
    radial-gradient(80vmax 66vmax at 12% -8%,
      var(--field-core) 0%,
      var(--field-core-mid) 34%,
      transparent 64%),
    radial-gradient(64vmax 56vmax at 92% 88%,
      var(--field-flare) 0%,
      transparent 58%);
  /* the pour: a hot core opening outward on first paint (see 1e below) */
  animation: fieldPour 900ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Vignette. Deepening the corners is what makes the core read as luminous:
   without it the eye has no darker reference and the whole field flattens
   into a uniform wash — which is exactly how the first attempt failed. */
.field::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(120vmax 100vmax at 26% 12%,
    transparent 36%, var(--field-vignette) 100%);
}
/* Cool layer sits above warm and is revealed purely by opacity. This is the
   whole temperature scrub: no color-mix on a scrubbed property, no gradient
   repaint, just one compositor-friendly cross-fade. */
.field-cool {
  background:
    radial-gradient(ellipse at 30% 18%, var(--field-cool-tint) 0%, transparent 52%),
    radial-gradient(ellipse at 72% 78%, var(--field-cool-tint-soft) 0%, transparent 55%);
  opacity: var(--field-temp);
}
/* Convection. The blur is STATIC so it rasterizes exactly once; only
   transform animates. Amplitude rides --motion-amp so pools and the tier
   system can damp the ambience to nothing without touching these rules. */
/* Outer: geometry + the CSS convection drift. Deliberately carries NO paint,
   so the inner is free to own its own transform (see the markup note in
   index.html — a running CSS animation outranks inline styles, so the drift
   and the velocity stretch cannot share an element). */
.field-cell {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 62vmax;
  height: 62vmax;
  margin: -31vmax 0 0 -31vmax;
  will-change: transform;
}
/* Inner: all the paint. The blur is STATIC so it rasterises exactly once;
   only transform and opacity ever change, which keeps this compositor-only. */
.field-cell-core {
  display: block;
  position: absolute;
  inset: 0;
  border-radius: 50%;
  filter: blur(70px);
  opacity: calc(0.78 * var(--motion-amp));
  will-change: transform;
}
.field-cell--a { animation: cellDriftA 47s ease-in-out infinite; }
.field-cell--b { animation: cellDriftB 61s ease-in-out infinite; }
.field-cell--a .field-cell-core {
  background: radial-gradient(circle, var(--field-cell-tint) 0%, transparent 68%);
}
.field-cell--b .field-cell-core {
  background: radial-gradient(circle, var(--field-flare) 0%, transparent 68%);
}
/* 47s and 61s are coprime, so the two cells never resynchronise and the
   pattern has no visible repeat period. */
@keyframes cellDriftA {
  0%   { transform: translate3d(-16vw, -10vh, 0) scale(1); }
  50%  { transform: translate3d(12vw, 8vh, 0) scale(1.10); }
  100% { transform: translate3d(-16vw, -10vh, 0) scale(1); }
}
@keyframes cellDriftB {
  0%   { transform: translate3d(14vw, 12vh, 0) scale(1.06); }
  50%  { transform: translate3d(-11vw, -9vh, 0) scale(0.94); }
  100% { transform: translate3d(14vw, 12vh, 0) scale(1.06); }
}

/* ── 1e. The pour ──────────────────────────────────────────────────────
   First paint is already the warm dark field with a corona core: no white
   flash, no spinner, nothing gated. The pour ACCOMPANIES content rather than
   delaying it — a loader that withholds the page is a conversion cost, not a
   flourish. */
@keyframes fieldPour {
  from { opacity: 0; transform: scale(0.42); }
  to   { opacity: 1; transform: scale(1); }
}
@keyframes pourRise {
  from { opacity: 0; transform: translate3d(0, 14px, 0); }
  to   { opacity: 1; transform: none; }
}
/* The hero surfaces with the pour rather than the old generic fade. Applied
   to inner elements only, never to a section, so no containing block is
   created for the hoisted fixed UI. */
.hero .hero-icon { animation: pourRise 700ms cubic-bezier(0.22, 1, 0.36, 1) both 120ms; }
.hero h1         { animation: pourRise 760ms cubic-bezier(0.22, 1, 0.36, 1) both 190ms; }
.hero-tagline    { animation: pourRise 780ms cubic-bezier(0.22, 1, 0.36, 1) both 260ms; }
.hero p:not(.hero-tagline) { animation: pourRise 800ms cubic-bezier(0.22, 1, 0.36, 1) both 330ms; }

/* ── 1b. Flow and anchor layers ────────────────────────────────────────
   The structural split that keeps motion off click targets.

   .flow-inner  — the ONLY thing continuous scroll-linked motion ever touches
                  (Phase 3's PASS drift and velocity stretch).
   .anchor-layer — never drifts, never stretches, never scales.

   The precise rule is narrower than "CTAs must not animate": the hazard is
   CONTINUOUS motion under a pointer that is already aiming, which is a
   straight Fitts's law penalty on the one element whose job is being clicked.
   A one-time entrance that finishes and comes to rest is fine, which is why
   product cards can still ride the wave reveal while living in .anchor-layer.

   Both are plain block containers by design: they add grouping without
   introducing a formatting context that could disturb existing layout. */
.flow-inner,
.anchor-layer {
  display: block;
}
.anchor-layer {
  /* Belt and braces. If a future rule ever tries to transform this subtree,
     it fails loudly in review rather than silently costing clicks. */
  transform: none !important;
}

/* ── Reduced motion ────────────────────────────────────────────────────
   accessibility.html publicly states that scroll animations short-circuit
   under the OS preference, so this is a correctness requirement rather than
   a courtesy. The field stays (it is the page's background, not an
   animation); everything that moves stops. */
@media (prefers-reduced-motion: reduce) {
  /* Drift stops on the outer, dimming applies to the inner, which is where
     the paint moved when the velocity stretch needed its own element. */
  .field-cell { animation: none; }
  .field-cell-core { opacity: 0.18; }
  .field-warm { animation: none; }
  .hero .hero-icon,
  .hero h1,
  .hero-tagline,
  .hero p:not(.hero-tagline) { animation: none; }
  #confirmOverlay.is-leaving { animation: none; }
}

/* Inline trust signals at the two hesitation points (variant modal, checkout
   button). The full .trust-bar still lives after the editor; these are the
   compact restatement placed where the doubt actually occurs. Muted by
   design — reassurance should be findable, not compete with the price. */
.trust-inline {
  display: flex;
  flex-wrap: wrap;
  gap: 4px 14px;
  margin: 10px 0 0;
  color: var(--text-secondary);
  font-size: 0.72rem;
  line-height: 1.5;
}
.trust-inline i { color: var(--accent-sun); margin-right: 5px; }
.editor-actions .trust-inline,
.confirm-modal-actions + .trust-inline { justify-content: center; }

/* ── MOLTEN RECORD phase 4: signature moments ──────────────────────────
   The heliograph writes with sunlight, so headlines are INSCRIBED: a
   write-head travels the reading direction and each glyph ignites white-hot
   then cools behind it. --heat is the single animated channel; colour and
   glow are derived from it here so each character carries ONE animated
   property instead of three.

   Words are inline-block and nowrap so line breaking is identical to the
   unsplit text — that is what keeps a per-character split from causing a
   layout shift. The split is reverted once the animation finishes. */
.mr-word {
  display: inline-block;
  white-space: nowrap;
}
.mr-char {
  display: inline-block;
  will-change: transform, opacity;
  /* Ignites toward white-hot, settles back to the inherited text colour.
     color-mix in oklab so the ramp does not pass through a muddy midpoint. */
  color: color-mix(in oklab, currentColor calc((1 - var(--heat)) * 100%), #fff8e8);
  text-shadow: 0 0 calc(3px + 15px * var(--heat))
               rgba(240, 199, 94, calc(0.10 + 0.72 * var(--heat)));
}

/* RHEF equalization. The filtered panel resolves FROM a dark, high-contrast
   state (what the raw telescope frame looks like) UP to the equalized version
   where the faint outer atmosphere is finally visible at the same time as the
   bright disk. The animation performs the product claim rather than captioning
   it — and it is the store's one genuinely unstealable transition, because it
   comes out of the published research the product is built on.

   Driven by --rhef on #qualityStrip, registered via @property so it
   interpolates instead of stepping. */
#qualityStrip .diptych-pane:nth-of-type(2) .diptych-img {
  filter:
    brightness(calc(0.62 + 0.38 * var(--rhef)))
    contrast(calc(1.28 - 0.28 * var(--rhef)))
    saturate(calc(0.82 + 0.18 * var(--rhef)));
}

/* Reduced motion: no inscription, no reveal, no breathing. The RHEF panel
   still rests at its FINISHED state — the point of that image is the
   equalized result, and a static visitor must never be left looking at the
   unequalized one. */
@media (prefers-reduced-motion: reduce) {
  #qualityStrip .diptych-pane:nth-of-type(2) .diptych-img { filter: none; }
  .mr-char { text-shadow: none; color: inherit; }
}

/* ── MOLTEN RECORD phase 5: micro-interactions ─────────────────────────
   Pure CSS transitions driven by custom properties that plain pointer
   handlers write (see initInteractions in motion.js). No GSAP, no rAF: hover
   feedback has to be instant, and it must not depend on the tween engine.

   Retuned for plasma rather than water throughout. A cursor here is a MAGNETIC
   FOOTPOINT that organises light toward itself, not a paddle displacing a
   fluid; a press is magnetic RECONNECTION, elongated along the field, not a
   ripple expanding as a ring. --lx/--ly are the pointer position within the
   element, in percent. */

/* Buttons: reconnection. The 60%/140% gradient is the whole idea — energy
   elongates ALONG the button's long axis because plasma runs along field
   lines, where a Material-style ripple would expand as a circle. */
.product-select-btn,
.vibe-open,
.btn-buy-in-editor,
.cta,
.handoff-continue,
.confirm-modal-btn,
.vibe-master-btn {
  position: relative;
  isolation: isolate;
  --lx: 50%;
  --ly: 50%;
}
.product-select-btn::before,
.vibe-open::before,
.btn-buy-in-editor::before,
.cta::before,
.handoff-continue::before,
.confirm-modal-btn::before,
.vibe-master-btn::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  transition: opacity 180ms ease;
  background: radial-gradient(60% 140% at var(--lx) var(--ly),
    rgba(255, 248, 232, 0.55) 0%,
    rgba(240, 199, 94, 0.30) 38%,
    transparent 72%);
}
.product-select-btn:hover::before,
.vibe-open:hover::before,
.btn-buy-in-editor:hover::before,
.cta:hover::before,
.handoff-continue:hover::before,
.confirm-modal-btn:hover::before,
.vibe-master-btn:hover::before { opacity: 1; }

/* The press: impulsive rise, long decay. Real flares rise in seconds and decay
   over minutes, roughly 1:15, and that asymmetry is why this reads as a
   release of energy rather than a generic button bounce. */
@keyframes mrReconnect {
  0%   { opacity: 0; transform: scale(0.96); }
  8%   { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.04); }
}
.product-select-btn:active::before,
.vibe-open:active::before,
.btn-buy-in-editor:active::before,
.cta:active::before,
.handoff-continue:active::before,
.confirm-modal-btn:active::before,
.vibe-master-btn:active::before {
  animation: mrReconnect 900ms cubic-bezier(0.33, 1, 0.68, 1);
}

/* Cards: tilt toward the footpoint plus directional limb heating on the edge
   nearest the cursor. Deliberately NOT a wobble — a settle time in front of a
   click target is a tax on the one zone where time-to-click must not rise, and
   jelly physics reads as rubber, which is the wrong material entirely. */
@media (hover: hover) and (pointer: fine) {
  .product-card,
  .vibe-card { --mr-rx: 0deg; --mr-ry: 0deg; --lx: 50%; --ly: 50%; }

  .product-card:hover {
    transform: translateY(-3px) perspective(900px)
               rotateX(var(--mr-rx)) rotateY(var(--mr-ry));
  }
  .vibe-card:hover {
    transform: translateY(-1px) perspective(900px)
               rotateX(var(--mr-rx)) rotateY(var(--mr-ry));
  }
  /* Compression heating on the near limb. */
  .product-card::after,
  .vibe-card::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    opacity: 0;
    transition: opacity 220ms ease;
    background: radial-gradient(38% 38% at var(--lx) var(--ly),
      rgba(245, 205, 100, 0.20) 0%, transparent 70%);
  }
  .product-card { position: relative; }
  .product-card:hover::after,
  .vibe-card:hover::after { opacity: 1; }
}

/* Links: filament ignition. The underline runs OUTWARD from wherever the
   cursor approached rather than wiping left-to-right, which is both more
   physical and free — it is one transform-origin read from the pointer. */
.quality-strip-fineprint a,
.app-footer a,
.handoff-card a {
  position: relative;
  text-decoration: none;
  --lx: 50%;
}
.quality-strip-fineprint a::after,
.app-footer a::after,
.handoff-card a::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -2px;
  height: 1px;
  background: linear-gradient(90deg,
    var(--accent-sun), var(--accent-corona) 50%, var(--accent-sun));
  transform: scaleX(0);
  transform-origin: var(--lx) 50%;
  transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
.quality-strip-fineprint a:hover::after,
.app-footer a:hover::after,
.handoff-card a:hover::after { transform: scaleX(1); }

@media (prefers-reduced-motion: reduce) {
  .product-card:hover,
  .vibe-card:hover { transform: none; }
  .product-card::after,
  .vibe-card::after { display: none; }
  .quality-strip-fineprint a::after,
  .app-footer a::after,
  .handoff-card a::after { transition: none; }
}

/* ── MOLTEN RECORD phase 6: stillness ──────────────────────────────────
   The concept's endpoint. The pour cools into a solid object, and the object
   does not move.

   Stillness is the conversion mechanic, not a compromise against the design.
   Three things happen when ambient motion stops: attention is released for
   reading (you cannot compare two images or check a price while something
   drifts); stillness becomes the page's loudest gesture, since a page where
   everything moves has no way to say "this matters"; and continuous motion
   builds low-grade tension, so arriving at stillness on a CTA reads as
   resolution rather than commitment.

   Implemented as body classes + CSS rather than through the tween engine, so
   suppression is reliable even when the engine is not. Suppression failing
   open would be the expensive direction of that bet. */

/* NOTE: only .field-cell pauses. .field-warm carries fieldPour, a one-shot
   entrance, and pausing that would freeze the page's own arrival partway if a
   modal or focus happened within the first second of load. */
body.mr-still .field-cell { animation-play-state: paused; }
body.mr-still .field-cell-core { transition: opacity 650ms cubic-bezier(0.33,1,0.68,1); }

/* Motion is NEVER suppressed for user-initiated feedback. That contrast is the
   entire point: inside a cooled, still checkout the button is the one live
   element, which is what makes it read as the thing to press. Hover, press and
   the link ignition therefore survive every rule above. */

/* The checkout zero-motion zone: from the variant modal through to the Shopify
   handoff. No convection, no breathing, no entrance animation. Only button
   feedback and the progress indicator are permitted to move. */
body.step-review .field-cell,
body:has(#confirmSelectModal:not(.hidden)) .field-cell {
  animation-play-state: paused;
}
body.step-review .field-cell-core,
body:has(#confirmSelectModal:not(.hidden)) .field-cell-core {
  opacity: calc(0.42 * var(--motion-amp));
  transition: opacity 650ms cubic-bezier(0.33,1,0.68,1);
}

/* A form field with focus is a stillness basin. The date input especially:
   someone is about to type a wedding, a birth, possibly a death. Motion around
   that moment is noise at best. The page holds its breath. */
body:has(input:focus, textarea:focus, select:focus) .field-cell {
  animation-play-state: paused;
}

/* Proof and price both have to be READ, so neither competes with ambience. */
body:has(#qualityStrip:hover) .field-cell { animation-play-state: paused; }

/* Errors stop everything, so the visitor can read what went wrong.
   Deliberately NOT keyed on `.status-msg:not(:empty)`: that element carries
   ordinary backend-status text during normal operation, so the rule matched
   permanently and the field never convected at all. Stillness that never lifts
   is just a dead page. Gated on an explicit error class instead. */
body:has(.status-msg.error, .status-msg.err, #alertRegion:not(:empty)) .field-cell {
  animation-play-state: paused;
}

/* Async waits: ambient yields to DETERMINATE progress. During a cold backend
   wake (~20s), an image generation, or an HQ render (1-3 min), idle convection
   reads as "is this broken?" — a wait feels shorter when exactly one thing is
   visibly advancing and everything else holds still. */
body:has(#checkoutProgress:not(:empty)) .field-cell,
body:has(#imageStage.loading) .field-cell {
  animation-play-state: paused;
}

/* ── Room to arrive ────────────────────────────────────────────────────
   Card entrances need somewhere to enter, or they finish before the card is
   comfortably on screen and read as a flicker at the fold.

   Applied ONCE per section, not per grid. The first attempt put the gap on
   `.product-grid`, but that class is created per CATEGORY by renderProducts —
   nine of them — so it injected ~1900px of blank space into the product
   picker and made the page look empty and endless. Scoped to the section's
   moving content instead, which exists exactly once. */
.vibe-grid-section .flow-inner,
#productSection .flow-inner {
  margin-bottom: 12vh;
}
@media (max-width: 749px) {
  /* 12vh of a phone screen is a lot of nothing. */
  .vibe-grid-section .flow-inner,
  #productSection .flow-inner { margin-bottom: 6vh; }
}
@media (prefers-reduced-motion: reduce) {
  /* No entrance to make room for. */
  .vibe-grid-section .flow-inner,
  #productSection .flow-inner { margin-bottom: 0; }
}

/* ── Tier toggle: inline, not pinned ───────────────────────────────────
   #vibeRevealCta used to be position:fixed at the top of the viewport (with
   inline !important coordinates written by JS, and a matching top offset to
   dodge the breadcrumb). It now sits under the section-1 headline, directly
   above the cards it modifies.

   Floating it made a comparison control the most prominent element on the
   page — above the step the visitor is actually on — and a control that
   follows you around reads as chrome rather than as part of the flow. These
   rules override the old fixed positioning; the JS that wrote the inline
   coordinates has been removed rather than disabled. */
#vibeRevealCta {
  position: static !important;
  transform: none !important;
  /* Retired (2026-08-17, Gilly) — see the step-image override above for
     the full rationale. This was the base/fallback case (step "review"
     and anything not covered by a step-specific rule). */
  display: none !important;
  flex-direction: row !important;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px !important;
  margin: 0 0 18px !important;
  z-index: auto !important;
  pointer-events: auto;
}
/* The vibe grid reserved 64px for the toggle's old floating slot. */
.vibe-grid-section { padding-top: 0; }

/* ── Gamma: correct the RAW FITS renders, not the JPGs ─────────────────
   Earlier this applied #vibeRawGamma to .wl-thumb. That was wrong: those tiles
   are Helioviewer JPGs, which are already display-stretched, so the sqrt was a
   SECOND correction on top and washed them out.

   The image that actually needs it is the raw FITS render — the true
   "Original" tier — which comes out dark next to the JPG preview of the same
   moment. That is the mismatch worth fixing, and it is what the filter was
   written for ("live/custom-date raw frames that can't be pre-baked").

   Still display-only, and still NOT on the editor canvas: that canvas is the
   source of the print file, so a display-side stretch there would mean the
   buyer approves a corrected preview and receives an uncorrected print.
   Matching the editor means baking it into the pixel pipeline server-side. */
#confirmThumbRaw { filter: url(#vibeRawGamma); }
/* While the panel is still showing the Helioviewer JPG placeholder it must NOT
   be stretched: that image is already display-ready, and correcting it twice
   washes it out. The filter applies only once the real FITS frame swaps in. */
#confirmThumbRaw.is-placeholder { filter: none; }

/* ── Product category TOC ──────────────────────────────────────────────
   Nine groups is a long page, and the categories previously existed only as
   deep-link anchors for the 3D gallery — invisible to anyone browsing. This
   surfaces them as a jump list. Scrolls horizontally rather than wrapping to
   three lines on a phone. */
.product-toc {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0 0 18px;
}
.product-toc-link {
  display: inline-flex;
  align-items: center;
  min-height: 34px;
  padding: 6px 13px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: rgba(0, 0, 0, 0.18);
  color: var(--text-secondary);
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  text-decoration: none;
  white-space: nowrap;
  transition: color var(--transition), border-color var(--transition),
              background var(--transition);
}
.product-toc-link:hover,
.product-toc-link:focus-visible {
  color: var(--text-primary);
  border-color: var(--accent-sun);
  background: rgba(217, 169, 26, 0.12);
}
@media (max-width: 600px) {
  /* One scrollable row beats three wrapped lines above the fold. */
  .product-toc {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
    padding-bottom: 2px;
    /* Fade the right edge so a scrollWidth > clientWidth row reads as
       "more to scroll" instead of ending flush against the fold. */
    -webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
    mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
  }
  .product-toc::-webkit-scrollbar { display: none; }
}

/* ── Fine-tune commit row ──────────────────────────────────────────────
   A wavelength tile retunes the preview in place; these move you on. */
.wl-commit-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 10px;
  margin-top: 12px;
}
.wl-skip-btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 40px;
  padding: 9px 16px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-secondary);
  font: inherit;
  font-size: 0.84rem;
  font-weight: 600;
  cursor: pointer;
  transition: color var(--transition), border-color var(--transition);
}
.wl-skip-btn:hover {
  color: var(--text-primary);
  border-color: var(--accent-sun);
}
.wl-skip-btn.hidden { display: none; }

/* ── Examples are opt-in ───────────────────────────────────────────────
   The date picker is what this page is for. Eleven famous-moment cards ahead
   of it made picking your own date look like one option among many rather than
   the entire product, so they now sit behind an explicit ask.

   display:contents while open, so the cards remain grid items of .vibe-grid
   and every existing `.vibe-grid .vibe-card` selector keeps matching — the
   wrapper participates in no layout of its own. */
.vibe-examples { display: contents; }
.vibe-examples[hidden] { display: none; }

.vibe-examples-toggle {
  grid-column: 1 / -1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  min-height: 44px;
  padding: 11px 18px;
  border-radius: var(--radius-md);
  border: 1px dashed var(--border);
  background: transparent;
  color: var(--text-secondary);
  font: inherit;
  font-size: 0.88rem;
  font-weight: 600;
  cursor: pointer;
  transition: color var(--transition), border-color var(--transition),
              background var(--transition);
}
.vibe-examples-toggle:hover {
  color: var(--text-primary);
  border-color: var(--accent-sun);
  background: rgba(217, 169, 26, 0.07);
}
.vibe-examples-chevron { transition: transform var(--transition); }
.vibe-examples-toggle.is-open .vibe-examples-chevron { transform: rotate(180deg); }

/* The date card is the main event: it spans the grid instead of sitting as one
   tile among twelve. */
.vibe-card-birthday {
  grid-column: 1 / -1;
}
.vibe-card-birthday .vibe-birthday-inner { padding: 20px; }

/* The proof panel now lives inside section 1, under the toggle it explains, so
   it no longer needs the standalone section's outer spacing. */
#qualityStrip.quality-strip-section {
  margin: 0 0 16px;
  padding: 0;
  background: transparent;
}

/* ── Handoff: the look choice ──────────────────────────────────────────
   Two versions of the visitor's own Sun, side by side, both clickable. Asked
   with pictures rather than words because "Original or Filtered?" is a
   vocabulary question and people stall on those; two images is a glance.

   Picking one IS the continue, so the screen keeps a single decision. */
.handoff-card--choice { max-width: 720px; }

.handoff-question {
  margin: 4px 0 14px;
  font-family: var(--serif);
  font-size: 1.15rem;
  color: var(--handoff-text);
}
/* ── The quad (2026-08-24) ──────────────────────────────────────────────
   Columns = look (Original, Enhanced). Rows = form (Flat print, Dimensional
   object). Row 1 (auto) holds the corner + two column headers; the corner
   cell is empty so the row-head column lines up under it. */
.handoff-quad {
  display: grid;
  grid-template-columns: minmax(64px, auto) 1fr 1fr;
  grid-auto-rows: auto;
  gap: 10px 14px;
  align-items: center;
}
.handoff-quad-corner { grid-column: 1; }
.handoff-quad-colhead {
  font-family: var(--sans);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-align: center;
  color: var(--handoff-text-dim);
}
.handoff-quad-rowhead {
  font-family: var(--sans);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--handoff-text-dim);
  /* rotated on wide layouts only — see the media query below; stacked
     (horizontal) by default so it never clips on a phone */
  writing-mode: horizontal-tb;
}
@media (min-width: 561px) {
  .handoff-quad-rowhead {
    writing-mode: vertical-rl;
    transform: rotate(180deg);
    justify-self: center;
    white-space: nowrap;
  }
}
.handoff-cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  padding: 10px;
  border-radius: var(--radius-md);
  border: 2px solid var(--border);
  background: rgba(0, 0, 0, 0.22);
  color: var(--handoff-text);
  font: inherit;
  cursor: pointer;
  position: relative;
  transition: border-color var(--transition), background var(--transition),
              transform var(--transition);
}
.handoff-cell:hover,
.handoff-cell:focus-visible {
  border-color: var(--accent-sun);
  background: rgba(217, 169, 26, 0.10);
  transform: translateY(-2px);
}
/* The selected state IS the decision now (Submit acts on it), so it needs to
   read unmistakably — not just a slightly warmer hover tint. */
.handoff-cell.is-selected {
  border-color: var(--accent-sun);
  background: rgba(217, 169, 26, 0.16);
  box-shadow: 0 0 0 1px var(--accent-sun) inset;
}
.handoff-cell-check {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--accent-sun);
  color: #1a1408;
  font-size: 0.7rem;
  opacity: 0;
  transform: scale(0.6);
  transition: opacity var(--dur-fast, 140ms) ease, transform var(--dur-fast, 140ms) ease;
}
.handoff-cell.is-selected .handoff-cell-check { opacity: 1; transform: scale(1); }
.handoff-choice-imgwrap { position: relative; display: block; width: 100%; }
.handoff-choice-img {
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: var(--radius-sm);
  background: #0d0b08;
  display: block;
}
.handoff-choice-name {
  font-weight: 700;
  font-size: 0.95rem;
  letter-spacing: 0.01em;
}
.handoff-choice-sub {
  font-size: 0.76rem;
  color: var(--handoff-text-dim);
  text-align: center;
  line-height: 1.35;
}
/* Enhanced needs a real FITS fetch plus RHEF, so it arrives a beat later. The
   panel holds its place and says so, rather than the layout jumping when it
   lands — and Original stays pickable the whole time. */
.handoff-cell.is-pending .handoff-choice-img { opacity: 0.25; }
.handoff-choice-pending {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.78rem;
  color: var(--handoff-text-dim);
  letter-spacing: 0.04em;
  animation: pulse 1.8s ease-in-out infinite;
  pointer-events: none;
}
@media (max-width: 560px) {
  /* .handoff-choices { grid-template-columns: 1fr } removed 2026-08-24: dead
     since the quad rework — the container is .handoff-quad now, which has its
     own 560px rule further down (34px rowhead + 2 cell columns). */
  .handoff-choice-img { aspect-ratio: 16 / 10; }
}
/* Provenance line (contrast + honesty pass): names the mission, instrument
   and wavelength, and states the false-colour convention, right where the
   two preview images are compared, so neither card reads as a literal
   photograph. --handoff-text-dim measures 7.2-7.9:1 on this overlay, so a
   small font still clears the 4.5:1 AA floor. */
/* Sky controls for the Dimensional row of the handoff quad. Deliberately
   quieter than the four cells above: the quad is the decision, this is how the
   chosen thing is dressed. */
.handoff-sky {
  border: 1px solid rgba(255, 255, 255, 0.10);
  border-radius: 12px;
  padding: 0.55rem 0.8rem 0.7rem;
  margin: 0.75rem 0 0.25rem;
  background: rgba(255, 255, 255, 0.02);
}
.handoff-sky-legend {
  padding: 0 0.4rem;
  font-size: 0.78rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.62);
}
.handoff-sky-note {
  display: block;
  text-transform: none;
  letter-spacing: 0;
  font-size: 0.72rem;
  color: rgba(255, 255, 255, 0.40);
}
.handoff-sky-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.35rem;
}
.handoff-sky-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.38rem;
  padding: 0.3rem 0.62rem;
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 999px;
  font-size: 0.8rem;
  color: rgba(255, 255, 255, 0.66);
  cursor: pointer;
  user-select: none;
}
.handoff-sky-chip:hover { border-color: rgba(255, 255, 255, 0.28); }
/* The checkbox stays a real focusable checkbox — it is the control, and the
   chip is only its skin. Hiding it entirely would take the whole panel out of
   the keyboard order. */
.handoff-sky-chip input { accent-color: #d9a91a; margin: 0; }
.handoff-sky-chip:has(input:checked) {
  border-color: rgba(217, 169, 26, 0.55);
  background: rgba(217, 169, 26, 0.10);
  color: rgba(255, 255, 255, 0.92);
}
.handoff-sky-chip:focus-within { outline: 2px solid rgba(217, 169, 26, 0.7); outline-offset: 2px; }

.handoff-provenance {
  margin: 10px 0 0;
  font-size: 0.68rem;
  color: var(--handoff-text-dim);
  text-align: center;
}

/* ── Dimensional card (handoff bridge, third option) ─────────────────────
   The preview is a live plate render in an iframe, so it must sit in the same
   box the other two cards' <img> occupy and must never take the pointer — the
   card is the control, not the scene inside it. */
.handoff-choice-frame {
  width: 100%;
  aspect-ratio: 1;
  border: 0;
  border-radius: var(--r-md, 12px);
  background: var(--bg-deep, #0a0908);
  pointer-events: none;
  display: block;
}
/* The Dimensional cells' loading state.
   These two plates are DEFERRED on purpose (see _wireDimensionalCell): each
   one pulls the whole 3D bundle, and loading them ahead of the row that can
   actually be ordered would be backwards. But deferred rendered as EMPTY —
   two black squares under a heading, with nothing saying they were a preview
   waiting to be asked for rather than a pair of broken images (audit in
   Chrome, 2026-08-26). This is what sits in that gap. */
.plate-skel {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.7rem;
  border-radius: var(--r-md, 12px);
  background: radial-gradient(circle at 50% 45%, #17130d 0%, #0a0908 70%);
  pointer-events: none;
  opacity: 1;
  transition: opacity 600ms ease;
  z-index: 1;
}
/* Gone once the plate has actually painted — not merely once its document
   loaded, which is several seconds earlier. */
.plate-skel[data-state="ready"] { opacity: 0; }
.plate-skel-orb {
  width: 34%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle at 42% 38%,
    rgba(255, 214, 138, 0.34) 0%,
    rgba(212, 160, 23, 0.16) 55%,
    rgba(212, 160, 23, 0.04) 100%);
  box-shadow: 0 0 34px rgba(212, 160, 23, 0.13);
}
.plate-skel-text {
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted, #9b968c);
  text-align: center;
  padding: 0 0.8rem;
}
/* Only the armed state breathes. An idle cell is waiting for the visitor, not
   working, and animating it would claim otherwise. */
.plate-skel[data-state="loading"] .plate-skel-orb {
  animation: plate-skel-pulse 1.9s ease-in-out infinite;
}
@keyframes plate-skel-pulse {
  0%, 100% { transform: scale(0.92); opacity: 0.7; }
  50%      { transform: scale(1.04); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .plate-skel[data-state="loading"] .plate-skel-orb { animation: none; }
  .plate-skel { transition: none; }
}

/* Dimensional is orderable now (2026-08-26), so it is no longer dimmed to
   mark it as second-class. The class stays as a styling hook. */
.handoff-cell--preview { opacity: 1; }
.handoff-choice-badge {
  display: inline-block;
  margin-left: 0.4rem;
  padding: 0.1rem 0.45rem;
  border: 1px solid rgba(244, 239, 230, 0.32);
  border-radius: 999px;
  font-family: var(--sans);
  font-size: 0.6rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--handoff-text-dim, #c3b9a9);
  vertical-align: middle;
}
/* The grid SHAPE stays the same at every width (rowhead column + two
   image columns) — collapsing to one column would either stack both column
   headers before any cell (decoupling "Original"/"Enhanced" from the row
   they label) or need per-cell repeated labelling to stay legible. Easier
   and clearer to keep 4 real cells arranged as a genuine quad and just
   shrink it: the rowhead column narrows and the vertical-text rowhead label
   (see the >=561px rule above) reverts to compact horizontal text that fits
   the pinched space. */
@media (max-width: 560px) {
  .handoff-quad { grid-template-columns: 46px 1fr 1fr; gap: 6px 8px; }
  .handoff-quad-rowhead { font-size: 0.6rem; line-height: 1.15; }
  .handoff-cell { padding: 6px; }
  /* The badge fit fine spelled out beside "Dimensional" in the vertical,
     wide-screen rowhead; at 46px it wrapped both words across each other
     ("DIMENSIO" / "PREVI") rather than stacking cleanly. The badge's
     information survives on this width regardless — every Dimensional
     cell's own aria-label already says "Preview only". */
  .handoff-quad-rowhead .handoff-choice-badge { display: none; }
}

/* MH-12 waking panel: a small card shown while a render request waits on a
   sleeping origin. It never takes input (pointer-events: none) and moves
   only by opacity and scale; nothing animates a gradient
   (vault projects/solar-archive.md:446-454). Sits at the top so it clears
   the bottom toast and the sticky editor action bar. */
.waking-panel {
  position: fixed;
  top: 16px;
  left: 50%;
  z-index: 250;
  display: flex;
  align-items: center;
  gap: 12px;
  width: max-content;
  max-width: min(420px, calc(100vw - 32px));
  padding: 10px 16px 10px 10px;
  background: var(--bg-card);
  border: 1px solid rgba(247,168,37,0.3);
  border-radius: var(--radius-md);
  box-shadow: 0 8px 30px rgba(0,0,0,0.3);
  color: var(--text-primary);
  font-size: 0.85rem;
  line-height: 1.4;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transform: translateX(-50%) scale(0.96);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out, visibility 0s linear 0.6s;
}
.waking-panel[data-sun-state="waking"],
.waking-panel[data-sun-state="retrying"] {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) scale(1);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out, visibility 0s;
}
.waking-panel .waking-sun {
  width: 48px;
  height: 48px;
  flex-shrink: 0;
  border-radius: 50%;
  object-fit: cover;
}
.waking-panel .waking-status { margin: 0; }
/* Reduced motion: a static image and text, no scale, no fade. */
@media (prefers-reduced-motion: reduce) {
  .waking-panel,
  .waking-panel[data-sun-state="waking"],
  .waking-panel[data-sun-state="retrying"] {
    transition: none;
    transform: translateX(-50%);
  }
}
