/* ============================================================
   AvaCheck — visual system
   ------------------------------------------------------------
   The idea the whole design hangs on: pitch is a spectrum, so
   colour is one too. Low notes sit in deep violet, high notes
   rise into cyan, and a singer's range paints itself across that
   gradient as they find it. The one warm colour in the palette —
   amber — is reserved for a single thing: the note being sung
   right now. Nothing else in the app is allowed to be warm, so
   that note is always the first thing the eye lands on.

   All CSS. No frameworks, no webfonts, no images — this has to
   open on a slow connection.
   ============================================================ */

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

:root {
  /* ── ground ─────────────────────────────────────────────── */
  --bg-0: #07070f;
  --bg-1: #0d0e1c;

  /* ── the pitch spectrum ─────────────────────────────────── */
  --low: #7c5cff;          /* violet — chest voice, the bottom */
  --mid: #4c6ef5;          /* indigo — the brand anchor */
  --high: #22d3ee;         /* cyan — head voice, the top */
  --now: #ffc53d;          /* amber — the note happening NOW */

  /* ── surfaces ───────────────────────────────────────────── */
  --glass: rgba(255, 255, 255, 0.045);
  --glass-hi: rgba(255, 255, 255, 0.09);
  --stroke: rgba(255, 255, 255, 0.09);
  --stroke-hi: rgba(255, 255, 255, 0.16);

  /* ── ink ────────────────────────────────────────────────── */
  --ink: #edeef7;
  --ink-2: #a7abc7;
  --ink-3: #6a6f8d;

  --ok: #4ade80;
  --err: #fb7185;

  --r-lg: 26px;
  --r: 18px;
  --r-sm: 12px;

  --shadow-lift: 0 18px 40px -18px rgba(0, 0, 0, 0.9);
  --shadow-glow: 0 10px 30px -8px rgba(76, 110, 245, 0.55);

  --sans: system-ui, -apple-system, "Segoe UI", Tahoma, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  /* legacy aliases, so any older markup still resolves */
  --bg: var(--bg-0);
  --surface: rgba(255, 255, 255, 0.05);
  --primary: var(--mid);
  --primary-dim: rgba(76, 110, 245, 0.25);
  --text: var(--ink);
  --muted: var(--ink-3);
  --error: var(--err);
  --success: var(--ok);
  --accent: var(--now);
  --radius: var(--r);
  --radius-sm: var(--r-sm);
}

html, body {
  min-height: 100%;
  background: var(--bg-0);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}

body {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: 100dvh;
  padding: 28px 18px calc(28px + env(safe-area-inset-bottom));
  overflow-x: hidden;
  overscroll-behavior: none;
}

/* ── Ambient light ─────────────────────────────────────────
   Two enormous, very soft lights drifting behind everything —
   the room the voice is standing in.

   This was NOT cheap, despite the note that used to sit here.
   Two things made it the single most expensive thing on the page:

     - `filter: blur(90px)` over a 620px box. A radial-gradient that
       already fades to transparent is a blur; blurring it again buys
       almost nothing visually and costs a full Gaussian pass.
     - `scale()` in the keyframes. A promoted layer that only
       TRANSLATES can be rasterised once and moved by the compositor
       for free. The moment it scales, the layer must be rasterised
       again — so that 90px blur ran every frame, for ever, on an
       idle page.

   Now: no filter, translate-only keyframes, and the softness lives
   in the gradient stops where it is free. */
.aura {
  position: fixed;
  inset: -20% -20% auto -20%;
  height: 140vh;
  pointer-events: none;
  z-index: 0;
  overflow: hidden;
}
.aura::before,
.aura::after {
  content: "";
  position: absolute;
  width: 78vw;
  height: 78vw;
  max-width: 620px;
  max-height: 620px;
  border-radius: 50%;
  opacity: 0.5;
  will-change: transform;
}
/* The extra stops are what the blur used to do: a long, gentle falloff
   instead of one hard edge at 68%. Rasterised once, then reused. */
.aura::before {
  top: 2%;
  right: -18%;
  background: radial-gradient(circle at 50% 50%,
    var(--low) 0%,
    rgba(124, 92, 255, 0.55) 30%,
    rgba(124, 92, 255, 0.22) 52%,
    rgba(124, 92, 255, 0.06) 72%,
    transparent 88%);
  animation: drift-a 26s ease-in-out infinite alternate;
}
.aura::after {
  bottom: 4%;
  left: -22%;
  opacity: 0.34;
  background: radial-gradient(circle at 50% 50%,
    var(--high) 0%,
    rgba(34, 211, 238, 0.55) 30%,
    rgba(34, 211, 238, 0.22) 52%,
    rgba(34, 211, 238, 0.06) 72%,
    transparent 88%);
  animation: drift-b 32s ease-in-out infinite alternate;
}
/* Translate only — no scale, or the layer re-rasterises every frame. */
@keyframes drift-a {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(-8vw, 7vh, 0); }
}
@keyframes drift-b {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(9vw, -8vh, 0); }
}

/* fine grain, so the big gradients never band on an OLED panel.
   No mix-blend-mode: a blend mode has to read the backdrop, which pins a
   full-screen layer to whatever is moving underneath it and re-composites
   the lot every frame. At 3.5% opacity the blend was doing nothing the
   plain overlay does not already do. */
