/* Boxing With Bakr — Combo Trainer
   Mobile-first, dark, clean. Palette matched to boxingwithbakr.com:
   near-black background, white text, teal accent, mono uppercase labels. */

:root {
  --bg: #010101;
  --surface: #0d0d0d;
  --surface-2: #191919;
  --text: #ffffff;
  --muted: #8a8a8a;
  --accent: #499592;       /* BWB teal — matches site's --primary */
  --accent-dim: #2c5957;
  --accent-ink: #010101;   /* text placed on a solid accent background */
  --work: var(--accent);
  --rest: #6f9d6a;         /* muted sage — reads as "rest" without breaking the palette */
  /* The logo's ember, sampled from icons/icon.svg — the arc cools from deep
     teal through bright teal to a warm tip. The segmented controls travel this
     same ramp as you step up, so picking Advanced or Blitz literally lights
     the flame. */
  --ember-cool: #2a7d78;
  --ember-mid: #49c5b6;
  --ember-hot: #ffc46b;    /* the logo's actual tip — used by the icon, not the UI */
  /* What the SEGMENTED CONTROLS ramp to. Deliberately short of --ember-hot and
     of --streak. The two never share a screen — the orange flame is built by
     buildFinishSummary, which runs only on the finish screen, where focus mode
     has hidden these controls — so this is not a collision, it is restraint:
     warm stays strongest where it means "you earned this". Same warm family,
     hue 29 vs the flame's 35, saturation 0.58 against its 1.00. */
  --ember-tip: #d69a63;
  --streak: #ffb347;       /* the ONE warm accent, used only for the streak */
  --streak-deep: #ff8a1f;  /* base of the flame */
  --streak-hot: #ffd166;   /* highlight inside the flame */
  --hairline: rgba(255, 255, 255, 0.1);
  --radius: 16px;
  --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
}

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

html, body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  -webkit-font-smoothing: antialiased;
}

body {
  /* NO min-height here. It used to be 100dvh, stacked with another 100dvh on
     .app, and the pair left a screen of dead black under the footer on a
     phone. Viewport units are the wrong tool for this: iOS browsers do not
     reliably resolve dvh to the toolbar-SHOWN height, so the page gets sized
     to the taller toolbar-hidden viewport while you are looking at the shorter
     one, and the difference becomes scrollable nothing. The ready screen is
     now exactly as tall as its content; html/body already paint the background
     across the whole viewport, so nothing needs stretching to fill it. */
  padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
  /* An app, not a page: no rubber-band past the end, and no pull-to-refresh. */
  overscroll-behavior-y: none;
  /* An app, not a document: a long-press mid-round selecting "Rest" with
     blue handles reads as a web page, not a trainer. Selection stays ON for
     the changelog (.app--doc below), which is reading material. The audit
     log doesn't need selection — it copies via the clipboard API. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}
.app--doc {
  -webkit-user-select: text;
  user-select: text;
  -webkit-touch-callout: default;
}
/* Fullscreen means the PHYSICAL screen. The body's safe-area padding is right
   for the normal screen (keeps controls out of the notch), but in focus mode
   it left dead bands at the notch and home-bar corners — the stage's surface
   never reached the edges of the phone. Drop it there: the stage carries its
   own env() padding, so CONTENT still respects the notch while the background
   fills every corner. (:has is fine — every browser new enough to install the
   app has it; elsewhere this just behaves as before.) */
body:has(.app[data-focus="1"]) { padding: 0; }

.app {
  max-width: 560px;
  margin: 0 auto;
  padding: 20px 18px 40px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
/* A Mac has room the phone doesn't — use it rather than pinning a phone-width
   column to the middle of a 27" display. */
@media (min-width: 900px) {
  .app { max-width: 720px; }
}

/* Top bar */
.topbar {
  /* Pinned first in the flex column. The install strip lifts itself to
     order:-1 in a browser that cannot install (js/app.js), and without this
     it would jump ABOVE the logo — a card floating over the masthead reads as
     a broken page, not an urgent notice. */
  order: -2;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand__logo-link {
  display: block;
  line-height: 0;
  opacity: 1;
  transition: opacity 0.15s;
}
.brand__logo-link:active { opacity: 0.6; }
.brand__logo-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; }
.brand__logo {
  height: 22px;
  width: auto;
  display: block;
}
.brand__name { font-weight: 700; font-size: 15px; }
.brand__tag {
  color: var(--muted);
  font-size: 11px;
  font-family: var(--font-mono);
  text-transform: uppercase;
  letter-spacing: 0.14em;
}

/* The timer stage */
.stage {
  background: var(--surface);
  border-radius: var(--radius);
  padding: 36px 20px;
  text-align: center;
  border: 1px solid var(--hairline);
  transition: background 0.3s, border-color 0.3s;
}
.stage[data-phase="work"] { border-color: var(--work); }
.stage[data-phase="rest"] { border-color: var(--rest); }
.stage__meta, .stage__main { min-width: 0; }

/* The dial: a ring that empties as the phase runs down, with the time inside.
   This is the screen's centre of gravity — without it the stage was text
   floating in a large empty box. Colour follows the phase, so work and rest
   read differently from across the room. */
.dial {
  position: relative;
  width: min(62vw, 240px, 100%);
  max-width: 100%;
  aspect-ratio: 1;
  margin: 0 auto;
  display: grid;
  place-items: center;
}
.dial__ring {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transform: rotate(-90deg);   /* start the sweep at 12 o'clock */
}
.dial__track {
  fill: none;
  stroke: rgba(255, 255, 255, 0.07);
  stroke-width: 3;
}
.dial__fill {
  fill: none;
  stroke: var(--accent);
  stroke-width: 3;
  stroke-linecap: round;
  /* No transition on the dashoffset: the sweep is driven per-frame from JS
     (dialLoop), and an eased transition on top of that only adds lag. */
  transition: stroke 0.3s ease;
}
/* The countdown is the exception. Its value only changes once a second, so
   with no transition the disc teleports between thirds and reads as blocky.
   Gliding each step gives back the original feel: you still get three
   distinct per-second beats, but the movement between them is smooth. Scoped
   to this phase only — work and rest are driven per-frame, where easing on
   top would just add lag. */
.stage[data-phase="countdown"] .dial__fill {
  transition: stroke-dashoffset 0.45s linear, stroke 0.3s ease;
}
.stage[data-phase="rest"] .dial__fill { stroke: var(--rest); }
.stage[data-phase="ready"] .dial__fill,
.stage[data-phase="done"] .dial__fill { stroke: transparent; }
.stage[data-phase="done"] .dial__track { stroke: rgba(255, 255, 255, 0.03); }
.dial__center { text-align: center; z-index: 1; max-width: 84%; }

/* THE SLAM — the countdown's per-second beat.
   Replaces the old soft filling wave, which read as a spa timer in a boxing
   app. Each second now LANDS: the number drops in oversized, overshoots past
   its resting size and settles, while one thin ring shockwaves out from the
   dial and dies. The gaps between beats are deliberately dead still, so every
   number reads as contact rather than as a digit changing.
   Both are transform + opacity only — the compositor does all of it, and
   nothing here can relayout the stage mid-countdown. Retriggered per second
   from slamBeat() in app.js rather than run as a fixed 5-iteration animation,
   so a paused or re-anchored countdown always beats in time with the clock. */
.dial__pulse {
  position: absolute;
  inset: 5%;                /* the ring circle is r=54 in a 120-unit viewBox */
  border-radius: 50%;
  background: none;
  border: 2px solid var(--accent);
  opacity: 0;
  transform: scale(0.55);
  pointer-events: none;
  will-change: transform, opacity;
}
.dial__pulse.is-shock { animation: shockOut 620ms cubic-bezier(0.2, 0.7, 0.3, 1) both; }
@keyframes shockOut {
  0%   { transform: scale(0.55); opacity: 0.55; }
  100% { transform: scale(1.5);  opacity: 0; }
}
/* The countdown digit runs larger than the running clock. It is a single
   character for five seconds with the ring otherwise empty — the space is
   there, and at running-clock size the slam had nothing to land with. The
   label above it is blanked during the countdown (see render), which is what
   frees the room. */
/* Collapse the blanked label rather than leaving an empty line of it: kept in
   flow it still holds its height and pushes the digit off the ring's centre. */
.stage[data-phase="countdown"] .phase { display: none; }
.stage[data-phase="countdown"] .clock { font-size: clamp(64px, 18vw, 128px); }
.app[data-focus="1"] .stage[data-phase="countdown"] .clock { font-size: clamp(72px, 21vw, 150px); }
.clock.is-slam { animation: slamIn 420ms cubic-bezier(0.16, 0.9, 0.3, 1.35) both; }
@keyframes slamIn {
  0%   { transform: scale(1.75); opacity: 0; }
  55%  { transform: scale(0.94); opacity: 1; }
  100% { transform: scale(1);    opacity: 1; }
}
/* Reduced motion keeps the countdown legible and simply drops the theatre. */
@media (prefers-reduced-motion: reduce) {
  .dial__pulse.is-shock { animation: none; }
  .clock.is-slam { animation: none; }
  /* the tint still changes, it just doesn't ease */
  .seg { transition: none; }
}

/* ==========================================================================
   FOCUS MODE — while a session is actually running.
   Mid-round nobody needs the settings; they need to read the combo from across
   the room while moving. So the stage takes the whole screen and everything
   else folds away. Pausing brings it all back (see the app's start/pause).
   ========================================================================== */
.topbar, .settings, .about, .install, .foot {
  max-height: 1400px;
  opacity: 1;
  transition: opacity 0.25s ease 0.05s, max-height 0.35s ease;
}
.app[data-focus="1"] .topbar,
.app[data-focus="1"] .settings,
.app[data-focus="1"] .install,
.app[data-focus="1"] .foot,
.app[data-focus="1"] .about {
  max-height: 0;
  opacity: 0;
  margin: 0;
  padding-top: 0;
  padding-bottom: 0;
  border-width: 0;
  overflow: hidden;
  pointer-events: none;
  /* Kept gentle for any path that still animates the fold visibly (e.g.
     reduced-motion environments never enter the crossfade below). During a
     normal start this transition never shows: .is-entering suspends it and
     the swap happens behind the fade. */
  transition: opacity 0.4s ease 0.05s, max-height 0.6s cubic-bezier(0.3, 0.7, 0.2, 1);
}

/* The entrance crossfade (js: beginEntrance). The ready screen and the
   fullscreen countdown are two genuinely different layouts — padding, stage
   size, type scale — and morphing between them animated some properties
   while others snapped: an instant jump, then a glide. Not morphable, so
   not morphed: fade out, swap everything while the screen is dark, fade the
   countdown in. While .is-entering is on, the fold must SNAP — a transition
   still running under the fade would be caught mid-flight when the screen
   returns, recreating the exact seam this exists to remove. */
.app { transition: opacity 0.3s ease; }
.app.is-entering { opacity: 0; transition: opacity 0.16s ease; }
.app.is-entering .topbar,
.app.is-entering .settings,
.app.is-entering .install,
.app.is-entering .foot,
.app.is-entering .about { transition: none; }
/* ACTUALLY full screen: no column max-width, no page padding, no card chrome —
   the stage IS the screen. The controls float over it as icons (below), so not
   a single pixel band is reserved for buttons. */
/* Focus mode is the one place a full-height column is actually wanted: the
   stage has to grow to fill the phone while a round runs. Scoped here so the
   ready screen can never inherit it. */
.app[data-focus="1"] {
  min-height: 100dvh;
  max-width: none;
  padding: 0;
  gap: 0;
}
.app[data-focus="1"] .stage {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-evenly;
  gap: clamp(14px, 3.5vh, 34px);
  border: 0;
  border-radius: 0;
  /* The bottom reserve is where the floating controls live (52px + margins),
     so a long combo can never slide under them. Safe-area terms keep content
     off the notch/home bar when there's genuinely no browser chrome. */
  padding: calc(clamp(20px, 5vh, 56px) + env(safe-area-inset-top, 0px))
           calc(18px + env(safe-area-inset-right, 0px))
           calc(clamp(68px, 12vh, 100px) + env(safe-area-inset-bottom, 0px))
           calc(18px + env(safe-area-inset-left, 0px));
}
/* Mid-session the two buttons shrink to floating glass squircles over the
   stage. Rounded squares, not circles — they echo the card radius everywhere
   else, and a lone teal circle read as a record button. The text label is
   still in the DOM (font-size 0) for screen readers; the glyph is the icon. */
/* Portrait: centred at the bottom, on the same vertical axis as the ring and
   the combo, so the screen reads as one column top to bottom. Off in the
   right corner they sat against nothing. Still side by side. */
.app[data-focus="1"] .controls {
  position: fixed;
  left: 50%;
  right: auto;
  transform: translateX(-50%);
  bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  z-index: 6;
  gap: 10px;
}
.app[data-focus="1"] .btn {
  flex: none;
  width: 52px;
  height: 52px;
  padding: 0;
  border-radius: 16px;
  font-size: 0;
  border: 1px solid var(--hairline);
  background-color: rgba(255, 255, 255, 0.07);
  background-repeat: no-repeat;
  background-position: center;
  background-size: 20px 20px;
  /* No backdrop-filter here. Blurring behind a position:fixed element left
     ghost copies of both buttons painted at the top of the screen — visible in
     a screenshot, and the DOM only ever held one pair at the bottom. Over a
     near-black background the blur bought nothing anyway. */
}
.app[data-focus="1"] .btn--primary,
.app[data-focus="1"] .btn--primary.is-running {
  background-color: rgba(73, 149, 146, 0.3);
  border-color: rgba(73, 149, 146, 0.55);
  /* Play triangle: shown while PAUSED and on the finish screen ("go again") —
     the button's next action, not its current state. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8.5 5.2v13.6c0 .9 1 1.5 1.8 1L21 13c.7-.45.7-1.55 0-2L10.3 4.2c-.8-.5-1.8.1-1.8 1z' fill='%23e9f4f3'/%3E%3C/svg%3E");
}
.app[data-focus="1"] .btn--primary.is-running {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Crect x='6' y='4' width='4.4' height='16' rx='1.6' fill='%23e9f4f3'/%3E%3Crect x='13.6' y='4' width='4.4' height='16' rx='1.6' fill='%23e9f4f3'/%3E%3C/svg%3E");
}
/* On the normal screen only Start earns its place: Reset had nothing to reset
   there (the session is the thing you'd reset, and it isn't running). Both
   Reset (restart icon) and Exit exist only inside the fullscreen session. */
.app:not([data-focus="1"]) #resetBtn { display: none; }
#exitBtn { display: none; }
.app[data-focus="1"] #exitBtn {
  display: block;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23dfe7e6' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 14h6v6'/%3E%3Cpath d='M20 10h-6V4'/%3E%3Cpath d='M14 10l7-7'/%3E%3Cpath d='M3 21l7-7'/%3E%3C/svg%3E");
}
.app[data-focus="1"] #resetBtn {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M17.65 6.35A7.96 7.96 0 0 0 12 4a8 8 0 1 0 8 8h-2a6 6 0 1 1-6-6c1.66 0 3.15.68 4.24 1.76L13 11h7V4l-2.35 2.35z' fill='%23dfe7e6'/%3E%3C/svg%3E");
}
.app[data-focus="1"] .stage__meta,
.app[data-focus="1"] .stage__main { width: 100%; max-width: 100%; }
.app[data-focus="1"] .dial { width: min(78vw, 48vh, 100%); }
.app[data-focus="1"] .phase { letter-spacing: 0.28em; opacity: 0.9; }
.app[data-focus="1"] .round { letter-spacing: 0.16em; }
.app[data-focus="1"] .clock { font-size: clamp(44px, 12.5vw, 96px); }
.app[data-focus="1"] .combo {
  font-size: calc(clamp(46px, 15.5vw, 120px) * var(--fit, 1));
  line-height: 1.06;
  margin-top: 0;
}
.app[data-focus="1"] .phase { font-size: clamp(12px, 1.15vw, 17px); }
.app[data-focus="1"] .round { font-size: clamp(11px, 1vw, 15px); }

/* Landscape — phone on its side, or any desktop window. Vertical space is the
   scarce thing here, so the clock and the combo sit side by side instead of
   stacking, and the type is sized off height as much as width. */
@media (orientation: landscape) {
  /* GRID, not flex. A flex item's basis resolves to its content width, so the
     combo column never actually narrowed — it kept its full text width and
     justify-content shoved it straight over the clock. minmax(0, Nfr) tracks
     genuinely constrain, so the text wraps inside its own column. */
  .app[data-focus="1"] .stage {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.5fr);
    align-items: center;
    gap: clamp(14px, 3vw, 48px);
    /* The right gutter is where the floating controls column lives. */
    /* Same 72px on the left as the controls gutter on the right: measured, the
       content centre sat ~34px left of the screen centre — visibly off once
       the stage truly reaches the edges. */
    padding: calc(clamp(10px, 2.5vh, 36px) + env(safe-area-inset-top, 0px))
             calc(clamp(16px, 3vw, 40px) + 72px + env(safe-area-inset-right, 0px))
             calc(clamp(10px, 2.5vh, 36px) + env(safe-area-inset-bottom, 0px))
             calc(clamp(16px, 3vw, 40px) + 72px + env(safe-area-inset-left, 0px));
    min-height: 0;
  }
  .app[data-focus="1"] .stage__meta,
  .app[data-focus="1"] .stage__main { min-width: 0; min-height: 0; }
  .app[data-focus="1"] .stage__main { max-width: none; }
  /* On a side: the controls stack on the right edge, centred where a thumb
     naturally rests, out of both columns' way thanks to the gutter above. */
  /* On its side there is no room to spare at the bottom, so the pair becomes a
     column on the right edge where a thumb already rests. `left: auto` matters:
     without it the portrait `left: 50%` and this `right` would both apply and
     stretch the row across the screen. */
  .app[data-focus="1"] .controls {
    flex-direction: column;
    top: 50%;
    bottom: auto;
    left: auto;
    transform: translateY(-50%);
    right: calc(12px + env(safe-area-inset-right, 0px));
  }
  .app[data-focus="1"] .dial { width: min(32vw, 78vh, 100%); }
  /* Sized off height as well as width, so it fits the ~390px a phone gives you
     on its side rather than wrapping into the controls. */
  .app[data-focus="1"] .clock { font-size: clamp(26px, min(5.2vw, 15vh), 80px); }
  /* Landscape on a short phone has no spare height, so the countdown digit
     stays close to the running clock rather than taking the bigger treatment. */
  .app[data-focus="1"] .stage[data-phase="countdown"] .clock { font-size: clamp(30px, min(6vw, 18vh), 92px); }
  .app[data-focus="1"] .combo { font-size: calc(clamp(26px, min(6.6vw, 16.5vh), 92px) * var(--fit, 1)); }
  .app[data-focus="1"] .combo-name { font-size: 10px; margin-bottom: 0; }
  .app[data-focus="1"] .round { font-size: 11px; }
}