.grain {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: 0.035;
  background-image: repeating-conic-gradient(#fff 0 0.0009turn, #000 0 0.0018turn);
  background-size: 3px 3px;
}

#module-container {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: 420px;
}

/* ── Screens are glass panels ──────────────────────────────── */
[data-screen] {
  display: none;
  flex-direction: column;
  align-items: stretch;
  gap: 22px;
  padding: 30px 22px;
  border-radius: var(--r-lg);
  /* No backdrop-filter. It is the most expensive property in common use:
     it re-samples and re-blurs everything painted behind the element, and
     because the aura behind it never stops moving, that result can never be
     cached — it was a full-screen blur every frame on every screen.
     What actually reads as "glass" is the gradient, the hairline border and
     the inset top highlight, so those carry it alone now, a little more
     opaque to make up for the missing blur. */
  background: linear-gradient(180deg,
    rgba(255, 255, 255, 0.10),
    rgba(28, 30, 52, 0.72) 42%,
    rgba(18, 20, 38, 0.80));
  border: 1px solid var(--stroke);
  box-shadow: var(--shadow-lift), inset 0 1px 0 rgba(255, 255, 255, 0.10);
  opacity: 0;
  transform: translateY(10px) scale(0.985);
  transition: opacity 0.45s cubic-bezier(0.22, 1, 0.36, 1),
              transform 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
[data-screen].active {
  display: flex;
  opacity: 1;
  transform: none;
}

/* children arrive a beat after the panel itself */
[data-screen].active > * { animation: rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) backwards; }
[data-screen].active > *:nth-child(1) { animation-delay: 0.05s; }
[data-screen].active > *:nth-child(2) { animation-delay: 0.10s; }
[data-screen].active > *:nth-child(3) { animation-delay: 0.15s; }
[data-screen].active > *:nth-child(4) { animation-delay: 0.20s; }
[data-screen].active > *:nth-child(5) { animation-delay: 0.24s; }
[data-screen].active > *:nth-child(6) { animation-delay: 0.28s; }
[data-screen].active > *:nth-child(n+7) { animation-delay: 0.32s; }
@keyframes rise {
  from { opacity: 0; transform: translateY(9px); }
  to   { opacity: 1; transform: none; }
}

.stack { display: flex; flex-direction: column; gap: 18px; align-items: stretch; }
.center { text-align: center; }