/* Phone on its side, NOT in a session. There are only ~390px of height here, so
   the default spacing pushed the Start button (and, after a session, the
   summary) below the fold — you had to scroll to find the main action. Tighten
   everything so the important controls stay on screen. */
@media (orientation: landscape) and (max-height: 520px) {
  .app { padding: 8px 16px 14px; gap: 8px; }
  .stage { padding: 10px 16px; }
  /* No ring off-session here: it would have to shrink to ~100px while the
     clock stays 32px, so the time burst out of the circle and read as a
     rendering glitch. Mid-round (focus) keeps its own, larger dial. */
  .app:not([data-focus="1"]) .dial { width: auto; aspect-ratio: auto; }
  .app:not([data-focus="1"]) .dial__ring { display: none; }
  .app:not([data-focus="1"]) .dial__center { max-width: none; }
  .phase { font-size: 11px; }
  .clock { font-size: 32px; margin: 0; }
  .round { font-size: 11px; }
  .combo { font-size: calc(18px * var(--fit, 1)); margin-top: 8px; min-height: 22px; }
  .combo-name { font-size: 10px; min-height: 12px; margin-bottom: 0; }
  .stats { margin-top: 6px; }
  .btn { padding: 10px; font-size: 15px; }
  .finish__hero .stat-num { font-size: 34px; }
  .finish__hero { margin-bottom: 2px; }
  .finish__label, .finish__meta { font-size: 11px; }
  .settings { padding: 14px; gap: 12px; }
}

/* Big screens: a Mac has the room for genuinely large type, and this gets read
   from further away than a phone. */
@media (min-width: 900px) {
  .app[data-focus="1"] .dial { width: min(32vw, 66vh, 100%); }
  .app[data-focus="1"] .clock { font-size: clamp(44px, min(6vw, 17.5vh), 116px); }
  .app[data-focus="1"] .combo { font-size: calc(clamp(52px, min(9.8vw, 24vh), 148px) * var(--fit, 1)); }
}

.phase {
  font-family: var(--font-mono);
  text-transform: uppercase;
  letter-spacing: 0.18em;
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
}
.stage[data-phase="work"] .phase { color: var(--work); }
.stage[data-phase="rest"] .phase { color: var(--rest); }

.clock {
  /* Sized with the dial (min(62vw, 240px)): at 68px fixed, the digits poked
     out of the ring on a 320px phone. 16vw tracks the ring's 62vw. */
  font-size: min(16vw, 64px);
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
  margin: 6px 0;
}
.round {
  color: var(--muted);
  font-size: 12px;
  font-family: var(--font-mono);
  letter-spacing: 0.1em;
}

.combo {
  margin-top: 20px;
  font-size: calc(30px * var(--fit, 1));
  font-weight: 800;
  min-height: 40px;
  color: var(--text);
  letter-spacing: 1px;
  text-wrap: balance;          /* even line lengths instead of one orphan word */
}
/* One move plus its separator, kept together so a wrapped combo never starts a
   line with a dangling hyphen. inline-block because transforms do nothing on a
   plain inline element — the pop below needs a box to scale. */
.mv { white-space: nowrap; }
/* The move glyph on its own, without the trailing separator. inline-block
   because transforms do nothing to a plain inline element. */
.mv__label {
  display: inline-block;
  transition: color 0.25s ease;
}
/* The move being called RIGHT NOW. Deliberately restrained: a small lift plus
   the brand teal is enough to pull the eye to your place in the combo, where
   a bigger jump would pull attention off the number you are meant to be
   reading. The colour holds until the next word takes over, so at any instant
   exactly one move is marked; the scale is a one-shot on arrival. */
@keyframes movePop {
  0%   { transform: scale(1); }
  30%  { transform: scale(1.1); }
  100% { transform: scale(1); }
}
.mv.is-now .mv__label {
  color: var(--accent);
  animation: movePop 0.36s cubic-bezier(0.2, 0.9, 0.3, 1.2);
}

/* iOS treats two quick taps on the same spot as "zoom in", which both zoomed
   the page and swallowed the second tap — so jabbing +/- to set rounds, work
   or rest fought the browser instead of changing the number. `manipulation`
   opts controls out of double-tap zoom while leaving pinch-to-zoom on the page
   alone, so the app stays zoomable for anyone who needs it. */
button,
summary,
label {
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none;
  user-select: none;
  -webkit-user-select: none;
}

/* Controls */
.controls { display: flex; gap: 12px; }
.btn {
  flex: 1;
  padding: 16px;
  border: none;
  border-radius: 12px;
  background: var(--surface-2);
  color: var(--text);
  font-size: 17px;
  font-weight: 700;
  cursor: pointer;
  transition: transform 0.05s, background 0.2s;
}
.btn:active { transform: scale(0.98); }
.btn--primary { background: var(--accent); color: var(--accent-ink); }
.btn--primary.is-running { background: var(--accent-dim); color: var(--text); }

/* Settings */
.settings {
  background: var(--surface);
  border-radius: var(--radius);
  padding: 18px;
  border: 1px solid var(--hairline);
  display: grid;
  gap: 16px;
}
.settings__title { margin: 0; font-size: 11px; color: var(--muted); font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.18em; font-weight: 500; }

.field { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.field > span, .field__label { font-size: 15px; }
.field--stack { flex-direction: column; align-items: stretch; gap: 9px; }
.field--stack .field__label { color: var(--muted); font-size: 11px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.14em; font-weight: 500; }

/* Segmented control — tap a segment or swipe across it */
.seg {
  position: relative;
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  background: var(--surface-2);
  border: 1px solid var(--hairline);
  border-radius: 13px;
  padding: 4px;
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
}
/* --t is the thumb's position along the row, 0 at the first option and 1 at
   the last (js/app.js sets it). Registering it as a real number is what lets
   the tint EASE across with the slide — an unregistered custom property is a
   string to the animation system, so the colour would snap while the thumb
   glided. Where @property is unsupported the tint simply changes instantly;
   nothing else depends on it. */
@property --t {
  syntax: "<number>";
  inherits: true;
  initial-value: 0;
}
.seg { transition: --t .26s cubic-bezier(.34, 1.2, .5, 1); }

.seg__thumb {
  position: absolute;
  top: 4px; bottom: 4px; left: 4px;
  width: calc((100% - 8px) / var(--count));
  /* Flat accent first: a browser without color-mix drops the two rules below
     and keeps exactly the thumb this control has always had. */
  background: var(--accent);
  /* oklch, not oklab. Rectangular spaces interpolate straight through the
     neutral axis, so teal -> amber passes through a desaturated sage and the
     middle options (Intermediate, Steady, Fast) came out muddy — the exact
     colours nothing in this palette uses. A polar space travels the hue angle
     with chroma intact, giving teal -> green -> yellow -> amber: a ramp that
     reads as heat rather than as a colour going grey on its way somewhere. */
  background: linear-gradient(
    105deg,
    color-mix(in oklch shorter hue, var(--ember-tip) calc(var(--t, 0) * 78%), var(--accent)),
    color-mix(in oklch shorter hue, var(--ember-tip) calc(var(--t, 0) * 92% + 12%), var(--ember-mid))
  );
  border-radius: 10px;
  transform: translateX(calc(var(--i) * 100%));
  transition: transform .24s cubic-bezier(.34, 1.2, .5, 1);
  box-shadow: 0 2px 8px rgba(73, 149, 146, 0.4);
  /* The glow warms with the fill, so the whole control gains heat rather than
     an amber chip sitting in a teal halo. */
  box-shadow: 0 2px 10px color-mix(in srgb,
    color-mix(in oklch shorter hue, var(--ember-tip) calc(var(--t, 0) * 100%), var(--accent)) 42%, transparent);
  pointer-events: none;
}
.seg__opt {
  position: relative;
  z-index: 1;
  background: none; border: none;
  color: var(--muted);
  font: inherit; font-weight: 700; font-size: clamp(11px, 3.1vw, 13.5px);
  padding: 10px 4px;
  cursor: pointer;
  transition: color .2s;
  /* Grid items default to min-width:auto, so "Intermediate" refused to shrink
     and pushed the whole page 5px wider than a 320px phone. */
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Narrowest phones still in use (iPhone SE 1st gen and similar). */
@media (max-width: 360px) {
  .app { padding-left: 14px; padding-right: 14px; }
  .seg__opt { padding: 10px 2px; }
  .step__btn { width: 36px; height: 36px; }
  .step__val { min-width: 52px; }
  .field > span, .field__label { font-size: 14px; }
}
.seg__opt[aria-selected="true"] { color: var(--accent-ink); }
.seg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Stepper — tap +/- or drag the number to scrub */
.step { display: inline-flex; align-items: center; background: var(--surface-2); border: 1px solid var(--hairline); border-radius: 12px; padding: 3px; }
.step__btn { width: 40px; height: 40px; border: none; background: none; color: var(--text); font-size: 24px; font-weight: 700; line-height: 1; border-radius: 9px; cursor: pointer; transition: background .15s; }
.step__btn:hover { background: var(--surface); }
.step__btn:active { background: var(--accent); color: var(--accent-ink); }
.step__val { min-width: 62px; text-align: center; font-size: 17px; font-weight: 800; font-variant-numeric: tabular-nums; touch-action: pan-y; cursor: ew-resize; user-select: none; -webkit-user-select: none; }

.combo-name {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--accent);
  min-height: 14px;
  margin-bottom: 2px;
}

.stats {
  margin-top: 12px;
  min-height: 14px;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
.stage[data-phase="done"] .stats { color: var(--accent); }

/* Before a session there's no combo to show, so the placeholder shouldn't sit
   at the same weight as one. */
.stage[data-phase="ready"] .combo,
.stage[data-phase="done"] .combo {
  font-size: 19px;
  font-weight: 600;
  color: var(--muted);
  letter-spacing: 0.02em;
}

/* ---- Finish celebration ----
   The end of a session is the only moment the app asks someone to feel good
   about what they just did, so it gets the one flourish in the whole UI.
   Warm colours appear here and nowhere else, which is what makes the streak
   read as special against the otherwise teal palette. */
.stage { position: relative; overflow: hidden; }

/* Each new combo arrives with a quick pop — overshoots a touch and settles,
   the same shape as the finish total's landing so the app has one idiom for
   "this just changed". Kept small: at display size a big scale reads as the
   text lurching. */
@keyframes finishRise {
  from { opacity: 0; transform: translateY(8px) scale(0.97); }
  to   { opacity: 1; transform: none; }
}
/* :not(.is-finale) matters: during the staged finale these elements are held
   invisible by a transition, and an ANIMATION (which beats normal declarations
   in the cascade) was forcing them visible through the centre-stage hold. The
   staged path reveals them with its own slide; this rise covers the
   reduced-motion/unstaged path only. */
.stage[data-phase="done"]:not(.is-finale) .combo {
  animation: finishRise 0.55s cubic-bezier(0.2, 0.9, 0.3, 1.15) both;
}
.stage[data-phase="done"]:not(.is-finale) .stats { animation: finishRise 0.55s 0.14s cubic-bezier(0.2, 0.9, 0.3, 1.15) both; }

/* A single ring breathing outward behind the clock — celebratory without
   turning into confetti. */
@keyframes ringOut {
  from { opacity: 0.45; transform: translate(-50%, -50%) scale(1); }
  to   { opacity: 0;    transform: translate(-50%, -50%) scale(1.9); }
}
/* Anchored to the DIAL, not the stage. Centred on the stage it was centred on
   nothing — the dial sits above the stage's middle (combo and summary take the
   lower half), so the ripple bloomed off-centre from the very circle it is
   supposed to come out of, and drifted further whenever the layout changed
   height. Sized at 90% of the dial, which is exactly the ring's inset, so it
   starts ON the ring and expands outward at every screen size.
   :not(.is-finale-reveal): the ripple belongs to Act 1 of the finale, when
   the dial is at dead centre. Left matching past the reveal, its second
   bloom played AFTER the glide, at the dial's resting spot — and Safari
   restarts pseudo-element animations when an ancestor's class changes, so
   the reveal could replay both blooms up there: "plays off centre twice".
   Removing the ::after at the reveal ends the ripple with its act. The
   unstaged path (reduced motion — is-finale never applied) keeps the full
   animation. */
.stage[data-phase="done"]:not(.is-finale-reveal) .dial::after {
  content: "";
  position: absolute;
  top: 50%; left: 50%;
  width: 90%; height: 90%;
  border: 2px solid var(--accent);
  border-radius: 50%;
  animation: ringOut 1.8s ease-out 2 both;
  pointer-events: none;
}

.stat-num { color: var(--text); font-weight: 700; display: inline-block; }

/* Finish screen: the punch total reads as the headline, everything else
   supports it. Sized off the viewport so a Mac shows it properly large. */
.finish__hero {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 10px;
  margin-bottom: 8px;
}
.finish__hero .stat-num {
  font-size: clamp(46px, 14vw, 104px);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--accent);
}
.finish__label {
  font-size: clamp(12px, 3.2vw, 17px);
  letter-spacing: 0.18em;
  color: var(--muted);
}
.finish__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 4px 2px;
  font-size: clamp(12px, 3.2vw, 15px);
  color: var(--muted);
  letter-spacing: 0.1em;
}
.finish__dot { opacity: 0.45; }
@media (min-width: 900px) {
  .finish__hero .stat-num { font-size: clamp(72px, 9vw, 132px); }
  .finish__label, .finish__meta { font-size: 16px; }
}
/* Must sit AFTER the rules above, not with the other landscape tweaks earlier
   in the file: same specificity means the later rule wins, and a phone on its
   side was rendering the punch total at 104px and pushing the summary off the
   bottom of the screen. */