/* ── Type ──────────────────────────────────────────────────── */
.title {
  font-size: clamp(1.6rem, 7vw, 2.05rem);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1.25;
  text-align: center;
  text-wrap: balance;
  background: linear-gradient(180deg, #ffffff, #b9c0e8);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.subtitle { font-size: 0.95rem; color: var(--ink-2); text-align: center; text-wrap: balance; }
.instruction {
  font-size: 1.02rem;
  line-height: 1.9;
  text-align: center;
  color: var(--ink);
  text-wrap: balance;
}
.muted-text { font-size: 0.8rem; color: var(--ink-3); text-align: center; line-height: 1.7; }
.big-state {
  font-size: 1.05rem;
  font-weight: 700;
  text-align: center;
  min-height: 1.6em;
  color: var(--ink-2);
}

/* ── Buttons ───────────────────────────────────────────────── */
.btn {
  position: relative;
  width: 100%;
  padding: 17px 20px;
  border: none;
  border-radius: var(--r);
  font-family: inherit;
  font-size: 1.02rem;
  font-weight: 700;
  color: #fff;
  cursor: pointer;
  isolation: isolate;
  background: linear-gradient(145deg, #6d7ffb, var(--mid) 46%, #3a52d4);
  box-shadow:
    var(--shadow-glow),
    inset 0 1px 0 rgba(255, 255, 255, 0.35),
    inset 0 -1px 0 rgba(0, 0, 0, 0.22);
  transition: transform 0.14s cubic-bezier(0.22, 1, 0.36, 1),
              box-shadow 0.22s ease, opacity 0.2s ease;
}
/* soft specular sweep across the top half */
.btn::before {
  content: "";
  position: absolute;
  inset: 1px 1px 45% 1px;
  border-radius: calc(var(--r) - 1px) calc(var(--r) - 1px) 40% 40%;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.20), transparent);
  pointer-events: none;
  z-index: -1;
}
.btn:hover { box-shadow: 0 14px 36px -8px rgba(76, 110, 245, 0.7), inset 0 1px 0 rgba(255, 255, 255, 0.35); }
.btn:active { transform: translateY(1px) scale(0.988); }
.btn:disabled { opacity: 0.32; cursor: not-allowed; box-shadow: none; }
.btn:focus-visible { outline: 2px solid var(--high); outline-offset: 3px; }

.btn-ghost {
  background: rgba(255, 255, 255, 0.04);
  color: var(--ink);
  border: 1px solid var(--stroke-hi);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.btn-ghost::before { display: none; }
.btn-ghost:hover { background: rgba(255, 255, 255, 0.07); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12); }

/* ── Waveform ──────────────────────────────────────────────── */
.waveform {
  width: 100%;
  height: 92px;
  display: block;
  border-radius: var(--r);
  border: 1px solid var(--stroke);
  background:
    radial-gradient(120% 90% at 50% 120%, rgba(76, 110, 245, 0.18), transparent 70%),
    rgba(255, 255, 255, 0.03);
}

/* ── Note readouts ─────────────────────────────────────────── */
.live-note,
.note-heard {
  text-align: center;
  font-size: 1.35rem;
  font-weight: 700;
  min-height: 1.6em;
  letter-spacing: 0.04em;
  color: var(--high);
  text-shadow: 0 0 22px rgba(34, 211, 238, 0.45);
  transition: opacity 0.2s ease;
}
/* The tracker did not produce a usable frame this poll.
   The note is kept and dimmed rather than cleared. On a weak take the tracker
   can miss twenty-two polls out of twenty-six, and clearing on each of them
   made the readout flicker at 60 ms — which reads as an app that is slow and
   confused, not as an honest per-frame report. Dimming says the same thing at
   a speed a person can actually read: this is the last note I was sure of.
   Opacity only, so a flickering tracker cannot relayout the screen. */
.note-heard.stale { opacity: 0.35; }

.note-target {
  text-align: center;
  font-size: clamp(1.8rem, 8vw, 2.3rem);
  font-weight: 800;
  letter-spacing: 0.02em;
  line-height: 1.2;
  color: var(--now);
  text-shadow: 0 0 30px rgba(255, 197, 61, 0.35);
}

.walk-dir {
  align-self: center;
  padding: 7px 15px;
  border-radius: 999px;
  font-size: 0.88rem;
  font-weight: 700;
  text-align: center;
  color: var(--ink-2);
  border: 1px solid var(--stroke-hi);
  background: rgba(255, 255, 255, 0.04);
}
.walk-dir.down { color: #c4b5fd; border-color: rgba(124, 92, 255, 0.4); background: rgba(124, 92, 255, 0.10); }
.walk-dir.up   { color: #7ee7f7; border-color: rgba(34, 211, 238, 0.4); background: rgba(34, 211, 238, 0.10); }

/* ── Countdown ring ────────────────────────────────────────── */
.countdown-ring {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  align-self: center;
}
.countdown-ring svg { transform: rotate(-90deg); overflow: visible; }
.countdown-ring .track { fill: none; stroke: rgba(255, 255, 255, 0.07); stroke-width: 5; }
.countdown-ring .progress {
  fill: none;
  stroke: var(--high);
  stroke-width: 5;
  stroke-linecap: round;
  filter: drop-shadow(0 0 8px rgba(34, 211, 238, 0.6));
  transition: stroke-dashoffset 1s linear;
}
.countdown-ring .label {
  position: absolute;
  font-size: 1.35rem;
  font-weight: 800;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* ── Progress dots ─────────────────────────────────────────── */
.dots { display: flex; gap: 9px; justify-content: center; }
.dot {
  width: 9px; height: 9px; border-radius: 50%;
  background: rgba(255, 255, 255, 0.09);
  border: 1px solid var(--stroke-hi);
  transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.dot.done { background: var(--mid); border-color: transparent; box-shadow: 0 0 12px rgba(76, 110, 245, 0.7); }
.dot.active {
  background: var(--now); border-color: transparent;
  transform: scale(1.5);
  box-shadow: 0 0 16px rgba(255, 197, 61, 0.8);
}
.dot.skipped { background: transparent; border-color: var(--err); }

/* ── Keyboard ──────────────────────────────────────────────── */
.piano-container {
  width: 100%;
  border-radius: var(--r);
  overflow: hidden;
  background: rgba(0, 0, 0, 0.28);
  border: 1px solid var(--stroke);
  box-shadow: inset 0 2px 14px rgba(0, 0, 0, 0.55);
}
.piano-svg { width: 100%; height: 92px; display: block; }
.piano-container.tall { background: rgba(0, 0, 0, 0.22); padding: 10px 8px 8px; }
.piano-container.tall .piano-svg { height: 158px; }

/* ── The pointer arrives from where the walk came from ─────────
   draw() rebuilds the keyboard on every note, so a transition has nothing to
   run on: the element it would animate was destroyed and remade. An animation
   does not need continuity — it plays on each new element — so the pointer is
   drawn at its destination and slides the last of the way in.

   A gesture, not a path. The true distance between two notes changes with
   where the black keys fall, and reproducing it would mean knowing the key
   geometry out here, which is exactly the duplication this avoids. The
   direction is what carries the meaning, and the direction is true.

   The units are viewBox units, not pixels: a CSS transform on an SVG child
   works in the element's own coordinate system, so this scales with the
   keyboard instead of drifting from it on a wider phone.

   Only the pointer moves. The lit key snaps, because a piano key lights up
   and does not slide, and a highlight travelling between keys would be
   drawing a note nobody played.

   Where CSS transforms on SVG children are not supported — some very old
   Android WebViews — the animation simply does not run and the pointer
   appears where it always did. Nothing to fall back to. */
@keyframes pointer-in-up   { from { transform: translateX(-14px); } to { transform: none; } }
@keyframes pointer-in-down { from { transform: translateX(14px); }  to { transform: none; } }
.piano-container.walk-up .piano-pointer {
  animation: pointer-in-up 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.piano-container.walk-down .piano-pointer {
  animation: pointer-in-down 0.26s cubic-bezier(0.22, 1, 0.36, 1) both;
}

/* ── Explanations: the app says what it is doing, everywhere ─────── */
.step-badge {
  align-self: center;
  padding: 5px 14px;
  border-radius: 999px;
  font-size: 0.74rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  color: var(--ink-2);
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--stroke);
}

.howto { display: flex; flex-direction: column; gap: 10px; }
.howto-row {
  display: grid;
  grid-template-columns: 26px 1fr;
  align-items: start;
  gap: 11px;
  font-size: 0.92rem;
  line-height: 1.7;
  color: var(--ink-2);
}
.howto-row b {
  display: grid;
  place-items: center;
  width: 26px; height: 26px;
  border-radius: 50%;
  font-size: 0.78rem;
  font-weight: 800;
  color: var(--ink);
  background: rgba(76, 110, 245, 0.22);
  border: 1px solid rgba(76, 110, 245, 0.45);
}

/* the spoken brief before each direction of the walk */
.brief {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 20px 18px;
  border-radius: var(--r);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--stroke-hi);
  animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1);
}
.brief-title {
  font-size: 1.25rem;
  font-weight: 800;
  text-align: center;
  color: var(--ink);
}
.brief-body {
  font-size: 0.95rem;
  line-height: 1.95;
  text-align: center;
  color: var(--ink-2);
  text-wrap: pretty;
}

/* the quiet reminder before anything louder is said */
.pitch-hint {
  min-height: 1.5em;
  text-align: center;
  font-size: 0.86rem;
  font-weight: 600;
  color: var(--now);
  opacity: 0;
  transition: opacity 0.3s ease;
}
.pitch-hint.show { opacity: 1; }

.coach-card {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 18px 16px;
  border-radius: var(--r);
  background: linear-gradient(160deg, rgba(255, 197, 61, 0.10), transparent 70%);
  border: 1px solid rgba(255, 197, 61, 0.28);
}

/* ── Voice type ──────────────────────────────────────────────────── */
.vt-card {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 20px 18px;
  border-radius: var(--r);
  background:
    linear-gradient(160deg, rgba(124, 92, 255, 0.13), rgba(34, 211, 238, 0.07) 70%, transparent);
  border: 1px solid var(--stroke-hi);
}
.vt-head { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.vt-eyebrow {
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  color: var(--ink-3);
}
.vt-name {
  font-size: clamp(1.5rem, 7vw, 1.9rem);
  font-weight: 800;
  letter-spacing: -0.01em;
  background: linear-gradient(100deg, var(--low), var(--high));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.vt-hint { font-size: 0.8rem; color: var(--ink-3); text-align: center; }
.vt-p {
  font-size: 0.93rem;
  line-height: 1.95;
  color: var(--ink-2);
  text-wrap: pretty;
}
.vt-p:first-of-type { color: var(--ink); }
.vt-caveat {
  font-size: 0.76rem;
  line-height: 1.75;
  color: var(--ink-3);
  padding-top: 10px;
  border-top: 1px solid var(--stroke);
}
.vt-chips { display: flex; gap: 10px; }
.chip {
  flex: 1;
  padding: 13px 10px;
  border-radius: var(--r-sm);
  font-family: inherit;
  font-size: 0.92rem;
  font-weight: 700;
  color: var(--ink);
  cursor: pointer;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--stroke-hi);
  transition: background 0.18s ease, transform 0.12s ease;
}
.chip:hover { background: rgba(255, 255, 255, 0.09); }
.chip:active { transform: scale(0.98); }
.vt-switch {
  align-self: flex-start;
  padding: 0;
  border: none;
  background: none;
  font-family: inherit;
  font-size: 0.76rem;
  color: var(--ink-3);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.vt-switch:hover { color: var(--ink-2); }

/* ── Result ────────────────────────────────────────────────── */
.result-line {
  font-size: 1.12rem;
  font-weight: 600;
  text-align: center;
  color: var(--ink-2);
  text-wrap: balance;
}
.result-octaves {
  font-size: clamp(1.5rem, 7vw, 1.95rem);
  font-weight: 800;
  text-align: center;
  letter-spacing: -0.01em;
  background: linear-gradient(100deg, var(--low), var(--mid) 45%, var(--high));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}

.hits { width: 100%; }
.hits summary {
  cursor: pointer;
  list-style: none;
  text-align: center;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--ink-2);
  padding: 9px;
  border-radius: var(--r-sm);
  border: 1px solid var(--stroke);
  background: rgba(255, 255, 255, 0.03);
  transition: background 0.2s ease;
}
.hits summary::-webkit-details-marker { display: none; }
.hits[open] summary { background: rgba(255, 255, 255, 0.06); margin-bottom: 10px; }
.hit-list { display: flex; flex-direction: column; gap: 2px; border-radius: var(--r-sm); overflow: hidden; }
.hit {
  display: grid;
  grid-template-columns: 1fr auto 1fr auto;
  align-items: center;
  gap: 10px;
  padding: 10px 13px;
  font-size: 0.86rem;
  background: rgba(255, 255, 255, 0.035);
}
.hit-ref { color: var(--ink-3); }
.hit-arrow { color: var(--ink-3); font-size: 0.75rem; }
.hit-sung { font-weight: 700; color: var(--ink); }
.hit-cents {
  font-family: var(--mono);
  font-size: 0.72rem;
  direction: ltr;
  font-variant-numeric: tabular-nums;
  color: var(--now);
  padding: 2px 7px;
  border-radius: 999px;
  background: rgba(255, 197, 61, 0.12);
}
.hit-cents.good { color: var(--ok); background: rgba(74, 222, 128, 0.12); }

/* ── Mic / listening ───────────────────────────────────────── */
.mic-pulse {
  position: relative;
  width: 96px; height: 96px;
  border-radius: 50%;
  margin: 6px auto;
  display: grid;
  place-items: center;
  background: radial-gradient(circle at 50% 40%, rgba(76, 110, 245, 0.35), rgba(76, 110, 245, 0.06) 70%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
}
.mic-pulse::before,
.mic-pulse::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 1.5px solid rgba(76, 110, 245, 0.5);
  animation: ripple 2.6s cubic-bezier(0.2, 0.6, 0.3, 1) infinite;
}
.mic-pulse::after { animation-delay: 1.3s; }
.mic-pulse.listening {
  background: radial-gradient(circle at 50% 40%, rgba(34, 211, 238, 0.34), rgba(34, 211, 238, 0.05) 70%);
}
.mic-pulse.listening::before,
.mic-pulse.listening::after { border-color: rgba(34, 211, 238, 0.55); }
@keyframes ripple {
  0%   { transform: scale(0.82); opacity: 0.9; }
  100% { transform: scale(1.75); opacity: 0; }
}

.level-bar {
  width: 100%; height: 5px; border-radius: 999px;
  background: rgba(255, 255, 255, 0.07);
  overflow: hidden;
}
.level-bar > i {
  display: block; height: 100%; width: 0%;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--low), var(--mid) 50%, var(--high));
  box-shadow: 0 0 12px rgba(34, 211, 238, 0.5);
  transition: width 0.08s linear;
}

.state-icon {
  font-size: 2.1rem;
  text-align: center;
  min-height: 1.35em;
  line-height: 1.35;
  /* text-shadow, not filter: drop-shadow. This element scales continuously
     while the singer answers, and a filter forces the layer to re-rasterise
     on every frame of that animation; a text-shadow does not. */
  text-shadow: 0 4px 14px rgba(0, 0, 0, 0.6);
}
/* ── The cue bar ───────────────────────────────────────────────
   Fills while the note plays (an upbeat, so the entry is not cold),
   then shows the live input level while the singer answers (so the
   screen is visibly listening from the first millisecond).
   scaleX on a transform, never width — width relayouts every frame,
   which is exactly the sort of thing that made this app hot. */
.cue-bar {
  width: 100%;
  max-width: 260px;
  height: 5px;
  margin: 0 auto;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.07);
  overflow: hidden;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.cue-bar.lead, .cue-bar.live { opacity: 1; }
.cue-bar > i {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: inherit;
  transform: scaleX(0);
  transform-origin: right center;   /* RTL: grows from the right */
  will-change: transform;
}
/* Waiting to come in: the app's own colour, cool and quiet. */
.cue-bar.lead > i { background: linear-gradient(90deg, var(--mid), var(--high)); }
/* Your turn: amber, the one colour reserved for the note happening now. */
.cue-bar.live > i { background: linear-gradient(90deg, var(--now), #ffe08a); }

.state-icon.sing { animation: breathe 1.5s ease-in-out infinite; }
@keyframes breathe {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.16); }
}

/* ── Which half of the exchange this is ────────────────────────
   The glyph said it already, to anyone holding the phone close enough to
   read a 2.1 rem emoji and confident about which of 🎹 and 🎤 means "your
   turn". A word does not need either.

   It shares the icon's line rather than taking one of its own, and that is
   measured rather than styled: on a 320x568 screen the walk stack already
   stands 436 px inside a 528 px budget, so a 2.6 rem word on its own row
   costs 70 px and leaves 22 — which Safari's chrome then takes. Beside the
   icon it costs nothing, because that row's horizontal space was empty.
   tests/stepsui holds the arrangement so a later tidy-up does not quietly
   reintroduce the overflow. */
.walk-say-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
}
/* The icon keeps its size and its breathing; it only stops reserving a line
   of its own, which the row now does for both of them. */
.walk-say-row > .state-icon { min-height: 0; }
.walk-say {
  /* Readable at arm's length on a big phone, and small enough not to wrap on
     a 320 px one — where 8.5vw lands under the floor and the clamp holds. */
  font-size: clamp(1.9rem, 8.5vw, 2.6rem);
  font-weight: 800;
  line-height: 1.2;
  letter-spacing: 0.01em;
  color: var(--ink);
  /* A fade and nothing else. This element sits in a flex row beside an icon
     that is scaling on its own animation; moving it too would relayout the
     row on every frame of that, while the page is recording audio and running
     a 30 ms meter. */
  transition: opacity 0.22s ease;
}
/* Your turn, in the one colour this screen reserves for the note happening
   now — the same amber the cue bar switches to at the same moment. */
.walk-say.sing { color: var(--now); }

/* ── Feedback ──────────────────────────────────────────────── */
.error-text {
  color: var(--err);
  font-size: 0.95rem;
  font-weight: 600;
  text-align: center;
  text-wrap: balance;
}
.spinner {
  width: 42px; height: 42px;
  margin: 10px auto;
  border-radius: 50%;
  background: conic-gradient(from 0deg, transparent 0%, var(--mid) 55%, var(--high) 92%, transparent 100%);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3.5px), #000 0);
  mask: radial-gradient(farthest-side, transparent calc(100% - 3.5px), #000 0);
  animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ── Reduced motion ────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  [data-screen] { opacity: 1; transform: none; }
}

/* Short screens: take air out of the panel rather than scrolling */
@media (max-height: 680px) {
  body { padding: 14px; }
  [data-screen] { padding: 22px 18px; gap: 16px; }
  .piano-container.tall .piano-svg { height: 128px; }
  .waveform { height: 74px; }
  .mic-pulse { width: 78px; height: 78px; }
}

/* The trail is fixed to the bottom of the viewport, so on a phone it sits on
   top of whatever the screen ends with — which on the result screen is the
   range itself and the button to continue. Give the page somewhere to scroll
   to instead of hiding its own conclusion. */
body.has-trace { padding-bottom: 34vh; }

/* ?debug=1 — the walk's breadcrumb trail, on screen */
.trace-box {
  position: fixed;
  left: 8px; right: 8px; bottom: 8px;
  z-index: 99;
  max-height: 30vh;
  overflow: auto;
  margin: 0;
  padding: 8px 10px;
  border-radius: 10px;
  background: rgba(0, 0, 0, 0.82);
  border: 1px solid var(--stroke-hi);
  color: #9fe8b6;
  font-family: var(--mono);
  font-size: 10px;
  line-height: 1.5;
  direction: ltr;
  text-align: left;
  white-space: pre-wrap;
}


/* ============================================================
   Screen furniture
   ------------------------------------------------------------
   One screen, one job. Every screen is a short vertical stack
   with one thing that dominates it, and text arrives with a
   small movement so reading it feels like something happening
   rather than a wall appearing.
   ============================================================ */

.center-stack {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  text-align: center;
}
.center-stack > * { width: 100%; }

.eyebrow {
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  color: var(--ink-3);
  animation: fade-in 0.5s ease both;
}

.headline {
  font-size: clamp(1.3rem, 5.6vw, 1.6rem);
  font-weight: 750;
  line-height: 1.45;
  letter-spacing: -0.01em;
  color: var(--ink);
  /* Centred by itself, not by whichever stack happens to wrap it — the
     screen is a plain stack and its title was sitting right-aligned. */
  text-align: center;
  text-wrap: balance;
  animation: rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}

.body-text {
  font-size: 0.93rem;
  line-height: 1.95;
  color: var(--ink-2);
  text-align: right;
  text-wrap: pretty;
}

@keyframes fade-in { from { opacity: 0 } to { opacity: 1 } }

/* ── The note being offered or heard ───────────────────────── */
.giant-note {
  font-size: clamp(3rem, 17vw, 4.4rem);
  font-weight: 800;
  line-height: 1.1;
  letter-spacing: 0.01em;
  color: var(--ink-3);
  transition: color 0.25s ease, transform 0.25s cubic-bezier(0.22, 1, 0.36, 1),
              text-shadow 0.25s ease;
}
.giant-note.lit {
  color: var(--high);
  transform: scale(1.06);
  text-shadow: 0 0 44px rgba(34, 211, 238, 0.45);
}

.mic-glyph { font-size: 30px; }

/* ── Stability ring (anchor screen) ────────────────────────── */
/* Wraps the giant-note with no change to the layout stack.    */
.note-ring-wrap {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* SVG ring sits behind the note, sized by the font via em units.
   pointer-events: none so the note itself stays tappable.      */
.anchor-ring {
  position: absolute;
  /* em must resolve against the note's own font-size, not the body's.
     Without this the SVG is ~27px while the note is ~60px and the ring
     sits invisible behind the glyph. */
  font-size: clamp(3rem, 17vw, 4.4rem);
  width: 1.5em; height: 1.5em;
  transform: rotate(-90deg); /* start arc at top-centre */
  pointer-events: none;
  overflow: visible;
}
/* Subtle background track — only visible when ring is active.  */
.anchor-ring-fill {
  fill: none;
  stroke: var(--high);
  stroke-width: 3.5;
  stroke-linecap: round;
  stroke-dasharray: 276.46;
  stroke-dashoffset: 276.46; /* JS sets this each frame */
  opacity: 0;               /* JS sets this too         */
  transition: opacity 0.15s ease;
}
@media (prefers-reduced-motion: reduce) {
  .anchor-ring-fill { transition: none; }
}

/* ── Result ────────────────────────────────────────────────── */
.range-line {
  font-size: clamp(1.7rem, 8vw, 2.2rem);
  font-weight: 800;
  letter-spacing: 0.01em;
  color: var(--ink);
  animation: pop 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes pop {
  from { opacity: 0; transform: scale(0.9); }
  to   { opacity: 1; transform: none; }
}

/* ── The voice label, alone ────────────────────────────────── */
.voice-name {
  font-size: clamp(2.4rem, 13vw, 3.4rem);
  font-weight: 800;
  line-height: 1.15;
  letter-spacing: -0.02em;
  background: linear-gradient(100deg, var(--low), var(--mid) 45%, var(--high));
  background-size: 220% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: pop 0.7s cubic-bezier(0.22, 1, 0.36, 1) both,
             sweep 5s ease-in-out 0.7s 3;
}
@keyframes sweep {
  0%, 100% { background-position: 0% 50%; }
  50%      { background-position: 100% 50%; }
}
.voice-covers {
  font-size: 0.86rem;
  line-height: 1.75;
  text-align: center;
  color: var(--ink-2);
  max-width: 32ch;
  margin: 0 auto;
}
.voice-hint {
  font-size: 0.88rem;
  color: var(--ink-3);
  animation: fade-in 0.6s ease 0.25s both;
}

/* ── Two big choices ───────────────────────────────────────── */
.choice-row { display: flex; gap: 12px; }
.choice {
  flex: 1;
  padding: 26px 12px;
  border-radius: var(--r);
  font-family: inherit;
  font-size: 1.02rem;
  font-weight: 700;
  color: var(--ink);
  cursor: pointer;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--stroke-hi);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
  transition: background 0.2s ease, transform 0.12s ease, border-color 0.2s ease;
}
.choice:hover { background: rgba(255, 255, 255, 0.09); border-color: var(--mid); }
.choice:active { transform: scale(0.98); }

/* ── Progressive disclosure ────────────────────────────────── */
.more { width: 100%; text-align: right; }
.more summary {
  cursor: pointer;
  list-style: none;
  text-align: center;
  font-size: 0.84rem;
  font-weight: 600;
  color: var(--ink-3);
  padding: 11px;
  border-radius: var(--r-sm);
  border: 1px solid var(--stroke);
  transition: color 0.2s ease, background 0.2s ease;
}
.more summary::-webkit-details-marker { display: none; }
.more summary:hover { color: var(--ink-2); background: rgba(255, 255, 255, 0.04); }
.more[open] summary { color: var(--ink-2); margin-bottom: 12px; }
.more[open] .more-body,
.more[open] .hit-list { animation: unfold 0.4s cubic-bezier(0.22, 1, 0.36, 1) both; }
@keyframes unfold {
  from { opacity: 0; transform: translateY(-6px); }
  to   { opacity: 1; transform: none; }
}
.more-body { display: flex; flex-direction: column; gap: 12px; }
.caveat {
  font-size: 0.76rem;
  line-height: 1.75;
  color: var(--ink-3);
  padding-top: 12px;
  border-top: 1px solid var(--stroke);
}
.link-btn {
  align-self: flex-start;
  padding: 0;
  border: none;
  background: none;
  font-family: inherit;
  font-size: 0.76rem;
  color: var(--ink-3);
  text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer;
}
.link-btn:hover { color: var(--ink-2); }

/* The generic per-child entrance is more specific than the class rules above,
   so the few elements that own a bespoke animation have to say so at the same
   weight or they silently lose it. */
[data-screen].active > .voice-name {
  animation: pop 0.7s cubic-bezier(0.22, 1, 0.36, 1) both,
             sweep 5s ease-in-out 0.7s 3;
}
[data-screen].active > .range-line { animation: pop 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
[data-screen].active > .giant-note { animation: fade-in 0.5s ease both; }

/* A practical instruction that changes the measurement, so it gets its own
   line instead of being tacked onto the end of another sentence where nobody
   reads it. Shown on the two screens where mic distance actually matters. */
.tip {
  align-self: center;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  width: auto;
  padding: 7px 14px;
  border-radius: 999px;
  font-size: 0.83rem;
  font-weight: 600;
  color: var(--ink-2);
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--stroke-hi);
}
.tip-icon { font-size: 0.95rem; line-height: 1; }

/* A silence hint is a different kind of message from a pitch nudge: it is
   about the phone, not the singing, so it does not borrow the amber that
   belongs to the current note. */
.pitch-hint.warn { color: var(--ink-2); }

/* ── Intro carousel ────────────────────────────────────────────────
   Four cards on one track. The whole strip follows the finger, so the
   next card is visibly arriving during the gesture rather than being
   swapped in after it — the difference between a swipe that works and
   one that merely responds. */
.carousel {
  display: grid;
  grid-template-columns: 34px 1fr 34px;
  align-items: center;
  gap: 2px;
  width: 100%;
}
.viewport {
  overflow: hidden;
  border-radius: var(--r);
  /* claim horizontal movement, leave vertical scrolling to the page */
  touch-action: pan-y;
  cursor: grab;
  -webkit-user-select: none;
  user-select: none;
}
.viewport:active { cursor: grabbing; }
.track {
  display: flex;
  direction: ltr;              /* index 0 sits first; text is re-flipped below */
  will-change: transform;
}
.slide {
  flex: 0 0 100%;
  direction: rtl;
  padding: 0 2px;
}

.intro-card {
  min-height: 150px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 8px;
  padding: 22px 18px;
  border-radius: var(--r);
  background: rgba(255, 255, 255, 0.035);
  border: 1px solid var(--stroke);
  transition: opacity 0.35s ease, transform 0.35s ease;
  /* Neighbours are dimmed, never hidden. If a transition is ever interrupted
     the card still has to be readable — legibility must not depend on an
     animation finishing. */
  opacity: 0.55;
  transform: scale(0.975);
}
.slide.current .intro-card { opacity: 1; transform: none; }

.intro-step-title {
  font-size: 1.15rem;
  font-weight: 750;
  color: var(--high);
}
.intro-step-body {
  font-size: 0.95rem;
  line-height: 1.85;
  color: var(--ink-2);
  text-wrap: pretty;
}

.intro-counter {
  font-size: 0.74rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink-3);
}

.arrow {
  display: grid;
  place-items: center;
  height: 44px;
  border: none;
  background: none;
  font-family: inherit;
  font-size: 1.8rem;
  line-height: 1;
  color: var(--ink-3);
  cursor: pointer;
  border-radius: var(--r-sm);
  transition: color 0.18s ease, background 0.18s ease, transform 0.12s ease;
}
.arrow:hover { color: var(--ink); background: rgba(255, 255, 255, 0.05); }
.arrow:active { transform: scale(0.88); }

button.dot { padding: 0; cursor: pointer; }

/* Persian copy that carries its own line breaks (mic permission steps). */
.pre-line { white-space: pre-line; }

/* The copy button for the trace box. Same corner, above it, and deliberately
   ugly: it only exists behind ?debug=1 and it must never be mistaken for part
   of the test. */
#av-trace-copy {
  position: fixed;
  left: 8px;
  /* Clear of .trace-box, which is bottom:8px and up to 30vh tall, and above
     it rather than behind it — that box sits at z-index 99. */
  bottom: calc(30vh + 16px);
  z-index: 100;
  font: 600 12px/1 system-ui, sans-serif;
  padding: 8px 12px;
  border-radius: 6px;
  border: 1px solid #7c5cff;
  background: #12121e;
  color: #cdd0ff;
}