@media (orientation: landscape) and (max-height: 520px) {
  .finish__hero .stat-num { font-size: 34px; }
  .finish__hero { margin-bottom: 2px; }
  .finish__label { font-size: 11px; }
  .finish__meta { font-size: 11px; }
}

/* The landing. Overshoots slightly and settles — the bit that makes hitting
   the total feel earned rather than just displayed. */
/* Scale + colour only. Animating text-shadow (the old glow) forces a repaint
   of the glyphs every frame — visible jank on phones at the exact moment that
   must feel best. The compositor animates transform for free. */
@keyframes numPop {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.42); color: var(--accent); }
  70%  { transform: scale(0.96); }
  100% { transform: scale(1); }
}
.stat-num.is-pop {
  animation: numPop 0.5s cubic-bezier(0.2, 0.9, 0.3, 1.4);
  /* The glow rides the pop as a constant, not a keyframe: interpolating
     text-shadow repaints the glyphs every frame, but switching it on for the
     pop's 500ms costs two paints total. Same flash, none of the jank. */
  text-shadow: 0 0 20px rgba(73, 149, 146, 0.75);
}
/* Warm orange appears ONLY on the streak — nowhere else in the app. Being the
   single exception to the BWB teal is exactly what makes it read as "this is
   the thing you earned"; in teal the flame just looked like another droplet. */
.streak { display: inline-flex; align-items: center; gap: 6px; color: var(--streak); white-space: nowrap; }

/* Flame: a drawn path, not stacked CSS shapes — at 13px the CSS version
   collapsed into an orange smudge overlapping the streak number. */
.flame {
  width: 1.15em; height: 1.15em;
  flex: none;
  filter: drop-shadow(0 0 5px rgba(255, 122, 0, 0.45));
  animation: flicker 1.6s ease-in-out infinite alternate;
  transform-origin: 50% 85%;
}
.flame__outer { fill: var(--streak-deep); }
.flame__inner { fill: var(--streak-hot); }
@keyframes flicker {
  from { transform: scale(0.94) rotate(-1.5deg); opacity: 0.92; }
  to   { transform: scale(1.05) rotate(1.5deg); opacity: 1; }
}

/* Anyone who has asked their phone to calm down gets no motion at all. */
@media (prefers-reduced-motion: reduce) {
  .stage[data-phase="done"] .combo,
  .stage[data-phase="done"] .stats,
  .stage[data-phase="done"] .dial::after,
  .flame,
  .stat-num.is-pop,
  .mv.is-now .mv__label,
  .stage[data-phase="countdown"] .dial__pulse { animation: none; }
  .stage[data-phase="done"] .dial::after { display: none; }
}

.about__version {
  margin-top: 4px;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.1em;
}

/* Collapsible "More options" — keeps the fine-tuning out of the way */
.more { border-top: 1px solid var(--hairline); margin-top: 2px; }
.more > summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; padding: 12px 2px 4px; color: var(--muted); font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; font-weight: 500; letter-spacing: .14em; }
.more > summary::-webkit-details-marker { display: none; }
.more > summary::after { content: "\2304"; font-size: 18px; line-height: 1; transition: transform .2s; }
.more[open] > summary::after { transform: rotate(180deg); }
.more__body { display: grid; gap: 16px; padding-top: 12px; }

/* Collapsible "About Combify" — a quiet card, closed by default */
.about {
  background: var(--surface);
  border-radius: var(--radius);
  border: 1px solid var(--hairline);
  overflow: hidden;
}
.about > summary {
  list-style: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 16px 18px;
  font-size: 14px;
  font-weight: 700;
  color: var(--text);
}
.about > summary::-webkit-details-marker { display: none; }
.about > summary::after { content: "\2304"; font-size: 18px; line-height: 1; color: var(--muted); transition: transform .2s; }
.about[open] > summary::after { transform: rotate(180deg); }
.about[open] > summary { border-bottom: 1px solid var(--hairline); }
.about__body { display: grid; gap: 12px; padding: 4px 18px 18px; }
.about__body p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.55; }
.about__body strong { color: var(--text); }

/* ==========================================================================
   The changelog page (changelog.html).
   Its own page rather than a panel inside About: the list only grows, and the
   app screen should stay short enough that Start is always the obvious thing
   to press.
   ========================================================================== */
.app--doc { min-height: auto; padding-bottom: 48px; }
.doc { display: grid; gap: 4px; }
.doc__back {
  color: var(--muted);
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  padding: 6px 10px;
  border-radius: 10px;
  border: 1px solid var(--hairline);
  white-space: nowrap;
}
.doc__back:hover { color: var(--text); }
.doc__title { margin: 8px 0 0; font-size: clamp(26px, 7vw, 38px); font-weight: 800; }
.doc__lede { margin: 0 0 8px; color: var(--muted); font-size: 14px; }

/* LINEAR, not cards. Seventy-odd releases in boxes gives every one of them the
   same visual weight and turns the page into a scroll of identical containers —
   the reader has to parse a frame before they can read a line. A changelog is
   skimmed for "what changed", so it wants the shape of a list: version, then
   its lines, then a rule. The hairline does the separating that a border, a
   fill, a radius and a padding were all doing before. */
.log { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
.log__item { padding: 18px 0; border-top: 1px solid var(--hairline); }
.log__item:first-child { border-top: none; padding-top: 4px; }
.log__head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.log__ver {
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.08em;
  color: var(--muted);
}
/* The build you're actually running, so "am I up to date?" is answerable at a
   glance — the reason the version is shown in About at all. */
.log__ver.is-current { color: var(--accent); }
.log__date { font-family: var(--font-mono); font-size: 11px; color: var(--muted); opacity: 0.7; }
.log__badge {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent);
  border: 1px solid var(--accent-dim);
  border-radius: 999px;
  padding: 2px 8px;
}
.log__title { margin: 4px 0 0; font-size: 16px; font-weight: 700; }
/* Tight. Every line is one claim, so they read as a list rather than as
   paragraphs that happen to have bullets. */
.log__notes { margin: 6px 0 0; padding-left: 16px; display: grid; gap: 3px; }
.log__notes li { color: var(--muted); font-size: 13.5px; line-height: 1.5; }
.log__notes li::marker { color: var(--accent-dim); }
.log__kind {
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--muted);
  border: 1px solid var(--hairline);
  border-radius: 999px;
  padding: 2px 8px;
}
.about__link { color: var(--accent); text-decoration: none; font-weight: 600; }
.about__link:hover { text-decoration: underline; }


/* Install nudge — one quiet, dismissible card above About. The teal hairline
   is the only flourish; it must read as a helpful aside, not a banner ad. */
.install {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--surface);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
  padding: 14px 16px;
}
.install[hidden] { display: none; }
.install__body { flex: 1; min-width: 0; }
.install__title { margin: 0; font-size: 14px; font-weight: 700; }
.install__sub { margin: 3px 0 0; font-size: 12.5px; color: var(--muted); line-height: 1.45; }
.install__actions { display: flex; align-items: center; gap: 8px; flex: none; }
.install__btn { flex: none; width: auto; padding: 9px 16px; font-size: 14px; border-radius: 10px; }
.install__dismiss {
  border: none;
  background: none;
  color: var(--muted);
  font-size: 20px;
  line-height: 1;
  padding: 6px;
  cursor: pointer;
}
.install__dismiss:hover { color: var(--text); }
/* iOS steps. Numbered because "tap Share, then Add to Home Screen" as one
   sentence is the form people fail to follow — two lines with the real glyph
   in them is the form they do. No arrow pointing at Safari's toolbar: iOS 15+
   lets that bar sit at the top OR the bottom, and an arrow pointing the wrong
   way is worse than no arrow at all. */
.install__steps {
  margin: 8px 0 0;
  padding-left: 18px;
  font-size: 12.5px;
  line-height: 1.65;
  color: var(--muted);
}
.install__steps[hidden] { display: none; }
.install__steps strong { color: var(--text); font-weight: 700; }
.install__share {
  display: inline-flex;
  vertical-align: -3px;
  color: var(--accent);
}


/* ==========================================================================
   The finish finale (js: startFinale). Act 1: dial alone at dead centre (a
   measured transform set inline), ripple blooming. Act 2: the glide back.
   Act 3: headline then numbers, staggered. Under reduced motion none of these
   classes are ever applied and everything simply appears.
   ========================================================================== */
.stage.is-finale .stage__main > * {
  opacity: 0;
  transform: translateY(22px);   /* enough travel that the slide reads as a slide */
}
.stage.is-finale.is-finale-reveal .stage__main > * {
  opacity: 1;
  transform: none;
  transition: opacity 0.65s ease, transform 0.65s cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Headline first, the counting numbers a beat later — time to take each in. */
.stage.is-finale.is-finale-reveal .combo { transition-delay: 0.2s; }
.stage.is-finale.is-finale-reveal .stats { transition-delay: 0.55s; }

/* The finale glide gets its own compositor layer; removed after (inline
   transition cleanup) so the layer doesn't linger. */
.stage.is-finale .stage__meta { will-change: transform; }
.stage.is-finale.is-finale-reveal .stage__meta { will-change: auto; }


/* The audit-mode copy button (see js/audit.js) borrows the footer link's
   look; buttons don't inherit it, so undo the UA button chrome. */
button.foot__link {
  background: none;
  border: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
  padding: 0;
  text-decoration: underline;
}

/* The always-visible footer: version + What's new, quiet mono, out of the way. */
.foot {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 8px;
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  padding-bottom: 4px;
}
/* ---- Report-a-problem dialog ----
   The native prompt() read as a browser artifact, not part of the app. Same
   card language as the install nudge: surface, dim accent border, radius —
   over a scrim so the app visibly waits. */
.report {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  /* Anchored high, not centred: the iPhone keyboard owns the bottom ~40%
     of the screen the moment the textarea focuses, and a centred card sat
     half-behind it. Up here the whole card stays visible while typing. */
  align-items: flex-start;
  justify-content: center;
  padding: max(8vh, calc(env(safe-area-inset-top) + 28px)) 20px 20px;
  /* A blurred, darker scrim: the card must read as a layer ABOVE the app,
     not a box sharing its background ("clashes with the background"). */
  background: rgba(0, 0, 0, 0.72);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}
.report[hidden] { display: none; }
.report__card {
  width: min(420px, 100%);
  background: var(--surface);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
  padding: 18px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
}
/* Title in the app's own label idiom — mono, uppercase, tracked — so the
   card reads as Combify, not as a generic dialog. */
.report__title {
  margin: 0;
  font-family: var(--font-mono);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  font-size: 12px;
  font-weight: 700;
  color: var(--accent);
}
.report__sub { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.5; }
/* Dialog actions are compact and right-aligned — the app's full-width .btn
   made Cancel as loud as Send and the pair swallowed the card. Cancel goes
   quiet (ghost); Send keeps the accent. */
.report__actions .btn {
  flex: 0 0 auto;
  padding: 9px 18px;
  font-size: 14px;
  border-radius: 10px;
}
.report__actions #reportCancel {
  background: none;
  color: var(--muted);
}
.report__actions #reportCancel:hover { color: var(--text); }
@keyframes reportIn {
  from { opacity: 0; transform: translateY(10px) scale(0.98); }
  to   { opacity: 1; transform: none; }
}
.report__card { animation: reportIn 0.18s ease-out; }
@media (prefers-reduced-motion: reduce) {
  .report__card { animation: none; }
}
.report__text {
  width: 100%;
  resize: none;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--accent-dim);
  border-radius: 8px;
  padding: 10px;
  font: inherit;
  /* 16px is load-bearing: iOS Safari auto-zooms the page into any text
     field below 16px — and doesn't always zoom back out ("the app is in a
     slightly zoomed in state" after sending a report). */
  font-size: 16px;
  /* the body turns selection off app-wide; typing needs it back */
  user-select: text;
  -webkit-user-select: text;
}
.report__text:focus { outline: none; border-color: var(--accent); }
/* The placeholder is an EXAMPLE, and has to look like one. At the browser
   default it sat almost as bright as real typed text, so the box read as
   already filled in — which is the one thing a placeholder must never do.
   Muted and italic: clearly a suggestion, still comfortably legible.
   `opacity: 1` is not redundant — Firefox dims placeholders by default, and
   without it this lands too faint to read. */
.report__text::placeholder {
  color: var(--muted);
  opacity: 1;
  font-style: italic;
}
.report__actions { display: flex; justify-content: flex-end; gap: 8px; }
.report__thanks {
  margin: 0;
  color: var(--accent);
  font-weight: 600;
  font-size: 14px;
  text-align: center;
}

.foot { flex-wrap: wrap; }
/* Line two of the footer: actions (Report a problem, and the developer's
   copy-log button when audit mode is armed). Its own centred row — tacked
   onto the meta line as a third item it squished into the corner on
   phones. */
.foot__actions {
  flex-basis: 100%;
  display: flex;
  justify-content: center;
  gap: 12px;
  margin-top: 2px;
}
.foot__dot { opacity: 0.5; }
.foot__link { color: var(--accent); text-decoration: none; font-weight: 600; }
.foot__link:hover { text-decoration: underline; }


/* The hero's glow lives on its own compositor layer BEHIND the digits: a soft
   radial halo whose opacity ramps with the count, crests at the pop, and fades
   out afterwards. Opacity is GPU-composited — the glyphs never repaint, which
   is what made the old text-shadow glow janky. */