/* ============================================================
   The front door
   ------------------------------------------------------------
   The first screen is not a splash to be dismissed. It says who
   made this, it says what the app is called, and while it is on
   screen it downloads eighteen piano samples — so the seconds it
   costs are seconds the test would have spent waiting anyway.

   The wordmark carries the app's whole idea: pitch is a spectrum,
   violet at the bottom and cyan at the top, and ninety seconds
   later the singer's own range is painted across that same
   gradient on the result card. The first screen makes a promise
   the last one keeps.

   The café logo is dark brown on white, which is invisible on a
   near-black ground. It gets a warm plate rather than a filter:
   inverting a two-colour hand-drawn mark makes it somebody
   else's logo, and a cup belongs on a coaster anyway.
   ============================================================ */
.brand-screen {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0;
  padding: 32px 24px calc(32px + env(safe-area-inset-bottom, 0px));
  background:
    radial-gradient(120% 70% at 50% 12%, rgba(76, 110, 245, 0.16), transparent 60%),
    radial-gradient(90% 55% at 50% 100%, rgba(34, 211, 238, 0.10), transparent 60%),
    var(--bg-0);
  text-align: center;
  animation: brand-in 520ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
@keyframes brand-in { from { opacity: 0 } to { opacity: 1 } }

/* A round plate, and the mark sized to sit INSIDE the circle.

   The square inscribed in a 112px circle is 112/√2 ≈ 79 across, so the
   16px inset is what keeps a corner of the artwork from being cut by
   the curve rather than a number picked to look right.

   The image is given that box in pixels rather than in percentages.
   `height: 100%` on a grid item whose row is sized by its own content
   has no definite height to resolve against, so it fell back to auto:
   the mark rendered 92×111 inside a 92×92 well and hung nine pixels
   past the plate, which `overflow: visible` then let through. */
.brand-plate {
  width: 112px;
  height: 112px;
  border-radius: 50%;
  overflow: hidden;
  background: #f6f1ea;
  display: grid;
  place-items: center;
  padding: 16px;
  box-shadow: 0 22px 50px -22px rgba(0, 0, 0, 0.95),
              0 0 0 1px rgba(255, 255, 255, 0.07);
}
.brand-plate img { width: 80px; height: 80px; object-fit: contain; display: block; }
.brand-plate.is-text {
  font: 700 0.95rem/1.25 var(--sans);
  color: #4a3226;
  padding: 14px;
}

.brand-name {
  margin-top: 26px;
  font-family: var(--sans);
  font-size: clamp(2.6rem, 13vw, 3.4rem);
  font-weight: 800;
  letter-spacing: -0.02em;
  line-height: 1;
  direction: ltr;
  /* The spectrum, on the name. Painted, with a plain colour underneath for
     anything that cannot clip a background to text. */
  color: var(--high);
  background: linear-gradient(100deg, var(--low) 0%, var(--mid) 52%, var(--high) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

/* The range bar, in miniature. The same object the result card draws. */
.brand-rule {
  width: 68px;
  height: 3px;
  margin-top: 16px;
  border-radius: 2px;
  background: linear-gradient(90deg, var(--low), var(--mid) 55%, var(--high));
  opacity: 0.9;
}

.brand-by {
  margin-top: 18px;
  font-size: 0.86rem;
  color: var(--ink-3);
  letter-spacing: 0.02em;
}

.brand-actions { margin-top: 44px; width: 100%; max-width: 300px; }
.brand-actions .btn { width: 100%; }

/* Only ever says something while it is still working. */
.brand-loading {
  margin-top: 14px;
  min-height: 1.1em;
  font-size: 0.76rem;
  color: var(--ink-3);
  opacity: 0;
  transition: opacity 220ms ease;
}
.brand-loading.show { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .brand-screen { animation: none; }
}