.finish__hero .stat-num { position: relative; z-index: 0; }
/* tabular-nums is a PERFORMANCE fix here, not a typographic nicety. With
   proportional digits every step of the count-up changes the text's width —
   "1" is narrower than "8" — so each of the 24 steps relayouts a hero span
   that is up to 132px tall, plus the halo sized off it, plus the row below.
   Fixed-width digits make same-length values cost nothing to swap, and
   countUp reserves the FINAL width up front (min-width, in ch) so the jumps
   from 1 to 2 to 3 digits do not resize the box either. That is why big punch
   totals felt worse than small ones: more digit-count changes, more reflows.
   Centred so the reserved box does not shift the number off-axis. */
.stat-digits {
  position: relative;
  z-index: 1;
  display: inline-block;
  font-variant-numeric: tabular-nums;
  text-align: center;
  /* The climb writes a scale per step; easing between them makes it read as
     one continuous growth rather than 24 discrete jumps. The pop animation
     outranks this transform (animations beat inline styles in the cascade),
     so the overshoot still lands cleanly on top of it. */
  transform-origin: center;
  transition: transform 140ms linear;
  will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
  .stat-digits { transition: none; }
}
.stat-digits.is-pop { animation: numPop 0.5s cubic-bezier(0.2, 0.9, 0.3, 1.4); text-shadow: 0 0 20px rgba(73, 149, 146, 0.75); }
.stat-halo {
  position: absolute;
  inset: -0.3em -0.45em;
  z-index: 0;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgba(73, 149, 146, 0.55), rgba(73, 149, 146, 0) 72%);
  opacity: 0;
  transition: opacity 0.18s linear; /* smooths the per-step ramp */
  pointer-events: none;
}


/* ==========================================================================
   Add-to-home-screen dialog (.ins)
   Same card language as .report — surface, dim-accent hairline, app radius,
   over a blurred scrim — so it reads as part of Combify rather than a banner
   bolted on. Anchored high like .report for the same reason: on a short phone
   in landscape a vertically centred card gets clipped at both ends.
   ========================================================================== */
.ins {
  position: fixed;
  inset: 0;
  z-index: 60; /* above .report's 50 — never both open, but order must be defined */
  display: flex;
  /* CENTRED, unlike .report — and the difference is deliberate. .report is
     anchored high because focusing its textarea summons the iPhone keyboard
     over the bottom of the screen. This card has no text field and no
     keyboard, so the only things it has to clear are the browser's own bars:
     Chrome on iOS puts its address bar at the TOP, which is precisely where
     an anchored-high card put its instructions. Centred clears both ends. */
  align-items: center;
  justify-content: center;
  /* dvh, not vh. On iOS `vh` resolves against the viewport with the toolbars
     HIDDEN, so a card positioned in vh sits underneath a toolbar that is
     actually on screen. */
  padding: max(4dvh, calc(env(safe-area-inset-top) + 16px)) 20px
           max(4dvh, calc(env(safe-area-inset-bottom) + 16px));
  overflow-y: auto;
  /* Darker than .report's scrim. With the card's border gone, separation has
     to come from the gap in brightness between card and ground — so the ground
     goes almost black and the card comes up to meet it. */
  background: rgba(0, 0, 0, 0.88);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
}
/* The card moves to whichever end of the screen the pointer is aimed at, so
   the arrow and the instructions read as ONE thing rather than a dialog and an
   unrelated arrow at the far edge. The 84px clearance is a hard floor, not a
   preference: it is the room the pointer occupies, and letting the card slide
   past it is how the card ended up under Chrome's address bar before. */
.ins[data-aim="top"] {
  align-items: flex-start;
  padding-top: max(84px, calc(env(safe-area-inset-top) + 84px));
}
.ins[data-aim="bottom"] {
  align-items: flex-end;
  padding-bottom: max(84px, calc(env(safe-area-inset-bottom) + 84px));
}
.ins[hidden] { display: none; }
.ins__card {
  width: min(400px, 100%);
  /* Borderless, and a step lighter than .report's surface. The teal hairline
     framed this as "a dialog to deal with"; without it, sitting just under its
     own arrow, it reads as a label ON the thing being pointed at — which is
     what it actually is. */
  background: var(--surface-2);
  border: none;
  border-radius: var(--radius);
  padding: 20px;
  display: flex;
  flex-direction: column;
  gap: 12px;
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.7);
}
/* The app's own label idiom: mono, uppercase, tracked, accent. */
.ins__eyebrow {
  margin: 0;
  font-family: var(--font-mono);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  font-size: 11px;
  font-weight: 700;
  color: var(--accent);
}
.ins__title { margin: -4px 0 0; font-size: 19px; font-weight: 800; line-height: 1.25; }
.ins__sub { margin: 0; font-size: 13.5px; color: var(--muted); line-height: 1.55; }
/* Numbered, and each step on its own line. "Tap Share then Add to Home
   Screen" written as one sentence is precisely the form people fail to
   follow; three short numbered lines is the form they complete. */
.ins__steps {
  margin: 2px 0 0;
  padding: 0;
  list-style: none;
  counter-reset: ins-step;
  display: grid;
  gap: 10px;
}
.ins__steps:empty { display: none; }
.ins__steps li {
  counter-increment: ins-step;
  display: grid;
  grid-template-columns: 22px 1fr;
  align-items: start;
  gap: 10px;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--muted);
}
.ins__steps li::before {
  content: counter(ins-step);
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--surface-2);
  border: 1px solid var(--accent-dim);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 11px;
  font-weight: 700;
}
.ins__steps strong { color: var(--text); font-weight: 700; }
/* Platform glyphs inside the steps (js/app.js GLYPHS). Drawn, never named:
   most people have never consciously looked at the Share icon, so "tap Share"
   alone is the instruction that fails — and in a long share sheet the picture
   beside "Add to Home Screen" is what the eye finds before the words. Accent
   coloured so they read as "look for this shape". */
.ins__glyph {
  display: inline-flex;
  vertical-align: -3px;
  color: var(--accent);
  margin: 0 1px;
}
/* The old name, kept for the inline strip's hand-written markup in index.html. */
.ins__share { display: inline-flex; vertical-align: -3px; color: var(--accent); }
.ins__go {
  width: 100%;
  margin-top: 2px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}
/* On the solid accent button the glyph must take the button's ink colour, not
   the accent it uses everywhere else — accent on accent is invisible. */
.ins__go .ins__glyph { color: inherit; margin: 0; }
.ins__go[hidden] { display: none; }
/* Inconspicuous on purpose — present and reachable, never competing. */
/* Quiet, but the ONLY way out — the scrim deliberately does not dismiss. So
   it stays visually understated while getting a real thumb-sized hit area;
   inconspicuous must not mean hard to hit. */
.ins__skip {
  border: none;
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: 12.5px;
  padding: 10px 20px;
  min-height: 44px;
  cursor: pointer;
  align-self: center;
  text-decoration: underline;
  text-underline-offset: 3px;
  opacity: 0.75;
}
.ins__skip:hover { color: var(--text); opacity: 1; }
@keyframes insIn {
  from { opacity: 0; transform: translateY(12px) scale(0.98); }
  to   { opacity: 1; transform: none; }
}
.ins__card { animation: insIn 0.2s ease-out; }


/* ==========================================================================
   First-run walkthrough (.tour, js/tour.js)
   One spotlight box + one caption bubble. The darkness is the spotlight's own
   box-shadow spread across the viewport, so there is no second overlay layer
   to keep in sync and the whole thing animates by moving one element.
   ========================================================================== */
.tour {
  position: fixed;
  inset: 0;
  z-index: 70; /* above every other layer: it runs before anything else can */
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.tour[hidden] { display: none; }
.tour__spot {
  position: absolute;
  border-radius: 14px;
  /* The spread IS the scrim. Slightly lighter than .report's so the spotlit
     control still reads as the app's own colour, not a cutout. */
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.78);
  border: 1px solid var(--accent);
  transition: top 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              left 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              width 0.28s cubic-bezier(0.4, 0, 0.2, 1),
              height 0.28s cubic-bezier(0.4, 0, 0.2, 1);
  pointer-events: none;
}
.tour__bubble {
  position: absolute;
  background: var(--surface);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius);
  padding: 14px 16px;
  display: grid;
  gap: 6px;
  box-shadow: 0 14px 40px rgba(0, 0, 0, 0.6);
  pointer-events: none;
}
/* The arrow: a rotated square tucked under the bubble's edge, its horizontal
   position driven by --arrow-x so it keeps pointing at the target even when
   the bubble has been shoved sideways to stay on screen. */
.tour__bubble::after {
  content: "";
  position: absolute;
  left: var(--arrow-x, 50%);
  width: 12px;
  height: 12px;
  margin-left: -6px;
  background: var(--surface);
  border: 1px solid var(--accent-dim);
  transform: rotate(45deg);
}
.tour__bubble[data-side="below"]::after {
  top: -7px;
  border-right: none;
  border-bottom: none;
}
.tour__bubble[data-side="above"]::after {
  bottom: -7px;
  border-left: none;
  border-top: none;
}
.tour__count {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
  font-weight: 700;
}
.tour__text { margin: 0; font-size: 14px; line-height: 1.5; color: var(--text); }
.tour__hint { margin: 2px 0 0; font-size: 11.5px; color: var(--muted); }
/* Bottom corner, quiet, and out of the thumb's way for "continue". The dark
   pill is load-bearing rather than decorative: the spotlight lifts whatever is
   under it out of the scrim, and when that happens to be text near the bottom
   of the page, a transparent "Skip" sat illegibly on top of it. */
.tour__skip {
  position: absolute;
  right: max(16px, env(safe-area-inset-right));
  bottom: max(16px, env(safe-area-inset-bottom));
  border: none;
  background: rgba(0, 0, 0, 0.65);
  border-radius: 8px;
  color: var(--muted);
  font: inherit;
  font-size: 12.5px;
  padding: 8px 10px;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
  opacity: 0.7;
}
.tour__skip:hover { color: var(--text); opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  .ins__card { animation: none; }
  .tour__spot { transition: none; }
}


/* ==========================================================================
   Developer badge + panel (.devbadge / .devpanel, js/dev.js)
   Deliberately NOT in the app's design language. This is the one surface that
   must never be mistaken for something a member is meant to see, so it is
   flat, dense, monospaced and slightly ugly on purpose. If it ever starts
   looking like Combify, someone will ship it to a member and wonder why the
   numbers are wrong.
   ========================================================================== */
.devbadge {
  position: fixed;
  left: max(8px, env(safe-area-inset-left));
  bottom: max(8px, env(safe-area-inset-bottom));
  z-index: 80;
  border: 1px solid #7a5cff;
  background: #17122b;
  color: #b9a8ff;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  padding: 4px 8px;
  border-radius: 6px;
  cursor: pointer;
  opacity: 0.85;
}
.devbadge:hover { opacity: 1; }
.devpanel {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 81;
  background: #12102a;
  border-top: 1px solid #7a5cff;
  padding: 12px 14px calc(14px + env(safe-area-inset-bottom));
  display: grid;
  gap: 10px;
  font-family: var(--font-mono);
  font-size: 11px;
  color: #d6ccff;
  max-height: 62vh;
  overflow-y: auto;
  box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.7);
}
.devpanel[hidden] { display: none; }
.devpanel__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  text-transform: uppercase;
  letter-spacing: 0.18em;
  color: #b9a8ff;
  font-weight: 700;
}
.devpanel__row { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }
.devpanel__label {
  flex: 0 0 88px;
  color: #8d7fc4;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 10px;
}
.devpanel__btn {
  border: 1px solid #3d3268;
  background: #1c1840;
  color: #d6ccff;
  font: inherit;
  padding: 6px 10px;
  border-radius: 6px;
  cursor: pointer;
}
.devpanel__btn:hover { background: #2a2358; }
.devpanel__sel {
  border: 1px solid #3d3268;
  background: #1c1840;
  color: #d6ccff;
  font: inherit;
  padding: 6px 8px;
  border-radius: 6px;
}
.devpanel__check { display: inline-flex; align-items: center; gap: 5px; color: #8d7fc4; }
.devpanel__note { margin: 0; color: #6f639e; line-height: 1.5; }


/* The install pointer (.ins__aim, js/app.js renderAim).
   Sits against the screen edge nearest the browser's own Share or menu button
   and points out of the page at it. We cannot draw on browser furniture — the
   page has no access to it and no coordinates for it — so this leads the eye
   to the edge and lets the member finish the journey. */
.ins__aim {
  position: absolute;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  color: var(--accent);
  pointer-events: none;
  /* A slow drift toward the edge. Motion is what makes it read as "over
     there" rather than as another icon sitting on the page. */
  animation: aimNudge 1.6s ease-in-out infinite;
}
.ins__aim[hidden] { display: none; }
.ins__aim[data-edge="top"]    { top: max(10px, env(safe-area-inset-top)); }
.ins__aim[data-edge="bottom"] { bottom: max(10px, env(safe-area-inset-bottom)); }
/* --aim-inset is where the real button's CENTRE sits, measured in from the
   screen edge; the half-width correction puts the arrow under it rather than
   beside it. Chrome's share icon lives inside an omnibox that is itself inset,
   which is why pinning to the edge missed by about 6px. */
.ins__aim[data-side="right"]  { right: calc(var(--aim-inset, 27px) - 16px); }
.ins__aim[data-side="left"]   { left: calc(var(--aim-inset, 27px) - 16px); }
.ins__aim[data-side="center"] { left: 50%; margin-left: -16px; }
/* The arrow is the loudest thing on the screen: it is the actual instruction,
   and everything else is explaining it. The glow lifts it off the near-black
   scrim without needing a plate behind it. */
.ins__aim > svg { filter: drop-shadow(0 0 10px rgba(73, 149, 146, 0.65)); }
/* The repeated glyph is a reminder, not a second instruction — smaller and
   quieter than the arrow leading to it. */
.ins__aim .ins__glyph { color: inherit; opacity: 0.9; }
.ins__aim .ins__glyph svg { width: 21px; height: 21px; }
@keyframes aimNudge {
  0%, 100% { transform: translateY(0); opacity: 0.65; }
  50%      { opacity: 1; }
}
.ins__aim[data-edge="top"]    { animation-name: aimNudgeUp; }
.ins__aim[data-edge="bottom"] { animation-name: aimNudgeDown; }
@keyframes aimNudgeUp {
  0%, 100% { transform: translateY(6px); opacity: 0.7; }
  50%      { transform: translateY(-3px); opacity: 1; }
}
@keyframes aimNudgeDown {
  0%, 100% { transform: translateY(-6px); opacity: 0.7; }
  50%      { transform: translateY(3px); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .ins__aim { animation: none; opacity: 0.9; }
}


/* "Don't ask again" — the real opt-out on the install card.
   Deliberately the loneliest thing on the screen: pinned to whichever end the
   pointer is NOT using (js/app.js sets data-edge), so it is nowhere near the
   card, the arrow, or "Not now". Someone who genuinely never wants asking can
   find it; nobody hits it by accident while declining once. */
.ins__never {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  border: none;
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: 11px;
  letter-spacing: 0.02em;
  padding: 10px 16px;
  min-height: 40px;
  cursor: pointer;
  opacity: 0.45;   /* present, findable, and clearly not the intended answer */
}
.ins__never:hover { opacity: 0.85; color: var(--text); }
.ins__never[data-edge="top"]    { top: max(6px, env(safe-area-inset-top)); }
.ins__never[data-edge="bottom"] { bottom: max(6px, env(safe-area-inset-bottom)); }
