/* =========================================================================
   Lucy Learns
   Design tokens first, then primitives, then screens.
   Light only by design: the app is used in a lit entryway with a leash in
   one hand. Cool by design too: the chrome runs violet, denim, and slate —
   drawn from Lucy's collar — while the illustrations keep their warm cream.
   The temperature difference is deliberate. See docs/design-system.md.
   ========================================================================= */

/* --- fonts ----------------------------------------------------------------
   Self-hosted so a session works with no signal. Both are variable, latin
   subset, SIL Open Font License.
   Fraunces carries the voice: a soft, slightly wonky serif that sits with the
   painted illustrations instead of fighting them.
   Karla handles everything small: a warm grotesque with a tall x-height that
   stays legible at arm's length with a dog on the other end of the leash.
   -------------------------------------------------------------------------- */

@font-face {
  font-family: 'Fraunces';
  src: url('../fonts/fraunces-latin.woff2') format('woff2-variations');
  font-weight: 400 700;
  font-display: swap;
  font-style: normal;
}

@font-face {
  font-family: 'Karla';
  src: url('../fonts/karla-latin.woff2') format('woff2-variations');
  font-weight: 400 700;
  font-display: swap;
  font-style: normal;
}

:root {
  /* --- color ------------------------------------------------------------
     Two layers. Primitives are raw ramps, each named for where it lives in
     the artwork: Lucy's collar (violet), the visitor's hoodie and her tag
     (denim), the treat pouch (gold), the yard (spruce), a rose for caution,
     and a slate neutral pulled a few degrees toward the collar so the grays
     belong to the same family. Semantic tokens below them are what the rest
     of this file uses; nothing outside this block reaches for a primitive.

     The chrome is cool on purpose while the illustrations stay warm. The
     temperature difference is the frame: warm pictures sit forward on a
     cool field the way they do on a gallery wall, instead of dissolving
     into a paper the same color as their own cream. Gold is the one warm
     family the UI keeps, and it is reserved for reward moments, so warmth
     always means the same thing here: that went well.

     Contrast figures in the comments are computed, not eyeballed, and the
     full pair-by-pair table lives in docs/design-system.md. Re-verify
     before moving any value. */

  /* the collar. #452368 is the lit strap as sampled from the art
     (splash-mark.jpg reads #4a216d); 600 is the same hue — 271° — lifted
     just far enough to hold AA both as text on paper and under white text
     as a fill. */
  /* The 50 is the wash step, and it is deliberately not at the collar's hue.
     A tint is the collar mixed into the paper, so it should land between the
     two: the collar is 271deg, the paper's slate is 252deg, and this sits at
     258deg. It was #efe9f6 at 268deg — a lilac, which is to say the collar
     diluted rather than blended, and the pale pink cast that came with it was
     most of what made a screen of these pills read as one colour. Deeper too,
     by about 3%, so a selected pill separates from the card it sits on. */
  --violet-50: #e7e2f3;
  --violet-100: #e0d5f0;
  --violet-300: #a98fd0;
  --violet-600: #6a3d94;
  --violet-700: #55307a;
  --violet-800: #452368;
  --violet-900: #34204f;

  /* slate: the neutrals, and neutral now means neutral — hue 240 at 6–8%
     saturation across the ramp, where it used to run 19–29% at the light end.
     The cast was put there so the grays would "belong to the collar's family"
     while the art was warm; once the art was redrawn cool with lavender walls
     of its own, a violet-tinted paper under a violet-walled picture under a
     violet button was three purples stacked, and the app read as one colour.
     The paper is the largest surface on every screen, so it is the one that
     has to carry none of the hue. Every contrast floor rose when it moved. */
  --slate-25: #f6f6f7;
  --slate-50: #efeff1;
  --slate-100: #eaeaed;
  --slate-200: #dedee3;
  --slate-300: #c4c4cc;
  --slate-500: #5c5c68;
  --slate-800: #26262f;
  --slate-900: #17171d;

  /* denim: the blue hoodie and Lucy's tag. Three steps now rather than one.
     Denim was defined for informational accents and then used twice in six
     thousand lines, which left violet carrying the data as well as the
     actions; the wash and the text step are what it needed to take that
     work back. 700 exists because 600 lands at 4.54:1 on the wash — over
     the floor, but not by enough to survive anyone nudging either value. */
  --denim-100: #e5ecf5;
  --denim-600: #4c6b9b;
  --denim-700: #3f5c86;

  /* gold: the treat pouch */
  --gold-100: #f9f0d8;
  --gold-400: #e3b448;
  --gold-800: #755718;

  /* spruce: the yard through the open door */
  --spruce-100: #e2eeea;
  --spruce-600: #34735c;

  /* rose: caution. It replaces the old terracotta — same warmth of meaning,
     but it sits on the magenta side of the collar, so it reads as kin to
     the violet rather than a visitor from the tan palette. */
  --rose-100: #f8e9ed;
  --rose-400: #c25e79;
  --rose-600: #a63d5b;
  --rose-700: #92344e;

  /* --- semantic roles --------------------------------------------------- */
  --background: var(--slate-25);
  /* What sits behind the content column when the window is wider than it is.
     Unused on a phone, where the column is the window. */
  --field: var(--slate-100);
  --surface: #ffffff;
  --surface-sunken: var(--slate-50);
  --text-primary: var(--slate-800);       /* 14.19:1 on --background */
  --text-primary-dark: var(--slate-900);  /* hover only */
  --text-secondary: var(--slate-500);     /* 6.02:1 on --background */
  --text-on-dark: #ffffff;

  --primary: var(--violet-600);           /* 7.09:1 on --background, 7.75:1 on white,
                                             and 7.75:1 under white button text */
  --primary-dark: var(--violet-700);
  --primary-wash: var(--violet-50);
  --secondary: var(--denim-600);
  --secondary-text: var(--denim-700);   /* 5.72:1 on --secondary-wash, 6.22:1 on --background */
  --secondary-wash: var(--denim-100);

  --reward: var(--gold-400);              /* fills and accents only, never text */
  --reward-text: var(--gold-800);         /* 5.90:1 on --reward-wash, 6.13:1 on --background */
  --reward-wash: var(--gold-100);
  --success: var(--spruce-600);           /* 5.60:1 on white */
  --success-wash: var(--spruce-100);
  --caution: var(--rose-400);             /* fills and borders */
  /* 5.58:1 on --background, 5.19:1 on --caution-wash, 6.10:1 on white.
     The terracotta this replaces had already been lifted once for AA on
     the "Lucy is too excited" button and the watch chips; the rose keeps
     those same three floors. */
  --caution-text: var(--rose-600);
  --caution-text-dark: var(--rose-700);   /* hover */
  --caution-wash: var(--rose-100);

  --border: var(--slate-200);
  --border-strong: var(--slate-300);

  /* --- type ------------------------------------------------------------- */
  --font: 'Karla', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-display: 'Fraunces', Georgia, 'Times New Roman', serif;

  --step--1: 0.8125rem;
  --step-0: 1rem;
  --step-1: 1.125rem;
  --step-2: 1.375rem;
  --step-3: 1.75rem;
  --step-4: 2.125rem;

  /* --- space ------------------------------------------------------------ */
  --s-1: 0.25rem;
  --s-2: 0.5rem;
  --s-3: 0.75rem;
  --s-4: 1rem;
  --s-5: 1.5rem;
  --s-6: 2rem;
  --s-7: 3rem;

  /* --- shape ------------------------------------------------------------ */
  --r-sm: 10px;
  --r-md: 14px;
  --r-lg: 20px;
  --r-pill: 999px;

  /* --- motion ----------------------------------------------------------- */
  /* Two curves cover every animation in the app. --spring overshoots and
     settles — it is the arrival curve, for things that should land with a
     little life (tab icons, seals, the tally's kick already uses its own
     tuned copy). --ease-soft decelerates without overshoot — the departure
     and drift curve, for entrances that should be felt rather than seen.
     Anything needing a third curve should argue for it here first. */
  --spring: cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-soft: cubic-bezier(0.16, 1, 0.3, 1);

  /* Shadow ink is --slate-900, so shadows stay in the ramp's family. */
  --shadow-card: 0 1px 2px rgba(23, 23, 29, 0.05);
  --shadow-lift: 0 6px 20px rgba(23, 23, 29, 0.1);

  --tap: 48px;
  --nav-h: calc(60px + env(safe-area-inset-bottom));
  --page-max: 34rem;
}

/* --- reset ---------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
}

/* Any author `display` beats the UA's [hidden] rule, which has already bitten
   the tab bar and the tally's undo button. Settle it once, for everything. */
[hidden] {
  display: none !important;
}

body {
  margin: 0;
  font-family: var(--font);
  font-size: var(--step-0);
  line-height: 1.5;
  color: var(--text-primary);
  background: var(--background);
  -webkit-font-smoothing: antialiased;
  overscroll-behavior-y: none;
}

/* A whisper of paper tooth, so the flat UI shares air with the painted
   illustrations instead of floating above them. Fixed, so it never scrolls. */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: 0.4;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23g)' opacity='0.055'/%3E%3C/svg%3E");
}

/* Only the document flow needs lifting above the grain. The tab bar, player,
   and sheets are already positioned with higher z-indexes of their own, and
   giving them `position: relative` here would drop them out of fixed. */
#app {
  position: relative;
  z-index: 1;
}

h1,
h2,
h3,
h4,
p,
figure {
  margin: 0;
}

h1,
h2,
h3,
h4 {
  font-family: var(--font-display);
  line-height: 1.18;
  letter-spacing: -0.005em;
  font-weight: 600;
  /* Fraunces' optical size and softness axes: warmer at display sizes. */
  font-variation-settings: 'SOFT' 30, 'WONK' 1, 'opsz' 40;
}

/* Small headings sit closer to the body face so they do not shout. */
h3,
h4 {
  font-variation-settings: 'SOFT' 20, 'WONK' 0, 'opsz' 16;
  letter-spacing: 0;
}

button,
input,
select,
textarea {
  font: inherit;
  color: inherit;
}

img {
  max-width: 100%;
  display: block;
}

ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

a {
  color: var(--primary);
}

:focus-visible {
  outline: 3px solid var(--primary);
  outline-offset: 2px;
  border-radius: 4px;
}

/* Headings and containers only ever receive focus programmatically, to tell a
   screen reader the screen changed. They are not operable, so a focus ring on
   them reads as a bug to sighted users. Real controls keep theirs above. */
[tabindex='-1']:focus,
[tabindex='-1']:focus-visible {
  outline: none;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* A skip link that never becomes visible is a keyboard trap in slow motion:
   focus lands somewhere the user cannot see. WCAG 2.4.7. */
a.visually-hidden:focus {
  position: fixed;
  top: calc(var(--s-3) + env(safe-area-inset-top));
  left: var(--s-3);
  z-index: 80;
  width: auto;
  height: auto;
  min-height: var(--tap);
  margin: 0;
  padding: var(--s-3) var(--s-4);
  overflow: visible;
  clip: auto;
  clip-path: none;
  display: inline-flex;
  align-items: center;
  background: var(--surface);
  color: var(--primary-dark);
  font-weight: 650;
  border-radius: var(--r-pill);
  box-shadow: var(--shadow-lift);
}

/* =========================================================================
   View transitions
   Spatial continuity: tabs cross-fade, guided steps slide in the direction
   of travel, and an activity card's illustration morphs into the detail
   hero. All of it no-ops in browsers without the API and under
   prefers-reduced-motion (withTransition never starts a transition there).
   ========================================================================= */

@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root),
  ::view-transition-new(root) {
    animation-duration: 0.2s;
    animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
  }

  /* Forward: the new screen arrives from the right. Back: it returns. */
  html[data-vt='forward'] ::view-transition-old(root) {
    animation-name: vt-fade-out, vt-slide-out-left;
  }
  html[data-vt='forward'] ::view-transition-new(root) {
    animation-name: vt-fade-in, vt-slide-in-right;
  }
  html[data-vt='back'] ::view-transition-old(root) {
    animation-name: vt-fade-out, vt-slide-out-right;
  }
  html[data-vt='back'] ::view-transition-new(root) {
    animation-name: vt-fade-in, vt-slide-in-left;
  }

  @keyframes vt-fade-out {
    to {
      opacity: 0;
    }
  }
  @keyframes vt-fade-in {
    from {
      opacity: 0;
    }
  }
  @keyframes vt-slide-out-left {
    to {
      transform: translateX(-24px);
    }
  }
  @keyframes vt-slide-in-right {
    from {
      transform: translateX(24px);
    }
  }
  @keyframes vt-slide-out-right {
    to {
      transform: translateX(24px);
    }
  }
  @keyframes vt-slide-in-left {
    from {
      transform: translateX(-24px);
    }
  }
}

/* The tab bar is furniture: it holds still while screens change under it. */
.tabbar {
  view-transition-name: tabbar;
}

/* The morphing illustration rides above the cross-fading screens. */
::view-transition-group(*) {
  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}

/* =========================================================================
   App shell
   ========================================================================= */

#app {
  min-height: 100dvh;
}

.screen {
  max-width: var(--page-max);
  margin-inline: auto;
  padding: 0 var(--s-4) calc(var(--nav-h) + var(--s-6));
}

.screen-head {
  padding: calc(var(--s-5) + env(safe-area-inset-top)) 0 var(--s-4);
}

.screen-head .eyebrow {
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-secondary);
  margin-bottom: var(--s-2);
}

.screen-head h1 {
  font-size: var(--step-3);
}

.screen-head p {
  color: var(--text-secondary);
  margin-top: var(--s-2);
}

.section {
  margin-top: var(--s-6);
}

.section > h2 {
  font-size: var(--step-1);
  margin-bottom: var(--s-3);
}

.section-note {
  color: var(--text-secondary);
  font-size: var(--step--1);
  margin-top: var(--s-1);
}

.section-head-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: var(--s-3);
}

.section-head-row h2 {
  font-size: var(--step-1);
  margin: 0;
}

/* Full tap height like everything else. It was trimmed to 40px to sit neatly
   beside the heading; the negative margin buys that back visually without
   shrinking the target under the 48px the rest of the app holds to. */
.section-head-row .btn {
  min-height: var(--tap);
  margin-block: calc(var(--s-2) * -1);
  padding-inline: var(--s-3);
}

/* --- bottom navigation ---------------------------------------------------- */

/* The class selector would otherwise beat the UA [hidden] rule, so the player
   could only cover the tab bar rather than remove it from the page. */
.tabbar[hidden] {
  display: none;
}

.tabbar {
  position: fixed;
  inset: auto 0 0 0;
  z-index: 30;
  display: flex;
  background: rgba(255, 255, 255, 0.94);
  backdrop-filter: saturate(180%) blur(12px);
  border-top: 1px solid var(--border);
  padding-bottom: env(safe-area-inset-bottom);
}

.tabbar a {
  flex: 1;
  min-height: var(--tap);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  padding: var(--s-2) 0;
  text-decoration: none;
  color: var(--text-secondary);
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.01em;
}

.tabbar a[aria-current='page'] {
  color: var(--primary-dark);
}

/* The whole tab lights up, not a pill around its icon.
   ------------------------------------------------------------------------
   Color alone was not carrying this. Inactive is #5b5977 and current was
   #55307a — different hues, but almost the same *lightness*, so at 24px the
   only real cue was a half-pixel of extra stroke weight and the row read as
   four identical tabs. Value contrast is what the eye finds first; hue is what
   it confirms with. So the current tab is a filled shape, the same idiom the
   program row uses for "you are here".

   The fill was a seat around the icon and is now the tap target itself, which
   is what a person is actually aiming at — highlighting a 50px pill inside a
   94px button describes the icon rather than the control.

   It is painted by an absolutely positioned ::before rather than a background
   on the anchor, and that is a hard constraint rather than a preference. The
   bar measures 60.5px against `--nav-h: 60px`, which is what `.screen` pads
   the page by — it is already half a pixel over its own budget, so anything
   here that takes vertical space hides the bottom of every screen behind the
   bar. A ::before is out of flow and costs none: the inset gives the highlight
   its breathing room without adding a pixel of height. Raise --nav-h first if
   that ever has to change. */
.tabbar a {
  position: relative;
}

.tabbar a[aria-current='page']::before {
  content: '';
  position: absolute;
  inset: 4px 8px;
  border-radius: 14px;
  background: var(--primary-wash);
}

/* Above the fill, which is a sibling painted first. */
.tabbar a > * {
  position: relative;
}

.tabbar svg {
  width: 24px;
  height: 24px;
  stroke: currentColor;
  fill: none;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.tabbar a[aria-current='page'] svg {
  stroke-width: 2.25;
}

/* On wider screens the bar follows the content column rather than the window. */
@media (min-width: 34rem) {
  .tabbar {
    justify-content: center;
  }

  .tabbar a {
    flex: 0 1 8.5rem;
  }
}

/* =========================================================================
   Primitives
   ========================================================================= */

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  overflow: hidden;
}

.card-body {
  padding: var(--s-4);
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  min-height: var(--tap);
  padding: 0 var(--s-5);
  border: 1px solid transparent;
  border-radius: var(--r-pill);
  background: var(--primary);
  color: var(--text-on-dark);
  font-size: var(--step-0);
  font-weight: 650;
  text-decoration: none;
  cursor: pointer;
  transition: background 0.15s ease, transform 0.1s ease;
}

.btn svg {
  width: 20px;
  height: 20px;
  flex: none;
  stroke: currentColor;
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.btn:active {
  transform: scale(0.985);
}

.btn:hover {
  background: var(--primary-dark);
}

.btn--block {
  display: flex;
  width: 100%;
}

.btn--lg {
  min-height: 56px;
  font-size: var(--step-1);
}

.btn--quiet {
  background: var(--surface);
  color: var(--primary-dark);
  border-color: var(--border-strong);
}

.btn--quiet:hover {
  background: var(--primary-wash);
}

.btn--ghost {
  background: transparent;
  color: var(--text-secondary);
  font-weight: 600;
}

.btn--ghost:hover {
  background: var(--surface-sunken);
  color: var(--text-primary);
}

.btn--caution {
  background: transparent;
  color: var(--caution-text);
  border-color: var(--caution);
  font-weight: 600;
}

.btn--caution:hover {
  background: var(--caution-wash);
}

.btn[disabled] {
  opacity: 0.45;
  pointer-events: none;
}

.btn-row {
  display: flex;
  gap: var(--s-3);
}

.btn-row > * {
  flex: 1;
}

/* --- chips (multi-select tags) -------------------------------------------- */

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

.chip {
  min-height: var(--tap);
  padding: 0 var(--s-4);
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  border: 1.5px solid var(--border-strong);
  border-radius: var(--r-pill);
  background: var(--surface);
  color: var(--text-primary);
  font-size: var(--step-0);
  font-weight: 550;
  cursor: pointer;
}

.chip::before {
  content: '';
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 1.5px solid var(--border-strong);
  flex: none;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 12px;
}

.chip:active,
.option:active,
.level-row:active,
.choice:active {
  transform: scale(0.98);
}

.chip[aria-pressed='true'] {
  border-color: var(--primary);
  background: var(--primary-wash);
  color: var(--primary-dark);
}

.chip[aria-pressed='true']::before {
  background-color: var(--primary);
  border-color: var(--primary);
  /* check mark, so state never depends on color alone */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
}

.chip--watch[aria-pressed='true'] {
  border-color: var(--caution);
  background: var(--caution-wash);
  color: var(--caution-text);
}

.chip--watch[aria-pressed='true']::before {
  background-color: var(--caution);
  border-color: var(--caution);
}

/* --- big single-choice options -------------------------------------------- */

.options {
  display: grid;
  gap: var(--s-2);
}

.option {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  min-height: 60px;
  padding: var(--s-3) var(--s-4);
  text-align: left;
  border: 1.5px solid var(--border);
  border-radius: var(--r-md);
  background: var(--surface);
  cursor: pointer;
}

.option::before {
  content: '';
  width: 22px;
  height: 22px;
  flex: none;
  border-radius: 50%;
  border: 2px solid var(--border-strong);
  background-repeat: no-repeat;
  background-position: center;
  background-size: 14px;
}

.option[aria-pressed='true'] {
  border-color: var(--primary);
  background: var(--primary-wash);
}

.option[aria-pressed='true']::before {
  border-color: var(--primary);
  background-color: var(--primary);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
}

.option strong {
  display: block;
  font-weight: 600;
}

.option small {
  display: block;
  color: var(--text-secondary);
  font-size: var(--step--1);
}

/* --- stepper -------------------------------------------------------------- */

.stepper {
  display: flex;
  align-items: center;
  gap: var(--s-3);
}

.stepper button {
  width: var(--tap);
  height: var(--tap);
  flex: none;
  border-radius: 50%;
  border: 1.5px solid var(--border-strong);
  background: var(--surface);
  font-size: var(--step-2);
  line-height: 1;
  cursor: pointer;
  color: var(--primary-dark);
}

.stepper button:hover {
  background: var(--primary-wash);
}

.stepper output {
  font-family: var(--font-display);
  font-size: var(--step-3);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  min-width: 2.25ch;
  text-align: center;
}

/* --- pills / badges ------------------------------------------------------- */

.meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
  color: var(--text-secondary);
  font-size: var(--step--1);
}

/* Interpuncts between meta items, without stray leading dots. */
.meta > *:not(:first-child)::before {
  content: '·';
  margin-right: var(--s-2);
  color: var(--border-strong);
}

.badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 10px;
  border-radius: var(--r-pill);
  font-size: 0.75rem;
  font-weight: 650;
  letter-spacing: 0.01em;
  background: var(--surface-sunken);
  color: var(--text-secondary);
  white-space: nowrap;
}

/* Mastery badges carry a shape as well as a color, so state never rests on
   color alone: open ring, half bar, full bar, check. */
.badge--learning::after,
.badge--improving::after,
.badge--almost::after,
.badge--reliable::after,
.badge--untouched::after {
  content: '';
  width: 9px;
  height: 9px;
  flex: none;
  order: -1;
  background: currentColor;
  border-radius: 2px;
}

.badge--learning {
  background: var(--surface-sunken);
  color: var(--text-secondary);
}
.badge--learning::after {
  border-radius: 50%;
  background: none;
  box-shadow: inset 0 0 0 2px currentColor;
}
.badge--improving {
  background: var(--reward-wash);
  color: var(--reward-text);
}
.badge--improving::after {
  clip-path: polygon(0 100%, 100% 100%, 100% 55%, 0 75%);
}
/* Denim rather than the violet wash it wore. The five badges are a ladder —
   untouched, learning, improving, almost, reliable — and it now climbs slate,
   slate, gold, denim, spruce, so the step before mastery is visibly one step
   short of it. Spruce here would have read as already-arrived: --reliable is
   spruce, and two spruce badges separated only by their glyph is exactly the
   collision the glyphs exist to back up, not to carry alone. */
.badge--almost {
  background: var(--secondary-wash);
  color: var(--secondary-text);
}
.badge--almost::after {
  clip-path: polygon(0 100%, 100% 100%, 100% 5%, 0 45%);
}
.badge--reliable {
  background: var(--success-wash);
  color: var(--success);
}
.badge--reliable::after {
  width: 11px;
  height: 11px;
  background: none;
  border-radius: 0;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23427459' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
  background-size: contain;
  background-repeat: no-repeat;
}
.badge--untouched {
  background: transparent;
  color: var(--text-secondary);
  box-shadow: inset 0 0 0 1px var(--border);
}
.badge--untouched::after {
  background: none;
  box-shadow: inset 0 0 0 1.5px var(--border-strong);
  border-radius: 50%;
}

/* A badge inside a meta row is a peer, not a separated item. */
.meta > .badge::before {
  content: none;
}

.difficulty {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
}

.difficulty .pips {
  display: inline-flex;
  align-items: center;
  gap: 3px;
}

.difficulty i {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--border-strong);
}

.difficulty i.on {
  background: var(--secondary);
}

/* --- meter ---------------------------------------------------------------- */

.meter {
  height: 8px;
  border-radius: var(--r-pill);
  background: var(--surface-sunken);
  overflow: hidden;
}

.meter > span {
  display: block;
  height: 100%;
  border-radius: var(--r-pill);
  /* Was --primary. A meter here always shows levels already cleared — banked
     progress — and banked is spruce, the same claim the cleared pips and the
     rail's finished nodes make. Violet stays for position ("you are here"),
     which no .meter carries; the player's session bar is its own component. */
  background: var(--success);
}

.meter--reward > span {
  background: var(--reward);
}

/* =========================================================================
   Today
   ========================================================================= */

.today-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  padding: calc(var(--s-5) + env(safe-area-inset-top)) 0 var(--s-4);
}

/* Bigger than every other screen's h1, which is the point: this is the line
   that says the app knows who opened it. The extra size is what makes the name
   read as a welcome rather than a page title.

   It steps down as the name gets longer, because the size that makes
   "Fabiola" feel like a greeting makes "Bartholomew" wrap into the avatar.
   today.js measures the first name and sets data-name-length; three sizes is
   enough to cover everything from "Al" to a name that has no business being
   in a heading. Wrapping is still allowed as the last resort — a balanced
   two-line greeting beats a clipped one. */
.today-head h1 {
  font-size: var(--step-4);
  line-height: 1.15;
  text-wrap: balance;
  overflow-wrap: anywhere;
}

.today-head h1[data-name-length='long'] {
  font-size: var(--step-3);
}

.today-head h1[data-name-length='very-long'] {
  font-size: var(--step-2);
}

.today-head > div:first-child {
  min-width: 0;
}

.today-head p {
  color: var(--text-secondary);
  font-size: var(--step--1);
  margin-top: 2px;
}

.hero {
  position: relative;
  border-radius: var(--r-lg);
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-lift);
}

.hero img {
  width: 100%;
  /* Today has to fit a greeting, the program row, and a whole activity card
     with its button above the tab bar on a 812pt phone. The illustration is
     the one element here that can give up height without losing information,
     so it is the one that does. Cropped rather than scaled, and the focal
     point stays where it was. */
  aspect-ratio: 16 / 7;
  object-fit: cover;
  object-position: center 42%;
  background: var(--surface-sunken);
}

.hero-body {
  padding: var(--s-4) var(--s-4) var(--s-5);
}

.hero-body .eyebrow {
  font-size: var(--step--1);
  font-weight: 650;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  /* Was --primary-dark. Violet is reserved for what you can tap and for
     "you are here"; an eyebrow is metadata, and painting it violet put it
     in competition with the button below it. The activity's mark keeps a
     voice of its own in denim, below. */
  color: var(--text-secondary);
}

/* The activity's mark — the one informational token that is neither action
   nor state — speaks denim: the second accent, from her tag, doing the job
   it was sampled for. Scoped to the hero and the library card; the program
   strip's route marks carry state and keep their own colors. */
.hero-body .eyebrow-mark,
.activity-card-mark .eyebrow-mark {
  color: var(--secondary);
}

/* The eyebrow carrying the activity's mark. Inline-flex rather than a floated
   or absolutely placed icon so the mark sits on the text baseline row and
   wraps with it on a narrow screen. */
.eyebrow--marked {
  display: flex;
  align-items: center;
  gap: var(--s-2);
}

.eyebrow-mark {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: 1.5px solid currentColor;
}

.eyebrow-mark svg {
  width: 13px;
  height: 13px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.hero-body h2 {
  font-size: var(--step-2);
  margin: var(--s-2) 0 var(--s-1);
}

.hero-body > p {
  color: var(--text-secondary);
  margin-bottom: var(--s-4);
}

.hero .meta {
  margin-bottom: var(--s-4);
}

/* The one thing worth reading each week. Deliberately not another rounded
   card: the box is gone and the type carries it, which is also the single
   break in the app's otherwise uniform card rhythm. */
.insight {
  display: flex;
  gap: var(--s-3);
  align-items: flex-start;
  padding: var(--s-2) 0;
}

.insight p {
  font-family: var(--font-display);
  font-size: var(--step-2);
  line-height: 1.3;
  font-weight: 600;
  color: var(--text-primary);
  font-variation-settings: 'SOFT' 40, 'WONK' 1, 'opsz' 28;
  text-wrap: balance;
}

.insight svg {
  width: 24px;
  height: 24px;
  flex: none;
  margin-top: 4px;
  stroke: var(--reward);
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The headline is not always good news, so the mark beside it must not always
   be gold. `headlineInsight()` now leads with a watch behavior that got worse
   — nipping that appeared, jumping that went up — and a reward-colored
   sunburst next to "Jumping showed up in 2 sessions" is the same flattery the
   branch order was fixed to stop, done with color instead of a sentence.
   The sentence carries the meaning either way; this only stops the decoration
   arguing with it. */
.insight--watch svg {
  stroke: var(--caution-text);
}

/* The week, in one card: the reading on top, the evidence for it underneath.
   These were two stacked blocks saying the same thing in two registers. */
.week-card {
  padding: var(--s-4);
}

.week-card .insight {
  padding: 0;
  background: none;
  border: 0;
}

.week-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  margin-top: var(--s-4);
  padding-top: var(--s-3);
  border-top: 1px solid var(--border);
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.week-dots {
  display: flex;
  /* Room for the today halo below: 2px of card plus a 1.5px ring on each side
     of one dot is 7px between it and its neighbours, and 5px had them touch. */
  gap: 8px;
}

.week-dots i {
  width: 10px;
  height: 26px;
  border-radius: var(--r-pill);
  background: var(--surface-sunken);
  display: block;
}

/* Spruce, for the same reason the practice-frequency bars are: a filled dot is
   a day already practiced. It also makes the today ring legible for the first
   time — it was a violet-700 hairline drawn inset on a violet-600 fill, which
   is to say invisible on precisely the day it exists to mark. Banked is green,
   here is violet, and now the two are on different dots. */
.week-dots i.on {
  background: var(--success);
}

/* A halo rather than an inset ring: a gap of card, then violet, so the ring is
   always drawn against white (7.75:1) whatever the dot under it is filled
   with. Inset on the fill it was 1.8:1 on spruce and worse on the violet it
   replaced — check-contrast holds this pairing now, which is how that came to
   light. Violet here is still the "you are here" claim; it just stopped
   sharing a surface with the "already done" one. */
.week-dots i.today {
  box-shadow:
    0 0 0 2px var(--surface),
    0 0 0 3.5px var(--primary);
}

/* =========================================================================
   Activities
   ========================================================================= */

.goal-group + .goal-group {
  margin-top: var(--s-6);
}

.goal-group > header {
  margin-bottom: var(--s-3);
}

.goal-group h2 {
  font-size: var(--step-1);
}

.goal-group header p {
  color: var(--text-secondary);
  font-size: var(--step--1);
  margin-top: 2px;
}

.activity-list {
  display: grid;
  gap: var(--s-3);
}

.activity-card {
  display: flex;
  gap: var(--s-3);
  padding: var(--s-3);
  text-align: left;
  text-decoration: none;
  color: inherit;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  align-items: stretch;
}

.activity-card:active {
  background: var(--surface-sunken);
}

/* The thumb's frame. Sizing lives on the span rather than the img so the
   rounded box is one shape to reason about; the planned cards keep their own
   .planned-thumb treatment and stay outside the frame. The span also carried
   the art grade's violet ::before until the grade was deleted at the finish
   line, which is why the sizing is there and not on the image. */
.activity-card .card-illo {
  position: relative;
  width: 84px;
  height: 84px;
  flex: none;
  border-radius: var(--r-md);
  overflow: hidden;
}

.activity-card .card-illo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: var(--surface-sunken);
}

.activity-card .body {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
  justify-content: center;
}

.activity-card h3 {
  font-size: var(--step-0);
}

.activity-card .body > p {
  color: var(--text-secondary);
  font-size: var(--step--1);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.planned {
  padding: var(--s-4);
  border: 1px dashed var(--border-strong);
  border-radius: var(--r-lg);
  background: transparent;
}

.planned p {
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.planned ul {
  margin-top: var(--s-2);
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

.planned li {
  font-size: var(--step--1);
  color: var(--text-secondary);
  background: var(--surface-sunken);
  padding: 4px 10px;
  border-radius: var(--r-pill);
}

/* =========================================================================
   Activity detail
   ========================================================================= */

/* The column, like every other screen. Edge-to-edge and column-width are the
   same thing on a phone, which is why this went unnoticed: `--page-max` is
   34rem and no phone is that wide, so this cap does nothing there and the
   image still bleeds to both edges.

   On a laptop they are not the same thing. The image is `width: 100%` at 4/3,
   so its height is the window's width times 0.75 — at 1280 that is a 960px
   photograph in an 800px viewport. Opening any activity showed a wall of
   illustration with the title, the levels and the steps all below the fold,
   and the back button floating in the corner of the window rather than over
   the picture it belongs to.

   Capping the width rather than the height on purpose: `object-fit: cover` in
   a box that wide crops a letterbox slice out of the middle of a 4/3 painting,
   and these are illustrations of a person and a dog — the slice cuts their
   heads off. Holding the column keeps the same crop the phone gets. */
.detail-hero {
  position: relative;
  max-width: var(--page-max);
  margin-inline: auto;
}

.detail-hero img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  background: var(--surface-sunken);
}

/* The program screen leads with numbers, not a picture. The illustration sets
   the scene and then gets out of the way. */
.detail-hero--short img {
  aspect-ratio: 21 / 9;
}

.backlink {
  position: absolute;
  top: calc(var(--s-3) + env(safe-area-inset-top));
  left: var(--s-3);
  width: var(--tap);
  height: var(--tap);
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.92);
  box-shadow: var(--shadow-lift);
  color: var(--text-primary);
  text-decoration: none;
  border: 0;
  cursor: pointer;
}

.backlink svg {
  width: 22px;
  height: 22px;
  stroke: currentColor;
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.detail-body {
  position: relative;
  margin-top: -24px;
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  background: var(--background);
  padding: var(--s-5) var(--s-4) calc(var(--nav-h) + 90px);
  max-width: var(--page-max);
  margin-inline: auto;
}

.detail-body h1 {
  font-size: var(--step-3);
}

.lede {
  font-size: var(--step-1);
  color: var(--text-secondary);
  margin-top: var(--s-2);
}

.level-list {
  display: grid;
  gap: var(--s-2);
}

.level-row {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  padding: var(--s-3);
  text-align: left;
  background: var(--surface);
  border: 1.5px solid var(--border);
  border-radius: var(--r-md);
  cursor: pointer;
}

.level-row[aria-pressed='true'] {
  border-color: var(--primary);
  background: var(--primary-wash);
}

.level-row .n {
  width: 32px;
  height: 32px;
  flex: none;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--surface-sunken);
  color: var(--text-secondary);
  font-size: var(--step--1);
  font-weight: 700;
}

.level-row[aria-pressed='true'] .n {
  background: var(--primary);
  color: var(--text-on-dark);
}

/* The selected row says so in words, not border color alone. */
.level-row[aria-pressed='true'] .txt strong::after {
  content: ' · practicing this';
  font-size: var(--step--1);
  font-weight: 650;
  color: var(--primary-dark);
  white-space: nowrap;
}

.level-row .txt {
  min-width: 0;
  flex: 1;
}

.level-row strong {
  display: block;
  font-size: var(--step-0);
  font-weight: 600;
}

.level-row small {
  display: block;
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.checklist li,
.criteria li {
  display: flex;
  gap: var(--s-3);
  padding: var(--s-2) 0;
  color: var(--text-primary);
}

.criteria li::before {
  content: '';
  width: 18px;
  height: 18px;
  flex: none;
  margin-top: 3px;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23427459' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E")
    center / contain no-repeat;
}

.notes-list li {
  display: flex;
  gap: var(--s-3);
  padding: var(--s-2) 0;
  color: var(--text-primary);
  font-size: var(--step-0);
}

.notes-list li::before {
  content: '';
  width: 18px;
  height: 18px;
  flex: none;
  margin-top: 3px;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b25a40' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 9v4'/%3E%3Cpath d='M12 17h.01'/%3E%3Cpath d='M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z'/%3E%3C/svg%3E")
    center / contain no-repeat;
}

/* The fallback list is help, not warnings. Keep it visually quiet. */
.notes-list--calm li::before {
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236a3d94' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 6 15 12 9 18'/%3E%3C/svg%3E")
    center / contain no-repeat;
}

.sticky-action {
  position: fixed;
  inset: auto 0 var(--nav-h) 0;
  z-index: 20;
  padding: var(--s-3) var(--s-4);
  background: linear-gradient(
    to top,
    var(--background) 55%,
    rgba(246, 246, 247, 0)
  );
}

.sticky-action > * {
  max-width: var(--page-max);
  margin-inline: auto;
}

/* Naming the destination above the button keeps the label to one line, which
   matters when the destination is called "Stay While the Door Opens". */
/* Two lines need an opaque bar, not the single-button gradient: the hint would
   otherwise sit in the transparent half and collide with the page behind it.
   The fade moves to a strip above the bar instead. */
.sticky-action--stacked {
  padding-top: var(--s-4);
  background: var(--background);
}

.sticky-action--stacked::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  height: var(--s-6);
  pointer-events: none;
  background: linear-gradient(to top, var(--background), rgba(246, 246, 247, 0));
}

.sticky-hint {
  /* `auto` on the inline sides, not 0. This is a shorthand, so writing `0 0`
     here quietly cancelled the `margin-inline: auto` it gets from
     `.sticky-action > *` — and on a wide screen that dropped the hint against
     the left edge of the window while the button it labels stayed centered in
     the content column, two hundred pixels away. */
  margin: 0 auto var(--s-2);
  text-align: center;
  font-size: var(--step--1);
  font-weight: 600;
  color: var(--text-secondary);
}

details.disclosure {
  border-top: 1px solid var(--border);
  padding: var(--s-3) 0;
}

details.disclosure summary {
  cursor: pointer;
  font-weight: 600;
  min-height: 44px;
  display: flex;
  align-items: center;
  color: var(--primary-dark);
  list-style: none;
}

details.disclosure summary::-webkit-details-marker {
  display: none;
}

details.disclosure summary::after {
  content: '';
  width: 16px;
  height: 16px;
  margin-left: auto;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2355307a' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")
    center / contain no-repeat;
  transition: transform 0.15s ease;
}

details.disclosure[open] summary::after {
  transform: rotate(180deg);
}

details.disclosure .disclosure-body {
  padding-top: var(--s-2);
  color: var(--text-secondary);
}

/* =========================================================================
   Activity player (full screen)
   ========================================================================= */

.player {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  flex-direction: column;
  background: var(--background);
}

/* Held to the column, the way `.player-foot` already holds its button. The
   footer does it to its children because it has a surface and a border that
   should still span the window; this one is painted in the page background, so
   there is nothing to span and the whole row can move.

   Without it the session bar was the width of the window while the session
   itself was 34rem: on a 1280px laptop the close button sat at x=16 and the
   step badge at x=1264, about 1100px apart, with the progress track stretched
   into a 1102px hairline between them — three controls at the far edges of the
   screen belonging to a column in the middle of it. */
.player-top {
  view-transition-name: player-top;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  max-width: var(--page-max);
  margin-inline: auto;
  padding: calc(var(--s-3) + env(safe-area-inset-top)) var(--s-4) var(--s-3);
  background: var(--background);
}

.player-top .progress-track {
  flex: 1;
  height: 6px;
  border-radius: var(--r-pill);
  background: var(--surface-sunken);
  overflow: hidden;
}

.player-top .progress-track > span {
  display: block;
  height: 100%;
  width: 100%;
  background: var(--primary);
  transform-origin: left center;
  transition: transform 0.25s ease;
}

/* --tap, like everything else. This was 44px — Apple's minimum rather than
   this app's — and it is the close button on the player, which is the one
   control a handler reaches for with a dog on the other arm. */
.icon-btn {
  width: var(--tap);
  height: var(--tap);
  flex: none;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 0;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
}

.icon-btn:hover {
  background: var(--surface-sunken);
  color: var(--text-primary);
}

.icon-btn svg {
  width: 22px;
  height: 22px;
  stroke: currentColor;
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.player-scroll {
  flex: 1;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

.player-inner {
  max-width: var(--page-max);
  margin-inline: auto;
  padding: 0 var(--s-4) var(--s-5);
  min-height: 100%;
  display: flex;
  flex-direction: column;
}

.player-foot {
  view-transition-name: player-foot;
  padding: var(--s-3) var(--s-4) calc(var(--s-3) + env(safe-area-inset-bottom));
  background: var(--surface);
  border-top: 1px solid var(--border);
}

.player-foot > * {
  max-width: var(--page-max);
  margin-inline: auto;
}

/* The instruction used to sit under a "Step 3 of 5" eyebrow, which carried the
   spacing. With that removed as a duplicate of the top bar, the heading came to
   rest directly on the image. The gap belongs to the figure, so it holds for
   whatever follows it. */
.step-figure {
  border-radius: var(--r-lg);
  overflow: hidden;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
}

/* .player-inner is a flex column, so these margins do not collapse into each
   other the way they would in normal block flow. Without this the screens that
   still have an eyebrow would get both. */
/* Spacing between the player's blocks belongs to the container.
   ------------------------------------------------------------------------
   It used to belong to whichever element happened to sit between two others,
   which meant deleting an element silently deleted a gap: removing the
   duplicated "Step 3 of 5" eyebrow left the heading resting on the photo at
   0px, and that was the fourth time in this app that a removal took spacing
   with it.

   `gap` would be the obvious tool and is not, because these blocks do not
   share one rhythm: an eyebrow sits 8px from its heading and 24px from the
   figure above it. A gap can only be made smaller for a pair by giving that
   pair a negative margin, which trades a silent failure for an unreadable
   one. `> * + *` states the rhythm once and lets a pair name its own value,
   positively.

   The rule is "between any two children", so it cannot be removed along with
   a child. */
.player-inner > * + * {
  margin-top: var(--s-5);
}

/* An eyebrow belongs to the heading beneath it, not to the block above. */
.player-inner > .step-count + .step-instruction {
  margin-top: var(--s-2);
}

/* A heading and the thing that immediately explains it. */
.player-inner > .step-instruction + .section-note,
.player-inner > .welcome-title + .welcome-body {
  margin-top: var(--s-3);
}

.player-inner > .step-instruction + .say,
.player-inner > .step-count + .welcome-title,
.player-inner > .welcome-body + .welcome-note,
.player-inner > .welcome-note + .welcome-partner,
.player-inner > .cleared + .milestone-climb,
.player-inner > .program-band + .btn {
  margin-top: var(--s-4);
}

/* Pinned to the bottom of the screen, whatever precedes it. This has to come
   after the rule above, which would otherwise replace the auto. */
.player-inner > .panic-slot {
  margin-top: auto;
}

.step-figure img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

/* Short viewports — a 640px-tall Android is the common one — give the
   illustration less height so the step's controls are not pushed off the
   screen. `contain` rather than a shorter `cover`: cropping a quarter off a
   4:3 illustration takes the dog's head with it, and these pictures are
   composed, not stock. The figure's own tint fills the sides. */
@media (max-height: 720px) {
  .step-figure img {
    aspect-ratio: 16 / 9;
    object-fit: contain;
    background: var(--surface-sunken);
  }

  /* The rhythm and the display size are both tuned for a tall screen. On a
     short one they are what pushes the rep answer off the bottom, and an
     answer you have to scroll to is an answer that gets guessed. */
  .player-inner > * + * {
    margin-top: var(--s-4);
  }

  .player-inner > .step-instruction {
    font-size: var(--step-2);
  }
}

/* The tap-to-advance hint, shown once ever. Inside the picture because that
   is what it is about, at the bottom edge where the artwork is floor rather
   than face. Its own tint rather than a scrim over the whole image: the
   illustration is the most valuable thing on the screen and a one-time hint
   does not get to dim it. */
.step-figure {
  position: relative;
}

.advance-hint {
  position: absolute;
  left: 50%;
  bottom: var(--s-3);
  transform: translateX(-50%);
  max-width: calc(100% - var(--s-5));
  padding: var(--s-1) var(--s-3);
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--text-primary) 88%, transparent);
  color: var(--text-on-dark);
  font-size: var(--step--1);
  font-weight: 600;
  white-space: nowrap;
  pointer-events: none;
  animation: advance-hint-in 400ms var(--ease-soft) both;
}

@keyframes advance-hint-in {
  from {
    opacity: 0;
    transform: translate(-50%, 6px);
  }
  to {
    opacity: 1;
    transform: translate(-50%, 0);
  }
}

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

/* Right and wrong, side by side.
   ------------------------------------------------------------------------
   One step uses this: the calm greeting, where the failure worth naming is
   Lucy putting her feet on the guest. Two columns rather than two rows,
   because a comparison the household has to scroll between is not a
   comparison. At 375px that is about 163px a side, which is enough — sitting
   and rearing are different silhouettes, not different details.

   The captions carry the whole verdict. Nothing is painted into the artwork:
   no cross, no red ring, no tick. The error reads from the taut coral leash
   and from her posture, which is what the pilot was run to find out. */
.step-pair {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s-2);
}

.step-pair-item {
  margin: 0;
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  overflow: hidden;
  background: var(--surface-sunken);
}

.step-pair-item img {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

/* Short enough to hold one line at 163px, which is the whole design brief for
   this label. Both use a tinted band rather than color alone, so the pair
   still reads as two verdicts in grayscale and to anyone who cannot separate
   the green from the coral. */
.step-pair-item figcaption {
  padding: 6px var(--s-2);
  background: var(--success-wash);
  color: var(--success);
  font-size: var(--step--1);
  font-weight: 700;
  text-align: center;
}

.step-pair-item--avoid {
  border-color: var(--caution);
}

.step-pair-item--avoid figcaption {
  background: var(--caution-wash);
  color: var(--caution-text);
}

.step-count {
  font-size: var(--step--1);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-secondary);
}

.step-instruction {
  text-wrap: balance;
  font-family: var(--font-display);
  font-size: var(--step-3);
  line-height: 1.18;
  font-weight: 600;
  letter-spacing: -0.01em;
  font-variation-settings: 'SOFT' 30, 'WONK' 1, 'opsz' 48;
}

.say {
  display: flex;
  align-items: baseline;
  gap: var(--s-2);
  flex-wrap: wrap;
  padding: var(--s-3) var(--s-4);
  /* Was the reward wash. A cue is an instruction — the system speaking —
     not a reward, and the gold box was the loudest thing on the step screen,
     outshouting the instruction itself. Gold means one thing here: that went
     well. The cue speaks in the system's own violet instead. */
  background: var(--primary-wash);
  border-radius: var(--r-md);
  border-left: 4px solid var(--primary);
}

.say .label {
  font-size: var(--step--1);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--primary-dark);
}

.say .cue {
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  font-variation-settings: 'SOFT' 40, 'WONK' 1, 'opsz' 20;
}


.rep-counter {
  margin-top: var(--s-4);
  padding: var(--s-4);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
}

.rep-counter .label {
  font-weight: 600;
}

.rep-counter small {
  display: block;
  color: var(--text-secondary);
  font-size: var(--step--1);
  font-weight: 400;
}

/* Always within thumb reach at the bottom of the step, whatever the step holds. */
/* The escape hatch, pushed to the bottom of the screen. On a step with a short
   instruction this leaves real space above it, which is the point: it is not
   part of the sequence, and it sits where a thumb already is rather than in
   the flow of the instructions.
   (Briefly made sticky to close that gap. Reverted — the pinned version reads
   better.) */
.panic-slot {
  margin-top: auto;
  padding-top: var(--s-6);
  flex: none;
}

.panic {
  width: 100%;
}

/* --- the rep tally --------------------------------------------------------- */

.tally-actions {
  display: grid;
  gap: var(--s-2);
}

/* The success criteria, shown against the answer they define. Reference
   weight: it is the yardstick for the tap below it, never a competitor for
   it, so it takes the sunken tint the count strip uses rather than the raised
   surface a card would. Compact because it appears on every rep — the full
   section spacing on the activity page is for a page being read, not a line
   being checked with a dog waiting. */
.rep-criteria {
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-md);
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-size: var(--step--1);
  line-height: 1.45;
}

.rep-criteria-label {
  display: block;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-secondary);
}

/* The two answers sit side by side, not stacked.
   ---------------------------------------------------------------------------
   Stacked they were 88px and 56px with a gap between, and on the judgment
   screen — the tallest in the app — that put "Not that one" below the fold
   while "That went well" stayed in reach. A scorekeeper whose optimistic
   answer is one tap and whose honest answer is a scroll collects optimism,
   and the entire reason reps are counted as they happen is that the numbers
   are meant to be observations.

   So reach is equal now and the hierarchy moves into fill and color, which
   still say "most reps go well" without making the other answer dearer to
   give. Equal size is also simply truer: both are the same kind of statement
   about what just happened. */
.tally-answers {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s-2);
}

.tally-good,
.tally-miss {
  min-height: 76px;
  border-radius: var(--r-lg);
}

/* Why the answer labels no longer balance their line breaks.
   ---------------------------------------------------------------------------
   `text-wrap: balance` was here from when these buttons carried longer words.
   With a mic set before the label it began breaking "Not that one" over two
   lines while "Went well" stayed on one, which makes two buttons that are
   deliberately identical look like they are not — and that difference in
   weight is the entire reason they were made equal: the honest answer must
   not read as the lesser one. Dropping balance is the fix; from 360px up both
   phrases sit on one line on their own.

   Forcing `nowrap` was the tempting next step and it was wrong. A grid track
   cannot shrink below a line that refuses to break, so at 320px the pair grew
   to 334px inside a 320px screen and ran off the edge — invisibly, because
   the player's scroller clipped it. Below that width the label may wrap, the
   buttons stay the same size as each other, and nothing leaves the screen. */
.tally-answers > .btn {
  min-width: 0;
  /* Half the inline padding a full-width button takes. That padding exists to
     keep a label off the edges of a button that spans the screen; in a 2-up
     grid with the text centered anyway it only narrows the line. It was
     costing 48px of a 168px button, which left "Not that one" two pixels
     short of fitting and wrapping it to two lines beside a one-line "Went
     well" — the two are meant to look identical. */
  padding-inline: var(--s-3);
}

/* The running count during the rep cycle: one quiet line under the step, not
   a second scoreboard. The top bar already carries progress toward target;
   this is the glanceable total plus the one correction control. */
.rep-strip {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  flex-wrap: wrap;
  margin-top: var(--s-4);
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-md);
  background: var(--surface-sunken);
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.rep-strip b {
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

.rep-strip em {
  font-style: normal;
  color: var(--success);
  font-weight: 650;
}

.rep-strip-count {
  flex: 1 1 8rem;
}

/* Both corrections sit here at text weight. As full-width buttons they read
   as primary actions and stacked up into four visible ways to stop; a rep
   that went wrong and a rep logged wrong are both corrections to the count,
   and the count is what this row is. */
.rep-strip-actions {
  display: flex;
  align-items: center;
  gap: var(--s-1);
  margin-left: auto;
  flex-wrap: wrap;
}

/* --tap, not a squeezed 40px: these are reached for with a leash in the
   other hand. */
.rep-strip .btn {
  min-height: var(--tap);
  padding-inline: var(--s-2);
}

/* Meeting target is the peak of the session, so it gets the two beats the
   old counting screen had: the message arrives rather than appearing, and a
   light sweep travels the top progress bar — the arcade tell that the
   session was banked. Both fire once, on the render where the target is
   crossed (.spark is added by the player for one animation's length). */
.rep-strip--celebrate em {
  animation: tally-met-in 420ms cubic-bezier(0.22, 1.2, 0.36, 1) both;
}

@keyframes tally-met-in {
  from {
    opacity: 0;
    transform: translateY(6px) scale(0.96);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.player-top .progress-track {
  /* Containing block for the celebration sweep; overflow already clips it. */
  position: relative;
}

.player-top .progress-track.spark::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(
    90deg,
    transparent 0%,
    color-mix(in srgb, var(--success) 55%, transparent) 45%,
    transparent 90%
  );
  animation: tally-sweep 520ms ease-out;
}

@keyframes tally-sweep {
  from {
    transform: translateX(-100%);
    opacity: 0.9;
  }
  to {
    transform: translateX(100%);
    opacity: 0;
  }
}

/* The celebration is decoration on top of a strip that already changed, so
   reduced motion drops all of it. Nothing here carries information: the
   strip text and the toast say everything. */
@media (prefers-reduced-motion: reduce) {
  .rep-strip--celebrate em,
  .player-top .progress-track.spark::after {
    animation: none;
  }

  .player-top .progress-track.spark::after {
    content: none;
  }
}

/* The illustration doubles as a tap-to-advance target mid-cycle. No focus
   stop and no chrome: Next remains the accessible, discoverable path and the
   picture is a convenience for the thumb that lives up there anyway. */
[data-advance] {
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

/* --- hands free ------------------------------------------------------------
   The clock, kept on screen the whole time it is running. A timer with no
   visible state is a button that presses itself with no warning, so the
   strip shows the count and holds the pause and the length beside it. It
   takes the shape of the rep strip below it — same sunken field, same type
   — because it is the same kind of thing: a fact about the session with
   its corrections attached.

   Two rows by design, not by wrapping. The pause, the count and the stepper
   want 360px in a row and a phone gives the strip 327, and a flex row that
   wrapped put the stepper alone on a second line with nothing to say why.
   So the grid says why: the clock's state on the first row, the clock's
   length on the second, each with its name on the left and its control on
   the right. */

.pace-strip {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: var(--s-2) var(--s-3);
  margin-top: var(--s-4);
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-md);
  background: var(--surface-sunken);
  color: var(--text-secondary);
  font-size: var(--step--1);
}

/* Set in from the row's edge by the toggle's own inner padding, so the two
   names on the left start on the same line. */
.pace-step-label {
  padding-left: var(--s-2);
}

/* Drawn as the stepper buttons beside it are — white on the sunken field,
   with their border — so it reads as a control at rest. It used to be bare
   text that only turned white under the pointer, which on a phone is never:
   there is no hover on the screen that this strip is for, so the pause looked
   like a caption with an icon until it was pressed. --tap tall because it is
   reached for with a leash in the other hand. */
.pace-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--tap);
  padding: 0 var(--s-3) 0 var(--s-2);
  border: 1.5px solid var(--border-strong);
  border-radius: var(--r-pill);
  background: var(--surface);
  color: var(--text-primary);
  font: inherit;
  font-weight: 650;
  cursor: pointer;
}

.pace-toggle:hover {
  background: var(--primary-wash);
}

.pace-mark {
  display: inline-flex;
}

.pace-mark svg {
  width: 20px;
  height: 20px;
  stroke: currentColor;
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Tabular figures, so "Next step in 9s" and "Next step in 10s" take the same
   room and nothing beside the count shuffles once a second. */
.pace-count {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  justify-self: end;
  padding-right: var(--s-2);
}

/* The same stepper the counts use, with a smaller number. "10s" is a setting
   rather than a score, and at the score's display size it outweighed the
   instruction above it. */
.stepper--pace {
  gap: var(--s-2);
}

.stepper--pace output {
  font-size: var(--step-2);
  min-width: 3ch;
}

.stepper--pace button[disabled] {
  opacity: 0.45;
  pointer-events: none;
}

/* On get-ready the stepper sits in a counter row, like the count corrections
   on the detail screen, so the number has a name beside it. The row's own top
   margin is dropped because the note wrapper already spaces it from the
   chips above. */
.pace-counter {
  margin-top: 0;
}

.pace-note > .section-note {
  margin-top: var(--s-3);
}

/* The Next button, filling as the clock runs.
   ---------------------------------------------------------------------------
   The fill is the darker violet the button already turns on hover, swept
   left to right beneath the label, so the button reads as charging up to
   press itself. It is reinforcement rather than the record: the count in the
   strip carries the same fact as text, which is what lets a 1.4:1 step
   between two violets stand where a lone indicator would need 3:1. The
   label is positioned so it paints above the sweep; a static span would sit
   under an absolutely placed one whatever the source order. */
.btn--pace {
  position: relative;
  overflow: hidden;
}

/* Hover would paint the whole button the fill's colour and hide the clock,
   so on this button the fill is the hover state. */
.btn--pace:hover {
  background: var(--primary);
}

.btn-fill {
  position: absolute;
  inset: 0;
  background: var(--primary-dark);
  transform: scaleX(0);
  transform-origin: left center;
  will-change: transform;
  pointer-events: none;
}

.btn-pace-label {
  position: relative;
}

/* These follow a row of chips, which carries no bottom margin of its own, so
   the notes sat directly against the switch that reveals them and read as
   part of the control rather than as a note about it. A chip is a
   tap target with its own visual weight; text needs room to stop being an
   extension of it.

   Scoped under .voice-group deliberately: these carry .section-note too, and
   a bare class ties with it on specificity, so whichever rule sits later in
   the file wins — which is how this block ended up with a negative top
   margin pulling the notes into the switch above them. */
.voice-group .voice-warn,
.voice-group .voice-note {
  margin-top: var(--s-4);
}

/* And the last line of the group wants room before whatever the screen puts
   next, for the same reason. */
.voice-group > :last-child {
  margin-bottom: var(--s-2);
}

/* Stacked, not side by side. Sharing a row with its label left the select
   about 300px wide on a phone, which is where voice names go to be truncated
   — and the names are the entire content of this control. */
.voice-picker-row {
  display: block;
}

.voice-picker-row .label {
  display: block;
  margin-bottom: var(--s-2);
  font-weight: 600;
  font-size: var(--step--1);
  color: var(--text-secondary);
}

/* A native select on purpose: iOS gives it a proper wheel, which beats
   anything hand-built for a list this long, and it is reachable by keyboard
   and screen reader without any work. */
.voice-picker-row select {
  width: 100%;
  min-height: 52px;
  padding: 0 var(--s-4);
  /* The platform's chevron is drawn tight to the right edge, a thinner
     stroke and a different grey from the one on every disclosure in the app,
     so the only two down-arrows a household sees did not match and this one
     sat closer to its border than the text did to the other. Ours instead:
     the disclosure's chevron exactly -- same 16px box, same 2.5 stroke, same
     primary -- inset by --s-4 so it balances the text on the far side.
     appearance: none is what stops the platform drawing its own beside it,
     and the two prefixes are still load-bearing on iOS Safari. */
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  /* The chevron's box plus its inset plus a gap, so a long voice name is cut
     before it reaches the arrow rather than running under it. */
  padding-inline-end: calc(var(--s-4) * 2 + 16px);
  border: 1.5px solid var(--border-strong);
  border-radius: var(--r-md);
  /* One declaration, mark and ground together. Written as two -- the longhands
     above a `background: var(--surface)` -- the shorthand reset background-image
     to none and the select rendered with no arrow at all. */
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2355307a' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")
    right var(--s-4) center / 16px 16px no-repeat var(--surface);
  color: var(--text-primary);
  font: inherit;
  /* 16px is a threshold, not a preference: iOS zooms the whole page in when a
     form control smaller than this takes focus, and then leaves the layout
     zoomed. Every other note in this block is --step--1; this one cannot be. */
  font-size: 1rem;
  line-height: 1.2;
}

/* The one line on the get-ready screen that says what clears the intro level.
   A note, not a banner: it is the rule stated early, not a reward, and a card
   here would outrank the instruction it sits under. The check is the same
   mark the cleared seal uses, so the sentence and the thing it promises are
   drawn with one glyph. align-items: flex-start keeps the mark on the first
   line when the sentence wraps on a narrow phone. */
.first-level-note {
  display: flex;
  align-items: flex-start;
  gap: var(--s-2);
  margin-top: var(--s-2);
  color: var(--primary);
}
.first-level-note svg {
  flex: none;
  width: 16px;
  height: 16px;
  /* Optical: the cap height of the note's own size, so the mark sits on the
     text's shoulder rather than above it. */
  margin-top: 0.15em;
}

.voice-speech-status {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  flex-wrap: wrap;
}

.voice-speech-status .btn {
  min-height: var(--tap);
  padding-inline: var(--s-3);
}

.voice-warn {
  color: var(--caution-text);
}

/* The Lucy tab's last lines. Centred and set off from the cards above by a
   section's worth of space, because it is about the app rather than about
   the dog. The version is the one bold thing in it: the line somebody
   reads aloud down the phone. */
.colophon {
  margin-top: var(--s-6);
  text-align: center;
}

.colophon b {
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}

/* --- diagnostics -----------------------------------------------------------
   Plainer than the rest of the app on purpose. This screen is read once, by
   somebody trying to work out why something is broken, and every value on it
   has to be legible and copyable rather than handsome. Long strings — a user
   agent, a voice name — wrap instead of truncating, because a truncated fact
   is not a fact. */

.diag-table {
  border-top: 1px solid var(--border);
}

.diag-row {
  display: flex;
  gap: var(--s-3);
  align-items: baseline;
  padding: var(--s-2) 0;
  border-bottom: 1px solid var(--border);
  font-size: var(--step--1);
}

.diag-key {
  flex: 0 0 40%;
  color: var(--text-secondary);
  word-break: break-word;
}

.diag-value {
  flex: 1 1 auto;
  min-width: 0;
  color: var(--text-primary);
  font-weight: 600;
  word-break: break-word;
}

/* Monospaced and selectable: this block exists to be read closely or
   dragged over with a finger, and proportional type makes a wall of
   key-value lines harder to scan. It scrolls inside itself rather than
   stretching the page. */
.diag-text {
  margin: 0;
  max-height: 50vh;
  overflow: auto;
  padding: var(--s-3);
  border-radius: var(--r-md);
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 1.5;
  white-space: pre-wrap;
  word-break: break-word;
  -webkit-user-select: text;
  user-select: text;
}

.diag-no {
  color: var(--caution-text);
}

.diag-error {
  color: var(--danger, var(--caution-text));
}

/* Two halves that are actually halves. `.btn-row` sizes buttons to their
   content, which is right for a Cancel beside a Confirm and wrong for a pair
   of navigation controls that should look like one thing split in two —
   Previous step and Next step came out 139px and 109px and read as a mistake
   at a glance. */
.btn-row--split {
  display: grid;
  grid-template-columns: 1fr 1fr;
}

/* Alone on the last step, where the rep question is the way on. */
.btn-row--split > :only-child {
  grid-column: 1 / -1;
}

/* Slightly tighter than the app's body setting. These labels are two words
   set in a button rather than prose, and at this size the default tracking
   pushes "Previous step" wider than it needs to be. */
.btn-row--split .btn {
  letter-spacing: -0.01em;
  /* Half the inline padding a full-width button takes, for the same reason
     the answer buttons needed it: that padding keeps a label off the edges of
     something spanning the screen, and in a half-width button with a centered
     label it only narrows the line. "Previous step" wants 126px of content
     and had 118, so it wrapped to two lines beside a one-line "Next step". */
  padding-inline: var(--s-3);
}

.finish-session {
  margin-top: var(--s-3);
}

/* Fourteen faces, four across. At three the grid ran to five rows and pushed
   its own confirm button off the bottom of the sheet; at four it is four rows
   and the faces are still large enough to tell apart, which is the only job
   the tile has. */
/* Doubled class rather than trusting source order: `.avatar-grid` sets three
   columns several hundred lines below this, and at equal specificity that
   would win. */
.avatar-grid.avatar-grid--people {
  grid-template-columns: repeat(4, 1fr);
  gap: var(--s-2);
}

/* The sheet scrolls; this does not go with it.
   ---------------------------------------------------------------------------
   `bottom: 0` sticks the row to the bottom of the sheet's content box, not to
   the bottom of the sheet — the sheet's own bottom padding sits underneath it,
   and the grid kept scrolling through that band. Half a row of faces slid past
   below the two buttons, outside the bar's background and outside its shadow,
   which read as the sheet having sprung a leak.

   The padding moves off the sheet and onto the bar, so the bar's background
   runs to the sheet's bottom edge and there is nothing left to scroll behind.
   Carried on the bar rather than deleted, because that padding is also what
   keeps the buttons off a home indicator. */
.sheet:has(> .sheet-actions) {
  padding-bottom: 0;
}

.sheet-actions {
  position: sticky;
  bottom: 0;
  margin-top: var(--s-4);
  padding-top: var(--s-3);
  padding-bottom: calc(var(--s-5) + env(safe-area-inset-bottom));
  background: var(--surface);
  box-shadow: 0 -12px 16px -12px rgba(22, 20, 40, 0.18);
}

/* A badge that holds a picture instead of two letters. The initials version
   centres text; this one has to clip an image to the same circle at every
   size the badge is used. */
/* Doubled class, because `.avatar` is declared three thousand lines below
   this and would otherwise win the tie: it paints the badge teal for the
   initials it used to hold, and pads it a pixel at the top to sit them
   optically straight. Under a photograph that padding shows as a teal
   crescent along the rim — the sliver that kept turning up inside the
   circles — and the fill shows anywhere the image does not reach. */
.avatar.avatar--photo {
  padding: 0;
  overflow: hidden;
  background: var(--surface-sunken);
}

.avatar.avatar--photo img {
  width: 100%;
  height: 100%;
  margin: 0;
  border-radius: 50%;
  object-fit: cover;
}

/* Every person faces into the app, not off its edge. The dogs face as drawn.
   ---------------------------------------------------------------------------
   All twenty-four avatars were drawn looking to the right, and on Today the
   handler's portrait sits at the right-hand edge — so every face was
   looking out of the screen, away from the greeting beside it. Mirrored,
   the people look at the thing they belong to.

   The dogs are left as drawn. They were mirrored too for one release, and
   on the profile card — the one place a dog is shown large — the dog sits
   at the left edge with its name to the right, so the flip turned it away
   from its own name. Facing right is the direction that looks into that
   card, and the picker tiles follow the card so the picture chosen is the
   picture shown.

   A transform rather than a redraw, on purpose. The pictures are right in
   every other respect and a redraw is a round of generation per portrait
   with the usual chance of a new fault; the flip is free, exact and undone
   by deleting one rule. Nothing in the set carries text or an asymmetric
   mark that a mirror would betray. The three selectors are every place a
   person is drawn: the badge on Today and in the person rows, the people
   picker's tiles, and the setup preview. The edit badge lives on the
   wrapper, so it stays lower-right. */
.avatar.avatar--photo img,
.avatar-grid--people .avatar-choice img,
.avatar-pick img {
  transform: scaleX(-1);
}

/* Framing the handler portraits.
   ---------------------------------------------------------------------------
   Done to the files, not in CSS. They arrived as busts on a square field and
   dropped into a circle that read as sloppy: a band of empty field above the
   head, the chest sliced by the rim below.

   A single transform was tried first and cannot work here. The subject starts
   anywhere between 3% and 15% down depending on the hair, and one scale and
   origin either leaves the low ones adrift in space or shaves the tops off
   the tall ones — both of which happened. So each was measured and cropped to
   its own subject: a 300px window of the 400, twelve pixels of air above the
   hair, centered on the subject rather than on the frame. Nothing to undo at
   display time, and every circle now holds a head where a head should be. */
/* A face in the switcher is a control too. Same badge, smaller, because the
   portrait it sits on is a third the size of the one on the profile card. */
/* A face in the switcher, at the size the pictures were cropped for.
   ---------------------------------------------------------------------------
   It was 34px, which is a favicon of a person: the crop that reads as a
   portrait everywhere else in the app turned into a smudge here, and the row
   went cluttered because four controls were crowding a thumbnail. At 56 the
   same crop reads the same way it does on the profile card, the badge has
   room to sit on the rim rather than over a cheek, and the row has a subject
   rather than a decoration.

   Big enough to be its own tap target now, so no padding trick is needed. */
.person-row-face {
  position: relative;
  flex: none;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  --edit-badge-size: 20px;
}

.person-row-face .avatar {
  width: 56px;
  height: 56px;
}

/* Today's avatar is a button wrapped round a portrait, and nothing else:
   no fill, no border, no padding. Anything it painted would show as a ring
   behind the circle the portrait already draws. */
.person-switch {
  flex: none;
  display: block;
  padding: 0;
  border: 0;
  background: none;
  line-height: 0;
  cursor: pointer;
}

/* --- the handler's portrait -------------------------------------------------
   The one place the app plays. Everything after setup is counting repetitions
   honestly and reporting what they add up to, so the joke lives here, before
   any of that starts, and nowhere near a screen about how the dog is doing. */

.avatar-pick {
  flex: none;
  width: 76px;
  height: 76px;
  position: relative;
  padding: 0;
  border: 1.5px solid var(--border-strong);
  border-radius: 50%;
  background: var(--surface-sunken);
  cursor: pointer;
}

.avatar-pick img {
  width: 100%;
  height: 100%;
  margin: 0;
  border-radius: 50%;
  object-fit: cover;
}

/* Setup sits on the page rather than on a card, so the badge rings itself
   against that instead. */
.avatar-pick {
  --edit-badge-ring: var(--background);
}

.setup-avatar-name strong {
  color: var(--text-primary);
}

/* The two lines above the grid, set as two different things.
   ---------------------------------------------------------------------------
   They were set as one. The instruction inherited `.sheet h2` and the name
   inherited a bare paragraph, which put both at --step-2 in the display face
   — one heading against the left edge, another the same size centered under
   it, and nothing saying which to read first. Two headings is no heading.

   The instruction is read once and then never again, so it goes down to the
   small caps label this app already uses for that job. The name is the reason
   to open the sheet a second time — "Oracle of Obedience" is half the joke —
   so it is what looks like a heading now, and it keeps the accent that tells
   you it is the answer rather than more chrome.

   Both to the left edge, where every other sheet in the app sets its title. A
   name centered under the grid was also a name that moved: it is rewritten on
   every tap, and centered it grew out of both sides at once. Anchored to an
   edge it only ever grows one way. */
.avatar-sheet-title {
  font-family: var(--font);
  font-size: var(--step--1);
  font-weight: 650;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-secondary);
}

/* The plate is a direct child paragraph of the sheet, so `.sheet--dialog > p`
   — one class and one type more specific, three thousand lines further down —
   was quietly taking the colour and the margins off it. The accent this rule
   asks for never reached the screen; the name rendered in body grey. Matching
   that selector's shape settles it rather than leaving two rules disagreeing
   about the same paragraph. */
.sheet--dialog > .avatar-name-plate {
  margin: var(--s-1) 0 var(--s-4);
  /* A line's worth, held. The floor is what stops the grid jumping up the
     screen between one tap and the next; two lines of it, which is what the
     centered version reserved, left a permanent blank line under every name.
     All fourteen set on one line down to a 320px phone — measured, not
     assumed — and if a fifteenth ever wraps it costs one line of movement
     rather than a gap on every screen that never wraps. */
  min-height: 1.15em;
  text-align: left;
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  line-height: 1.15;
  color: var(--primary-dark);
  font-variation-settings: 'SOFT' 40, 'WONK' 1, 'opsz' 24;
}

.avatar-name-plate.is-new {
  animation: name-arrive 320ms var(--spring) both;
}

@keyframes name-arrive {
  from {
    opacity: 0;
    transform: translateY(4px) scale(0.97);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* The name is information; the way it lands is not. */
@media (prefers-reduced-motion: reduce) {
  .avatar-name-plate.is-new {
    animation: none;
  }
}

/* --- fallback sheet ------------------------------------------------------- */

.sheet-backdrop {
  position: fixed;
  inset: 0;
  z-index: 60;
  background: rgba(22, 20, 40, 0.45);
  display: flex;
  align-items: flex-end;
  justify-content: center;
}

.sheet {
  width: 100%;
  max-width: var(--page-max);
  max-height: 88dvh;
  overflow-y: auto;
  background: var(--surface);
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  padding: var(--s-5) var(--s-4) calc(var(--s-5) + env(safe-area-inset-bottom));
  animation: sheet-in 0.22s ease;
}

@keyframes sheet-in {
  from {
    transform: translateY(16px);
    opacity: 0;
  }
}

.sheet h2 {
  font-size: var(--step-2);
}

.sheet > p {
  color: var(--text-secondary);
  margin-top: var(--s-2);
}

.sheet img {
  width: 100%;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  border-radius: var(--r-md);
  margin: var(--s-4) 0;
}

/* --- result --------------------------------------------------------------- */

.result-group {
}

.result-group > h2 {
  font-size: var(--step-1);
  margin-bottom: var(--s-3);
}

.result-group > p {
  color: var(--text-secondary);
  font-size: var(--step--1);
  margin: calc(var(--s-1) * -1) 0 var(--s-3);
}

.field label {
  display: block;
  font-weight: 600;
  margin-bottom: var(--s-2);
}
/* The pronoun row's label is a span, not a label element -- three buttons
   have nothing for a `for` to point at, so the group is named by
   aria-labelledby instead -- and it fell through this rule: "And you call
   them…" sat welded to the chips under it while "Their name" kept its step
   over its input. Same step, so the two questions on the setup screen line
   up. The weight stays as it was. */
.field .label {
  display: block;
  margin-bottom: var(--s-2);
}

.field textarea,
.field input[type='text'],
.field input[type='datetime-local'] {
  width: 100%;
  min-height: var(--tap);
  padding: var(--s-3);
  border: 1.5px solid var(--border-strong);
  border-radius: var(--r-md);
  background: var(--surface);
  resize: vertical;
}

.recommend {
  text-align: center;
  padding: var(--s-6) 0 var(--s-5);
}

/* The verdict, as a picture. Seven strips, one per key `recommendation()`
   returns: Lucy alone on a flat field whose colour is the state — gold for
   the level opening, spruce for a good session, rose for the two cautions —
   with a saturated ground band along the bottom third. The band is what
   carries the colour, because a black dog on a fully saturated field has no
   edge to read against; the pale field above her is what she reads against.

   This replaces a 72px circle holding a stroke mark. That mark had three
   glyphs for seven verdicts, and the commonest of them, `check`, sat
   directly above the identical check in the "Level N cleared" seal.

   Edge to edge on purpose. The negative margins undo `.player-inner`'s
   padding so the field colour meets the page at a hard edge instead of
   sitting in a rounded card — the strip should read as the screen itself
   changing colour, not as a fourth picture in a frame. `.player-inner`
   already caps the column at `--page-max`, so the strip is capped with it,
   which is the same lesson `.detail-hero` learned: a 3:1 strip the width of
   a 1280px window is a 427px wall of colour.

   The width and height attributes on the element reserve the 3:1 box before
   the JPEG arrives; without them the title sat at the top for a frame and
   then jumped down. `aspect-ratio` is belt and braces for the same thing. */
.recommend .verdict-art {
  display: block;
  width: calc(100% + 2 * var(--s-4));
  /* The stylesheet caps every img at 100% of its container. Here that is the
     column inside the padding, so the strip started at the left screen edge
     and stopped 32px short of the right one. */
  max-width: none;
  height: auto;
  aspect-ratio: 3 / 1;
  object-fit: cover;
  margin: 0 calc(-1 * var(--s-4)) var(--s-5);
}

/* The emotional peak of the app: the moment a session lands. One considered
   beat, not a confetti cannon. The picture settles, then the words rise. A
   fade with the barest scale, rather than the 0.7 the old mark grew from — a
   full-width strip swelling by a third is a lot of movement. */
.recommend .verdict-art {
  animation: verdict-settle 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
}

.recommend h1 {
  animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.14s both;
}

.recommend > p {
  animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.22s both;
}

.recommend + .stat-row {
  animation: rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both;
}

@keyframes verdict-settle {
  from {
    transform: scale(0.97);
    opacity: 0;
  }
}

@keyframes rise {
  from {
    transform: translateY(8px);
    opacity: 0;
  }
}

.recommend h2 {
  font-size: var(--step-3);
}

.recommend > p {
  color: var(--text-secondary);
  margin-top: var(--s-3);
  font-size: var(--step-1);
}

.stat-row {
  display: flex;
  gap: var(--s-3);
}

.stat-row .stat {
  flex: 1;
  padding: var(--s-3);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  text-align: center;
}

.stat b {
  display: block;
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-variant-numeric: tabular-nums;
  font-variation-settings: 'SOFT' 20, 'WONK' 0, 'opsz' 28;
}

.stat span {
  font-size: var(--step--1);
  color: var(--text-secondary);
}

/* =========================================================================
   Progress
   ========================================================================= */

.metric-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--s-3);
}

.metric {
  padding: var(--s-4);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
}

.metric b {
  display: block;
  /* A measurement is information, not an action and not a place, so it speaks
     denim rather than violet or the slate it used to inherit. These two
     numbers are the largest type on the Progress screen; leaving them slate
     was what made the whole screen read as violet-and-grey. */
  color: var(--secondary);
  font-family: var(--font-display);
  font-size: var(--step-4);
  line-height: 1.05;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
  font-variation-settings: 'SOFT' 20, 'WONK' 0, 'opsz' 60;
}

.metric span {
  display: block;
  color: var(--text-secondary);
  font-size: var(--step--1);
  margin-top: var(--s-1);
}

.metric small {
  display: block;
  font-size: var(--step--1);
  margin-top: var(--s-2);
  color: var(--text-secondary);
}

.bars {
  display: flex;
  align-items: flex-start;
  gap: var(--s-2);
  padding-top: var(--s-2);
}

.bars .bar {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  align-items: center;
  gap: var(--s-2);
}

/* Fixed-height track keeps every day label on the same baseline. */
.bars .track {
  height: 76px;
  width: 100%;
  max-width: 26px;
  display: flex;
  align-items: flex-end;
}

.bars .bar i {
  width: 100%;
  border-radius: 6px;
  /* Was --primary, and it was the largest single block of violet in the app:
     six full-height bars on a white card. Spruce is not just a way to break
     that up, it is the rule already written for .meter — every bar here is a
     day already practiced, which is banked progress, and banked is spruce.
     Violet on this chart was claiming "you are here" seven times over. */
  background: var(--success);
  min-height: 4px;
  display: block;
}

.bars .bar--empty i {
  background: var(--surface-sunken);
}

.bars .bar span {
  font-size: 0.6875rem;
  color: var(--text-secondary);
  font-weight: 600;
}

.mastery-row {
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--border);
}

.mastery-row:last-child {
  border-bottom: 0;
}

.mastery-row strong {
  display: block;
  font-size: var(--step-0);
  font-weight: 600;
}

.mastery-row .under {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  margin-top: var(--s-2);
}

.mastery-row small {
  color: var(--text-secondary);
  font-size: var(--step--1);
  min-width: 0;
}

.log-row {
  display: flex;
  gap: var(--s-3);
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--border);
}

.log-row:last-child {
  border-bottom: 0;
}

.log-row .when {
  width: 4.5rem;
  flex: none;
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.log-row strong {
  font-weight: 600;
}

.log-row p {
  color: var(--text-secondary);
  font-size: var(--step--1);
  margin-top: 2px;
}

.tag-line {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-1);
  margin-top: var(--s-2);
}

.tag {
  font-size: 0.75rem;
  padding: 2px 8px;
  border-radius: var(--r-pill);
  background: var(--surface-sunken);
  color: var(--text-secondary);
}

.tag--watch {
  background: var(--caution-wash);
  color: var(--caution-text);
}

.tag--good {
  background: var(--success-wash);
  color: var(--success);
}


/* =========================================================================
   Lucy / settings
   ========================================================================= */

.profile {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  padding: var(--s-4);
}

/* Pushed to the far end of the row, away from the portrait, so the card has
   two controls that cannot be confused for each other: the picture changes the
   picture, and this changes the words. `align-self: start` keeps it level with
   the name rather than floating in the middle of a card whose height is set by
   however long the "about" paragraph runs. */
.profile-edit {
  flex: none;
  align-self: start;
  margin-left: auto;
  width: 40px;
  height: 40px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
}

.profile-edit svg,
.person-row-edit svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Same control, in the person list. Sized to the row rather than to the app's
   48px tap floor for the same reason the install hint's dismiss is: it sits
   beside a primary action — the row itself, which switches person — and a
   full-size target here would be a full-size mis-tap. */
.person-row-edit {
  flex: none;
  width: 36px;
  height: 36px;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
}

@media (hover: hover) {
  .profile-edit:hover,
  .person-row-edit:hover {
    background: var(--surface-sunken);
    color: var(--text-primary);
  }
}

/* The portrait is a button. It has to keep the circle exactly — the image
   inside is already sized and rounded by `.profile img`, so this contributes
   nothing but the hit area and the affordance. */
.profile-photo {
  position: relative;
  flex: none;
  display: block;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  line-height: 0;
  cursor: pointer;
}

/* A picture with no affordance is a picture. This is the smallest mark that
   says "this is yours to change" without a label crowding the card, and it
   sits on the circle's lower right where it cannot cover the dog's face. */
/* The mark that says a picture can be changed.
   ---------------------------------------------------------------------------
   One class, three places: the dog on the profile card, the handler in setup,
   and every face in the person switcher. They were three near-copies that had
   already drifted — different ring, different stroke — and the point of the
   badge is that it means the same thing wherever it appears. Changing a
   picture should be the same gesture whether the picture is of a dog or of a
   person.

   The ring matches whatever the badge sits on, which is not always the same
   surface, so it is a variable the context sets rather than a colour baked in
   here. Size likewise: on a 40px face in a list, 26px of badge is a button
   wearing a hat. */
.edit-badge {
  position: absolute;
  right: -2px;
  bottom: -2px;
  width: var(--edit-badge-size, 26px);
  height: var(--edit-badge-size, 26px);
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--primary);
  border: 2px solid var(--edit-badge-ring, var(--surface));
  color: var(--text-on-dark);
}

.edit-badge svg {
  width: 55%;
  height: 55%;
  fill: none;
  stroke: currentColor;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.avatar-grid {
  list-style: none;
  margin: var(--s-4) 0 0;
  padding: 0;
  display: grid;
  /* Three across on a phone at 34rem and narrower, which puts all ten in
     four rows — short enough that the sheet does not need its own scroll on
     an 812px screen with the keyboard away. */
  grid-template-columns: repeat(3, 1fr);
  gap: var(--s-3);
}

/* A tile is a picture now, and nothing else.
   ---------------------------------------------------------------------------
   This was a flex column holding an image above a caption, and it kept the
   column, the gap, the text colour, the line-height and a `--step--2` font
   size that never existed in the scale — so it resolved to nothing and
   inherited 16px. With the caption gone all of that was reserving room for a
   line of text that is never drawn: 32px per tile, which is 128px of empty
   space down a four-row grid. `grid` with one child collapses to the child. */
.avatar-choice {
  display: grid;
  width: 100%;
  padding: var(--s-2);
  border: 1.5px solid transparent;
  border-radius: var(--r-lg);
  background: transparent;
  cursor: pointer;
}

/* `height: auto` is load-bearing, not tidiness. The tiles carry width/height
   attributes so the grid reserves the right space before the images arrive,
   and a presentational `height="400"` beats `aspect-ratio` for the used
   height — every tile came out 400px tall, which pushed eight of eleven below
   the fold, and `loading="lazy"` then quite correctly refused to fetch them.
   A sizing bug that presents as a loading bug. */
.avatar-choice img {
  width: 100%;
  height: auto;
  /* `.sheet img` gives pictures 16px above and below, which is right for the
     one the fallback sheet shows in a column of prose and wrong for a tile in
     a grid: it added 32px to every tile, 128px down four rows, and pushed the
     Cancel button under the fold. Worth stating rather than deleting the
     inheritance, because the sheet rule is correct where it was aimed. */
  margin: 0;
  aspect-ratio: 1;
  border-radius: 50%;
  object-fit: cover;
  background: var(--surface-sunken);
}

.avatar-choice--active {
  border-color: var(--primary);
  background: var(--primary-wash);
  color: var(--primary-dark);
  font-weight: 600;
}

@media (hover: hover) {
  .avatar-choice:hover {
    border-color: var(--border);
  }

  .avatar-choice--active:hover {
    border-color: var(--primary);
  }
}

.profile img {
  width: 76px;
  height: 76px;
  border-radius: 50%;
  /* `cover` still earns its place as a guard — it keeps any future portrait
     filling the circle instead of squashing — but it does nothing to the
     current one. The portrait is a square crop of the app icon in a square
     box, so there is nothing left to crop; the framing was done in the file
     (see img/README.md) rather than here, because a circle clips the corners
     and object-position cannot know that. The `object-position: 30% 40%` that
     used to sit here was aimed at the off-center artwork this replaced; left
     in place it would have looked deliberate while meaning nothing. */
  object-fit: cover;
  flex: none;
  background: var(--surface-sunken);
}

.profile h2 {
  font-size: var(--step-2);
}

.profile p {
  color: var(--text-secondary);
  font-size: var(--step--1);
  margin-top: 2px;
}

.cue-row {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-bottom: 1px solid var(--border);
}

.cue-row:last-child {
  border-bottom: 0;
}

.cue-row .situation {
  flex: 1;
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.cue-row input {
  width: 10.5rem;
  min-height: 44px;
  padding: 0 var(--s-3);
  border: 1.5px solid var(--border);
  border-radius: var(--r-sm);
  background: var(--surface);
  font-weight: 600;
  text-align: right;
}

.cue-row input:focus {
  border-color: var(--primary);
}

.setting-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  width: 100%;
  padding: var(--s-4);
  border: 0;
  border-bottom: 1px solid var(--border);
  background: transparent;
  text-align: left;
  cursor: pointer;
  color: inherit;
  font-size: var(--step-0);
  min-height: 60px;
}

.setting-row:last-child {
  border-bottom: 0;
}

.setting-row small {
  display: block;
  color: var(--text-secondary);
  font-size: var(--step--1);
  font-weight: 400;
}

.setting-row .value {
  color: var(--text-secondary);
  font-size: var(--step--1);
  display: inline-flex;
  align-items: center;
}

.setting-row .value svg {
  width: 20px;
  height: 20px;
  stroke: var(--text-secondary);
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* =========================================================================
   Who is practicing
   ========================================================================= */

.person-list {
  list-style: none;
  margin: var(--s-4) 0 0;
  padding: 0;
}

.person-list li {
  display: flex;
  align-items: center;
  gap: var(--s-2);
}

.person-row {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  /* Tall enough to stand beside a 56px portrait without the name floating in
     the middle of a column of air. */
  min-height: 56px;
  padding: var(--s-2) var(--s-3);
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

/* One person to a row, with a line between them.
   ---------------------------------------------------------------------------
   The list read as a heap: four controls per person, no edges, and nothing
   saying where one person ended and the next began except the gap between
   them. A rule between rows is the least ink that answers it. */
.person-list > li {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2);
}

/* Between people, and not under the last one or either side of the tinted
   row, where a line would cut the highlight in half. */
.person-list > li + li:not(.is-active) {
  border-top: 1px solid var(--border);
}

.person-list > li.is-active + li {
  border-top: 0;
}

/* The whole row is the selection, not a pill inside it.
   ---------------------------------------------------------------------------
   The border used to sit on the name button alone, which left the portrait
   outside the thing it was part of and squeezed the name into the space left
   between a tick and a pencil — "Fabiola Hick…" beside a picture of her, with
   room to spare on both sides. Tinting the row says the same thing, includes
   the face, and gives the name back its width. */
.person-list > li.is-active {
  background: var(--primary-wash);
  border-radius: var(--r-lg);
}

.person-row-name {
  flex: 1;
  min-width: 0;
  font-size: var(--step-1);
  font-weight: 600;
  /* A name is free text capped at 32 characters, which overruns this row long
     before it overruns the field. One line, clipped with an ellipsis: the
     avatar beside it carries the identification when the tail is lost. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.person-row-mark svg {
  display: block;
  width: 20px;
  height: 20px;
  fill: none;
  stroke: var(--primary);
  stroke-width: 2.5;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Deliberately quiet, and deliberately not inside the row it removes: nesting
   a button in a button is invalid, and a destructive control sharing a hit
   area with "switch to this person" is a mis-tap waiting to happen. */
/* Holds the remove column open on the active row, which has no remove button.
   Same width as one, so the pencils above and below stay in a line. */
.person-row-spacer {
  flex: none;
  width: var(--tap);
}

.person-row-remove {
  flex: none;
  width: var(--tap);
  height: var(--tap);
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
}

.person-row-remove svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

@media (hover: hover) {
  .person-row:hover {
    border-color: var(--border);
  }

  .person-row--active:hover {
    border-color: var(--primary);
  }

  .person-row-remove:hover {
    background: var(--surface-sunken);
    color: var(--caution-text);
  }
}

.person-add {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  margin-top: var(--s-2);
  padding: var(--s-3);
  border: 0;
  border-radius: var(--r-lg);
  background: transparent;
  color: var(--primary);
  font: inherit;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}

.person-add svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.person-add-form {
  display: flex;
  gap: var(--s-2);
  margin-top: var(--s-2);
}

.person-add-form input {
  flex: 1;
  min-width: 0;
}

.setting-row.danger {
  color: var(--caution-text);
  font-weight: 600;
}

/* =========================================================================
   Empty states, misc
   ========================================================================= */

.empty {
  text-align: center;
  padding: var(--s-6) var(--s-4);
  color: var(--text-secondary);
}

.empty h3 {
  color: var(--text-primary);
  font-size: var(--step-1);
  margin-bottom: var(--s-2);
}

/* Sits above the tab bar and inside the content column, so on a laptop it does
   not float off in the empty field beside the app. `--page-max` minus the
   gutters the screens already use, then centered the same way they are.

   Not a modal and not a toast: it does not steal focus, it does not time out,
   and the app underneath stays entirely usable with it up. The only two things
   it can do are install and go away. */
.install-hint {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(var(--nav-h) + var(--s-3));
  z-index: 45;
  width: calc(100vw - var(--s-5));
  max-width: calc(var(--page-max) - var(--s-5));
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-3) var(--s-3) var(--s-4);
  background: var(--text-primary);
  color: var(--text-on-dark);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-lift);
  /* Its own keyframe rather than the shared `rise`, because this element is
     centered with `translateX(-50%)` and `rise` animates `transform` — it would
     replace the centering with `translateY(8px)` for the length of the
     animation and snap the card back into place at the end. */
  animation: install-hint-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both;
}

@keyframes install-hint-in {
  from {
    transform: translate(-50%, 10px);
    opacity: 0;
  }
  to {
    transform: translate(-50%, 0);
    opacity: 1;
  }
}

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

.install-hint-body {
  flex: 1;
  font-size: var(--step--1);
  line-height: 1.4;
}

.install-hint-body strong {
  font-weight: 650;
  /* The two tap targets it is naming. They are the content of the sentence,
     not emphasis inside it, so they read as the labels they are quoting. */
  white-space: nowrap;
}

.install-hint-actions {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  flex: none;
}

/* On the dark surface the quiet button's own colors disappear. */
.install-hint-go {
  background: var(--text-on-dark);
  color: var(--text-primary);
  border: 0;
}

/* 32px, under the app's own 48px floor, and deliberately so: it is the
   dismiss-by-accident control of the pair, sitting next to an Install button
   people do want to hit. The ::after gives it a full-size hit area without a
   full-size circle. */
.install-hint-close {
  position: relative;
  width: 32px;
  height: 32px;
  flex: none;
  display: grid;
  place-items: center;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: inherit;
  cursor: pointer;
}

.install-hint-close::after {
  content: '';
  position: absolute;
  inset: -8px;
}

.install-hint-close svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.install-hint-close:hover {
  background: rgba(255, 255, 255, 0.14);
}

.offline-note {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(var(--nav-h) + var(--s-3));
  z-index: 40;
  background: var(--text-primary);
  color: var(--text-on-dark);
  font-size: var(--step--1);
  padding: var(--s-2) var(--s-4);
  border-radius: var(--r-pill);
  box-shadow: var(--shadow-lift);
}

.toast {
  position: fixed;
  /* Centred by layout, not by transform.
     -------------------------------------------------------------------------
     It used to be `left: 50%` pulled back with `translateX(-50%)`, which makes
     the centring and the entrance animation fight over one property. The
     animation's first frame sets `translateY` and drops the X with it, so
     every toast slid in from half a screen right of where it belongs; and the
     reduced-motion rule sets `transform: none`, which leaves it stuck there
     for anybody who has that switched on. Worse than off-centre: pinned at
     the 50% mark it only had half the screen to grow into, so a sentence that
     fits on two lines was wrapping onto four.

     Nothing here is a transform now, so the animation can have that property
     to itself and reduced motion can take it away without moving anything. */
  left: 0;
  right: 0;
  margin-inline: auto;
  width: fit-content;
  bottom: calc(var(--nav-h) + var(--s-5));
  z-index: 70;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  max-width: calc(100vw - var(--s-6));
  /* No transition on `bottom`; see the lift rule below. */
  background: var(--text-primary);
  color: var(--text-on-dark);
  padding: var(--s-3) var(--s-3) var(--s-3) var(--s-4);
  border-radius: var(--r-pill);
  box-shadow: var(--shadow-lift);
  font-weight: 600;
  animation: sheet-in 0.2s ease;
}

/* Out of the install hint's way.
   ---------------------------------------------------------------------------
   Both live in the same strip above the tab bar, and both are dark, so a
   toast raised while the hint was up landed directly on top of it — one dark
   pill on another dark slab, which is the layout that looked wrong. The toast
   wins on z-index, so nothing was hidden; it just read as a mess.

   A sibling selector rather than a flag in JavaScript, because the hint
   already answers to `hidden` and the toast is always appended after it. When
   the hint is dismissed the next toast simply comes back down. */
.install-hint:not([hidden]) ~ .toast {
  bottom: calc(var(--nav-h) + var(--s-5) + 68px);
}

.toast-action {
  /* The one control in the app that is on screen for seven seconds and then
     gone. It was 36px, under the 48 everything else here answers to, on the
     button somebody is reaching for in a hurry to undo something. */
  min-height: var(--tap);
  padding: 0 var(--s-4);
  border: 0;
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, 0.16);
  color: var(--text-on-dark);
  font-weight: 700;
  cursor: pointer;
  flex: none;
}

.toast-action:hover {
  background: rgba(255, 255, 255, 0.28);
}

/* --- destructive + dialog -------------------------------------------------- */

.btn--danger {
  background: var(--caution-text);
}

.btn--danger:hover {
  background: var(--caution-text-dark);
}

.sheet--dialog {
  border-radius: var(--r-lg) var(--r-lg) 0 0;
}

.sheet--dialog h2 {
  font-size: var(--step-2);
}

.sheet--dialog > p {
  color: var(--text-secondary);
  margin: var(--s-2) 0 var(--s-4);
}

@media (min-width: 34rem) {
  .sheet-backdrop {
    align-items: center;
  }

  .sheet--dialog {
    border-radius: var(--r-lg);
    margin-bottom: var(--s-6);
  }
}

/* --- storage failure banner ------------------------------------------------ */

/* =========================================================================
   Splash
   The last thing the OS shows and the first thing the app shows, holding the
   same mark in the same place on the same background. Geometry is duplicated
   in scripts/make-splash.mjs, which bakes it into the iOS launch images: 88%
   of viewport width, centered at 50% height. Change one, change both.
   ========================================================================= */

.splash {
  position: fixed;
  inset: 0;
  z-index: 100;
  /* The artwork's own field, not the app's paper. The splash art is now a
     portrait illustration that runs the full width of the screen rather than a
     badge sitting on the app's field, so the field has to be the color the
     illustration already has at its edges — otherwise the art reads as a
     lighter rectangle on paper. Keep in step with FIELD in
     scripts/make-splash.mjs. The fade to the app is where paper arrives.

     The splash has since been redrawn through the pilot pipeline, flat and
     cool like the rest of the set, so nothing is baked into it any more. It
     predates that: the earlier version was given a gentle copy of the app's
     art grade in its own pixels (saturation 0.85, 7% violet blend) because CSS
     cannot reach the OS launch images and the treatment had to live in the
     file. Both the grade and that workaround are gone.

     Measured, not eyeballed: the mean of a six-pixel ring around the source
     PNG. Re-measure it whenever the art changes — this value drifted a little
     with each new illustration, and a mismatch shows up as a faint rectangle
     exactly where the two are supposed to be indistinguishable. */
  --splash-field: #f0ebe4;
  background: var(--splash-field);
  transition: opacity 420ms ease-in;

  /* One number drives the art and everything positioned off it. 88vw is the
     phone geometry the launch images bake in, so on a phone this resolves to
     exactly what the generator used. The caps only bite on a desktop window:
     50vh keeps the 2:3 art inside half the height, and 460px stops it filling
     a large screen.

     It was 100vw and centered at 45%, which put the top of the art at y=115 on
     a 375×812 phone and left the wordmark nowhere to go — the artwork's own
     cream painted straight over it. Backing off to 88% and centering properly
     opens a real band above the art for the title and still leaves room for
     the meta below. */
  --badge: min(88vw, 50vh, 460px);

  /* The art is 2:3, so its height is a derived number rather than a second
     free one. Everything stacked below the art measures off this, not off
     --badge, which is only the width.

     This said 4 / 3 for longer than it should have. The illustration was 3:4
     when the number was written and became 2:3 the first time the trainer was
     redrawn, but the ratio here did not follow — and because .splash-mark sets
     width and height to 100% with no object-fit, `fill` stretched the art 12.5%
     wider than it was drawn. The launch images never had the bug, since
     make-splash.mjs derives height from the source, so the OS image and the
     app's first paint disagreed by exactly that 12.5% across the handoff the
     whole rest of this file is built to make seamless. */
  --badge-h: calc(var(--badge) * 3 / 2);

  /* Where the art sits. Named because the meta is stacked underneath it and
     the two have to move together — and because
     scripts/make-splash.mjs bakes the same two numbers into the iOS launch
     images. Change one, change the other, and regenerate. */
  --badge-y: 50%;

}

/* A slow violet vignette at the very edges of the field. It gives the flat
   lavender some depth once the composition has assembled, and it breathes in
   over more than a second precisely so it cannot exist on the first frame —
   the launch image is a flat field, so the first frame must be too. Kept
   faint enough that where it grazes the art's corners it reads as light,
   not as a treatment applied to the picture. */
.splash::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    140% 110% at 50% 42%,
    transparent 58%,
    rgba(85, 48, 122, 0.07) 100%
  );
  animation: splash-vignette-in 1400ms 500ms ease-out both;
}

@keyframes splash-vignette-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* Position only. This is the geometry the iOS launch image baked in, so it
   never moves — the scale animation lives on the img inside it. If the two
   shared a transform the mark would start somewhere the launch image did not
   put it, and the handoff would jump. */
.splash-slot {
  position: absolute;
  top: var(--badge-y);
  left: 50%;
  width: var(--badge);
  height: var(--badge-h);
  transform: translate(-50%, -50%);
}

/* The wordmark, above the art.
   ------------------------------------------------------------------------
   The head is the band the art actually leaves free: from the top of the
   screen down to the art's top edge, which is the same sum the slot does.
   The previous head sat at a flat `top: 7%` and hoped — and because the slot
   comes after it in the DOM and the mark is an opaque cream JPG, whatever
   fell below the art's top edge was quietly painted over. On most screens
   that was the tagline, which is why it never seemed to render at all.
   Deriving the band from the art's own geometry means the text cannot land
   under the art on any screen, and the z-index makes the layering explicit
   rather than an accident of source order. */
.splash-head {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  /* The band is the space the *drawing* leaves free, not the space the badge
     leaves free. The badge's own top edge was the old boundary, but the JPG
     carries blank cream for its first quarter — measured from the file, the
     first painted row is at 23.6% of its height — so a band stopping at the badge
     edge centered the wordmark against empty field and read too high. The band
     now borrows 20% of the badge's height from that margin, keeping a cushion
     above the painted edge. The 0.2 is a constant of this artwork, like the
     geometry make-splash.mjs bakes: replace splash-mark.jpg and it must be
     re-measured. The nose-to-nose art starts within a quarter-point of where
     the one before it did, so the constant stayed put through that swap. */
  height: calc(var(--badge-y) - var(--badge-h) / 2 + var(--badge-h) * 0.2);
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* The band starts at y=0, which on a notched phone is under the notch.
     Center in what remains, not in the glass. */
  padding-top: env(safe-area-inset-top, 0px);
  text-align: center;
}

/* Dead-centering the wordmark in the band put it visually high: the band ends
   at the art's *baked* top edge, but the drawing itself starts lower — the
   badge carries its own transparent margin — so a centered title had more
   empty field below it than above. These spacers sink the cluster to 60% of
   the band instead of 50%. Proportional rather than a fixed nudge on purpose:
   on a short screen the band shrinks toward nothing and a fixed offset would
   march the title into the art, while 60% of a small band is still safely
   inside it. */
.splash-head::before {
  content: '';
  flex: 1.5;
}

.splash-head::after {
  content: '';
  flex: 1;
}

/* Stacked and set big rather than one centered line at heading size: two short
   words side by side at a readable size is a header, and this is a title card.
   The size is capped by height as well as width because the band it lives in
   is a height: the art's top edge is at worst 16.7vh from the top (the 50vh
   cap on --badge guarantees it), so the type scales with whichever axis runs
   out first instead of betting on portrait. */
.splash-title {
  margin: 0;
  font-family: var(--font-display);
  /* The line box is tighter than the glyphs need, which is what pulls the two
     words into one mass rather than two lines with air between them.

     0.46, arrived at by eye in two steps: 0.78 less 20px, then less another
     10, measured against the 93.75px this renders at on a 375-wide phone.
     Kept as a ratio rather than a pixel value so it holds across the clamp —
     a fixed 43px would be right on that one screen and wrong on every other.

     Leading this tight is only survivable because each word is masked with
     0.35em of padding on the block axis: the line box no longer contains the
     glyphs at all, and without that padding the "y" loses its tail and the
     "L" loses its head. The two numbers move together. */
  line-height: 0.46;
  /* The tagline used to sit under this and take a share of the head band.
     With it gone the name is the only thing in that space and can have all of
     it: 25vw against the 19vw it was, which is 94px on a 375-wide phone. Two
     lines at 0.78 leading is ~146px of a 274px band, so it fills the space
     without crowding the art below. The cap stops a desktop window turning
     it into a billboard. */
  font-size: clamp(3rem, min(25vw, 12vh), 6.5rem);
  font-weight: 600;
  letter-spacing: -0.03em;
  /* Reads on the artwork's lavender at 8.5:1 (verified by
     scripts/check-contrast.mjs, which resolves --splash-field from this
     file). The wordmark is the collar's own violet against the field the
     art was drawn on. */
  color: var(--primary-dark);
}

/* Every rule here uses the child combinator, and it is not decoration. Each
   word is now two nested spans, so a bare `.splash-title span:first-child`
   matches the outer span of "Lucy" *and* the inner span of both words — the
   inner one is the first child of its own parent. Weight and color would
   leak onto the wrong element. `>` is what keeps the two levels apart. */
.splash-title > span {
  display: block;
  /* The mask. Padding opens the clip box past the line box and the negative
     margin takes the space straight back, so nothing moves.

     Four sides, and no longer the same number on each, because the four
     distances are not the same. These are measured rather than judged: the
     first two passes at this were eyeballed, both times left the "y" docked,
     and "roughly 0.35em each way" turned out to be under on one side and
     nearly three times over on another.

     Where the numbers come from, at any font-size, since all of it is in em:

       line-height          0.46em      (the whole point — glyphs exceed it)
       font asc / desc      0.981 / 0.256em
       half-leading         (0.46 - 1.237) / 2  =  -0.389em
       baseline sits        -0.389 + 0.981 - 0.46  =  0.133em BELOW the line box
       "y" ink descent      0.244em below the baseline

     so the tail of the "y" ends 0.133 + 0.244 = 0.377em below the line box,
     against the 0.35em it was given. Two and a half pixels at the size this
     renders on a phone, which is exactly enough to read as a typo in the
     logo. 0.45em now — 0.073em of slack, and checked at wght 600, 640 and 700
     where the descender moves by less than a thousandth of an em.

     The top needs only 0.107em (cap height 0.70em, less the 0.133em the
     baseline is already carried down, less the 0.46em line box). It keeps
     0.35em regardless: it is the axis the reveal slides along, nothing is
     clipped there, and tightening it would only shorten a mask that has no
     reason to be short.

     Horizontally the mask is a full-width block with centered text, so the
     padding does not push the glyphs anywhere — it is the negative margin
     that widens the clip box, and the two move the edge together at roughly
     1em of padding to 1em of edge. Measured by sweeping the value: 0.14em
     clipped the right of the "s" in "Learns" by 5.25px, 0.2em cleared it by
     0.37px, 0.3em clears it by 9.75px. 0.3em.

     (An earlier note here put that first overhang at 0.3px. That came out of
     a canvas measurement left at the CSS `font-weight` of 600 while the text
     actually renders at `wght` 640 — the real cut is wider, and the error was
     entirely in the measuring, not the font.) */
  overflow: hidden;
  padding: 0.35em 0.3em 0.45em;
  margin: -0.35em -0.3em -0.45em;
}

.splash-title > span > span {
  display: block;
}

/* The subset of Fraunces shipped here carries the full opsz 9–144 and wght
   100–900 axes (no SOFT/WONK — they were subset away). `opsz` is pinned to
   144, the poster cut: highest stroke contrast, tightest fit, the version of
   Fraunces that was drawn to be a masthead rather than a paragraph. The
   `font-weight` properties stay as fallbacks for anything that cannot do
   variations; where variations work, these settings win. */

/* One size, one ink, and now one weight.
   ---------------------------------------------------------------------------
   This lockup has lost a distinction at every pass and been better for each.
   "Lucy" was smaller, then it was a warmer color, then it was lighter at 460
   against 640 — each time on the theory that the two words needed separating
   or they would read as one wall. Reading as one thing is the point: it is a
   name, not a sentence with a subject and a verb. Both words are now 640.

   And one tracking. Both words inherit the -0.03em set on the title, with no
   per-word override at all — "Lucy" used to carry +0.015em against the other
   word's inherited value, which is a difference small enough to look like a
   rendering accident rather than a decision.

   A justified lockup was tried here and taken out: tracking "Lucy" to the
   measured width of "Learns" (0.758 of it at wght 640, so 0.211em across
   three gaps) squares the pair into a rectangle, but it does it by setting
   one word loose and the other tight, and matching tracking was the ask. It
   is recorded here because the arithmetic is the hard part and the next
   person to want it should not have to measure the font again. */
.splash-title > span > span {
  font-variation-settings:
    'opsz' 144,
    'wght' 640;
}

/* Each word rises up into its own mask, the second a beat behind the first.
   The word begins wholly below the clip edge and travels its own height, so
   what you see is a name being revealed rather than a name being moved — the
   letters appear to be uncovered by an edge that never itself moves.
   Deliberately not the overshoot bounce this replaced: a spring drew the eye
   to the motion, and the interesting thing here should be the name.

   115%, not 100%: the padding above has made the mask taller than the line
   box, and the extra covers that margin so the first frame is genuinely
   empty.

   Transform and opacity only — this plays while the app is booting, which is
   the worst moment on the device to ask for layout or paint work. The hidden
   state lives in the keyframe's `from`, not the base rule, so `animation:
   none` under reduced motion leaves the name sitting visible with nothing to
   clean up. */
.splash-title > span > span {
  animation: splash-word-rise 700ms var(--ease-soft) both;
}

.splash-title > span:first-child > span {
  animation-delay: 140ms;
}

.splash-title > span:last-child > span {
  animation-delay: 290ms;
}

@keyframes splash-word-rise {
  from {
    opacity: 0;
    transform: translateY(115%);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* The art is deliberately the one thing that does not animate in. It is the
   only element the iOS launch image already has on screen, so it has to be at
   exactly its final size and position on the first frame — anything else, a
   fade or a scale, cross-dissolves one copy of the same picture into another
   and reads as a flicker at the handoff. The liveliness comes from the three
   things that were never in the launch image: the name, the line, and the dog.
   `splash-breathe` on the mark still starts at scale(1) for the same reason. */

.splash-mark {
  display: block;
  width: 100%;
  height: 100%;
  /* No mask. The art was a square badge and the launch images cut it to its
     inscribed circle, so this had to match at the edge. Both are rectangles
     now: the field is the artwork's own color at its edges, so there is no
     edge to hide, and a circle would take the dog's ears and paws with it. */
  /* Starts at exactly 1 — the size the launch image is already showing — and
     drifts in from there, so the first frame is identical and the motion
     begins after the handoff rather than through it. */
  animation: splash-breathe 2600ms cubic-bezier(0.16, 0.6, 0.3, 1) both;
}

@keyframes splash-breathe {
  from {
    transform: scale(1);
  }
  to {
    transform: scale(1.07);
  }
}

/* Leaving: the mark falls back and away as the app comes forward. */
/* Leaving: the art settles back as the app comes forward.
   ---------------------------------------------------------------------------
   The transform lives on the slot, not on the mark, and that is the whole
   fix. It used to sit on the mark itself as `animation: none` plus
   `transform: scale(0.93)` — which meant the breathe animation was torn off
   and a new transform applied in the same tick, so there was no before-value
   for the transition to start from. Measured: the art went from scale(1.07)
   to scale(0.93) in a single frame. Thirteen percent of a full-width
   illustration, instantly, which is exactly what "it jumps and the image
   resizes oddly" looks like.

   Two elements, two transforms, no argument: the mark keeps breathing on its
   own axis and the slot carries the departure. Because nothing else animates
   the slot, its transition has a real starting value and actually runs.

   The scale is gentler than the old one too — 0.98, not 0.93. The art is a
   full-bleed illustration now rather than a small badge, so the same
   percentage moves several times as many pixels. */
.splash--done .splash-slot {
  transform: translate(-50%, -50%) scale(0.98);
  transition: transform 420ms cubic-bezier(0.4, 0, 0.9, 0.4);
}

.splash-meta {
  position: absolute;
  left: 0;
  right: 0;
  /* Directly under the badge rather than pinned to the bottom of the screen,
     so the mark and the build it is showing read as one centered stack
     instead of two things at two heights. */
  top: calc(var(--badge-y) + var(--badge-h) / 2 + var(--s-5));
  margin: 0;
  text-align: center;
  animation: splash-meta-in 520ms 720ms ease-out both;
}

@keyframes splash-meta-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.splash--done .splash-meta {
  animation: none;
}

/* The version is part of the composition now: the wordmark's serif at colophon
   size, in the wordmark's ink, so the stack under the art reads as the mark's
   imprint line rather than debug output that wandered on. */
.splash-version {
  margin: 0;
  /* Sans, where the rest of the composition is serif. The version and its
     date are the one piece of this screen that is not the brand talking —
     they are a build stamp, and setting them in the display face dressed
     machine output up as part of the wordmark. The app's own text face says
     "this is information" and lets the name above keep the serif to
     itself. */
  font-family: var(--font-body);
  /* Full-strength ink, matching the date below. The two lines were 80% and
     62% of --primary-dark, which set up a hierarchy between a build number
     and its date — two halves of one colophon that have no reason to rank
     against each other. */
  color: var(--primary-dark);
  font-size: var(--step-0);
  /* Same weight as the date under it. They were 600 against 500, which put a
     hierarchy inside a two-line colophon that has no hierarchy in it. */
  font-weight: 500;
  letter-spacing: 0.01em;
  /* Tabular figures so the line cannot reflow if the number changes width. */
  font-variant-numeric: tabular-nums;
}

/* Same face as the version above it, a step down in size and lighter in ink.
   It was 11px wide-tracked uppercase at 0.75 opacity, which is four separate
   things all working against legibility at once on a warm field — and it is a
   date, the one kind of string that is hardest to read in caps because the
   month abbreviation loses its shape. Sentence case, normal tracking, and the
   hierarchy carried by size and color alone. */
.splash-date {
  margin: var(--s-1) 0 0;
  font-family: var(--font-body);
  color: var(--primary-dark);
  font-size: var(--step-0);
  font-weight: 500;
  letter-spacing: 0.01em;
  font-variant-numeric: tabular-nums;
}

.splash--done {
  opacity: 0;
  pointer-events: none;
}

/* Leaving: the head lifts slightly against the fade — the same beat as the
   mark falling back — so the departure has the same up/back parallax the
   arrival had, instead of one flat dissolve. */
.splash--done .splash-head {
  transform: translateY(-10px);
  transition: transform 420ms cubic-bezier(0.4, 0, 0.9, 0.4);
}

/* Reduced motion keeps the splash and drops every part of it that moves.
   Every hidden state lives in a keyframe's `from`, never in a base rule, so
   `animation: none` is also the visibility fix: with no animation to fill
   backwards from, everything simply sits at its final, visible state. */
@media (prefers-reduced-motion: reduce) {
  .splash,
  .splash--done .splash-mark,
  .splash--done .splash-head {
    transition: none;
  }

  .splash::after,
  .splash-mark,
  .splash-meta,
  .splash-title span,
  .splash--done .splash-mark,
  .splash--done .splash-head {
    animation: none;
    transform: none;
  }
}

.storage-warning {
  position: sticky;
  top: 0;
  z-index: 45;
  margin: 0;
  padding: var(--s-3) var(--s-4) calc(var(--s-3));
  padding-top: calc(var(--s-3) + env(safe-area-inset-top));
  background: var(--caution-text);
  color: var(--text-on-dark);
  font-size: var(--step--1);
  line-height: 1.4;
}

.storage-warning strong {
  display: block;
  font-weight: 700;
}

/* --- comparison rows ------------------------------------------------------- */

.compare-row {
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
  padding: var(--s-3) 0;
  border-bottom: 1px solid var(--border);
}

.compare-row:last-child {
  border-bottom: 0;
}

.compare-label {
  flex: 1;
  min-width: 0;
  font-weight: 600;
}

.compare-value b {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-variant-numeric: tabular-nums;
  font-variation-settings: 'SOFT' 20, 'WONK' 0, 'opsz' 24;
}

.compare-value small {
  color: var(--text-secondary);
  font-size: var(--step--1);
  margin-left: 4px;
}

.compare-delta {
  flex: none;
  width: 8.5rem;
  text-align: right;
  font-size: var(--step--1);
  color: var(--text-secondary);
}

/* Direction is in the words too, so this is reinforcement, not the signal. */
.compare-delta--better {
  color: var(--success);
}

.compare-delta--worse {
  color: var(--caution-text);
}

/* --- log rows -------------------------------------------------------------- */

.log-body {
  flex: 1;
  min-width: 0;
}

.log-remove {
  flex: none;
  align-self: flex-start;
  width: 44px;
  height: 44px;
}

.log-remove svg {
  width: 18px;
  height: 18px;
}

/* --- taller options for the one-tap result screen -------------------------- */

.options--tall .option {
  min-height: 76px;
  padding: var(--s-4);
}

.options--tall .option strong {
  font-size: var(--step-1);
}

/* =========================================================================
   Welcome / first run
   ========================================================================= */

.welcome-inner {
  padding-top: var(--s-4);
}

.welcome-figure {
  border-radius: var(--r-lg);
}

.welcome-figure img {
  aspect-ratio: 5 / 4;
}

.welcome-title {
  font-family: var(--font-display);
  font-size: var(--step-3);
  line-height: 1.15;
  font-weight: 600;
  font-variation-settings: 'SOFT' 40, 'WONK' 1, 'opsz' 44;
}

.welcome-body {
  font-size: var(--step-1);
  color: var(--text-secondary);
}

.welcome-note {
  padding: var(--s-3) var(--s-4);
  background: var(--primary-wash);
  border-radius: var(--r-md);
  color: var(--primary-dark);
  font-size: var(--step--1);
  font-weight: 600;
}

.welcome-dots {
  display: inline-flex;
  gap: 6px;
  padding-left: var(--s-2);
}

.welcome-dots i {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--border-strong);
  transition: background 0.2s ease, width 0.2s ease;
}

.welcome-dots i.on {
  width: 20px;
  border-radius: var(--r-pill);
  background: var(--primary);
}

.welcome-finish {
  padding-top: var(--s-6);
}

.welcome-finish h1 {
  font-size: var(--step-3);
  margin-top: var(--s-2);
}

.welcome-finish .eyebrow {
  font-size: var(--step--1);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--primary-dark);
}

.choice {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  width: 100%;
  margin-top: var(--s-3);
  padding: var(--s-4);
  text-align: left;
  background: var(--surface);
  border: 1.5px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  cursor: pointer;
  color: inherit;
}

.choice:hover {
  border-color: var(--primary);
  background: var(--primary-wash);
}

/* The recommended path, marked rather than merely listed first. Order alone
   is a weak signal on a screen with exactly two options — both are visible at
   once, so there is no "first" in the way a long list has one. */
.choice--recommended {
  border-color: var(--primary);
}

.choice-tag {
  display: block;
  margin-bottom: 3px;
  color: var(--primary);
  font-size: var(--step--2);
  font-style: normal;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* The example-dog note on the dog setup screen. Quieter than a section-note,
   because it is context rather than help for the field above it. */
.setup-aside {
  margin-top: var(--s-5);
  padding-top: var(--s-4);
  border-top: 1px solid var(--border);
  font-size: var(--step--1);
  line-height: 1.5;
  color: var(--text-secondary);
}

.choice strong {
  display: block;
  font-size: var(--step-1);
  font-weight: 650;
}

.choice small {
  display: block;
  margin-top: 4px;
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.choice svg {
  width: 22px;
  height: 22px;
  flex: none;
  stroke: var(--primary);
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* First-run nudge: quieter than a real trend, since nothing has happened yet. */
/* "Jump back in": a compact fast lane under the hero for the practiced. */
.quick-row {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  width: 100%;
  margin-top: var(--s-3);
  padding: var(--s-3) var(--s-4);
  text-align: left;
  background: var(--surface);
  border: 1.5px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  cursor: pointer;
  color: inherit;
}

.quick-row:hover {
  border-color: var(--primary);
  background: var(--primary-wash);
}

.quick-row:active {
  transform: scale(0.98);
}

.quick-row span:first-child {
  flex: 1;
  min-width: 0;
}

.quick-row strong {
  display: block;
  font-weight: 650;
}

.quick-row small {
  display: block;
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.quick-row svg {
  width: 20px;
  height: 20px;
  flex: none;
  stroke: var(--primary);
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.insight--start p {
  font-size: var(--step-1);
  color: var(--text-secondary);
}

/* --- trainer escalation + partner slot ------------------------------------- */

.sheet-trainer {
  margin-top: var(--s-4);
  padding: var(--s-3) var(--s-4);
  background: var(--primary-wash);
  border-radius: var(--r-md);
  color: var(--primary-dark);
  font-size: var(--step--1);
  font-weight: 550;
}

.sheet-trainer a {
  color: var(--primary-dark);
  font-weight: 700;
}

.welcome-partner {
  color: var(--text-secondary);
  font-size: var(--step--1);
  text-align: center;
}

/* --- lesson report ---------------------------------------------------------- */

.report-range {
  margin-top: var(--s-2);
}

/* =========================================================================
   Program arc
   The four activities as one connected route with a finish line, so the
   household can see the shape of the thing they signed up for.
   ========================================================================= */

.program-head {
  margin-bottom: var(--s-4);
}

.program-head h1,
.program-head h2 {
  margin: var(--s-1) 0 var(--s-3);
  font-size: var(--step-3);
  line-height: 1.15;
}

.program-tally {
  display: flex;
  align-items: baseline;
  gap: var(--s-2);
  margin-bottom: var(--s-2);
}

.program-tally b {
  font-family: var(--font-display);
  font-size: var(--step-4);
  font-weight: 600;
  line-height: 1;
  color: var(--primary);
  font-variant-numeric: tabular-nums;
}

.program-tally span {
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.program-head .meter {
  height: 10px;
}

/* The bar animates from wherever it was to wherever it is. Cheap, but it is
   the only place the app shows movement toward the far goal. */
.program-head .meter > span,
.program-strip .meter > span {
  transition: width 700ms cubic-bezier(0.22, 1, 0.36, 1);
}

.program-pitch {
  margin: var(--s-3) 0 0;
  color: var(--text-secondary);
  font-size: var(--step--1);
}

/* Before the first session the pitch is the whole header, so it gets room. */
.program-head--fresh .program-pitch {
  margin-top: 0;
  font-size: var(--step-0);
  color: var(--text-primary);
}

/* The setup node is a smaller mark than an activity: it is the trailhead, not
   a stage. Same tick, less weight. */
.stage--setup .stage-rail {
  --node: 26px;
}

.stage--setup .stage-node {
  margin-top: 4px;
}

.stage--setup .stage-node svg {
  width: 14px;
}

.stage--setup .stage-top strong {
  font-size: var(--step-1);
}

/* The smaller node plus its 4px nudge. The setup row has no word under its
   node, so it does not need the label clearance the others do. */
.stage--setup .stage-rail::before {
  top: calc(var(--node) + 4px);
}

/* --- the stage list ------------------------------------------------------- */

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

.stage > a,
.stage > div {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-3) var(--s-4) 0;
  color: inherit;
  text-decoration: none;
}

/* --- the rail: numbered nodes joined by a line ---------------------------- */

/* One number for the node, so the rail that joins them starts exactly where
   they end. These were separate literals: changing a node size left the line
   floating or overlapping, with nothing to say why. */
.stage-rail {
  --node: 34px;
  position: relative;
  flex: 0 0 auto;
  /* Wide enough for the longest label under the node ("PLACE", "GREET") at
     the size below. The node itself stays 34px and centered. */
  width: 46px;
  align-self: stretch;
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* The word under the mark. Same job and same voice as .route-label, one size
   down because the rail is a column rather than a row. */
.stage-mark {
  margin-top: 3px;
  font-size: 0.5625rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--text-secondary);
  text-align: center;
  white-space: nowrap;
}

.stage--complete .stage-mark {
  color: var(--success);
}

/* Same single-next rule as the route: only the stage the program actually
   points at is named in the accent. */
.stage--next .stage-mark {
  color: var(--primary);
}

/* The setup and outcome rows are not activities and have no short name. */
.stage--setup .stage-mark,
.stage--outcome .stage-mark {
  display: none;
}

/* The connector runs from this node down to the next one. The last item in
   the list has no one to connect to, so it does not draw one. */
.stage-rail::before {
  content: '';
  position: absolute;
  /* Clears the node *and* the word under it. Starting at the node alone drew
     the line straight through "PLACE". */
  top: calc(var(--node) + 17px);
  bottom: calc(var(--s-4) * -1 - var(--s-3));
  left: 50%;
  width: 2px;
  margin-left: -1px;
  background: var(--border-strong);
}

.stage:last-child .stage-rail::before {
  display: none;
}

/* A finished stage darkens the line below it, so the completed run reads as
   one continuous stretch of road rather than four unrelated ticks. */
.stage--complete .stage-rail::before {
  background: var(--success);
}

.stage-node {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: var(--node);
  height: var(--node);
  border-radius: 50%;
  background: var(--background);
  border: 2px solid var(--border-strong);
  color: var(--text-secondary);
  font-size: var(--step--1);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

.stage-node svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.stage--complete .stage-node {
  background: var(--success);
  border-color: var(--success);
  color: var(--text-on-dark);
}

.stage--active .stage-node,
.stage--open.stage--focus .stage-node {
  background: var(--surface);
  border-color: var(--primary);
  color: var(--primary);
}

/* Where you are right now. A ring, not a color swap, so it survives being
   printed and being colorblind. */
.stage--here .stage-node {
  box-shadow: 0 0 0 4px var(--primary-wash);
}

.stage--ahead .stage-node,
.stage--soon .stage-node {
  border-style: dashed;
}

/* Not in the app yet. The station is muted but the text stays at full
   contrast — the last time a stage state was signaled by fading its copy it
   measured 2.96:1, and this one applies to three of the four. */
.stage--soon .stage-node {
  background: var(--surface-sunken);
  color: var(--text-secondary);
}

.stage--soon .stage-thumb {
  filter: saturate(0.25);
  opacity: 0.65;
}

.stage--soon .stage-rail::before {
  background: var(--border);
}

/* --- the stage body ------------------------------------------------------- */

.stage-body {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  padding-top: var(--s-1);
}

.stage-top {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--s-3);
}

.stage-top strong {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  line-height: 1.15;
}

.stage-state {
  font-size: 0.6875rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--text-secondary);
}

.stage--complete .stage-state {
  color: var(--success);
}

.stage--active .stage-state,
.stage--open.stage--focus .stage-state {
  color: var(--primary);
}

.stage-purpose {
  color: var(--text-secondary);
  font-size: var(--step--1);
  line-height: 1.45;
}

/* A later stage is signaled by the dashed node and its "Comes later" label,
   not by fading the text. The fade this replaces measured 2.96:1 — well under
   AA — and on a fresh install it applied to three of the four activities, so
   most of the map was unreadable in the lit entryway this app is used in. */

.stage-meter {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin-top: var(--s-1);
}

.stage-count {
  font-size: var(--step--1);
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
}

.stage-note {
  font-size: var(--step--1);
  color: var(--text-secondary);
}

.stage--active .stage-note {
  color: var(--primary-dark);
  font-weight: 600;
}

.stage-thumb {
  flex: 0 0 auto;
  width: 56px;
  height: 56px;
  border-radius: var(--r-sm);
  object-fit: cover;
  background: var(--surface-sunken);
}

.stage--ahead .stage-thumb {
  filter: saturate(0.35);
  opacity: 0.7;
}

/* --- level pips ----------------------------------------------------------- */

.pips-row {
  display: inline-flex;
  gap: 3px;
  align-items: center;
}

.pips-row i {
  width: 14px;
  height: 8px;
  border-radius: 2px;
  background: var(--surface-sunken);
  border: 1px solid var(--border-strong);
}

/* Cleared is spruce everywhere — the same claim the rail's finished nodes
   and the level list's ticks make. This said "teal everywhere" when cleared
   borrowed the accent; once the accent went collar-violet, cleared pips were
   indistinguishable from the selected level's disc, and the palette read as
   one violet idea instead of two. Banked is green; violet is for where you
   are — which is exactly what the live pip below says. */
.pips-row i.on {
  background: var(--success);
  border-color: var(--success);
}

/* The level being worked right now: a half-filled pip, so "in progress" is a
   shape and not a hue — and violet, because this pip is the one place in the
   row that means "you are here" rather than "behind you". */
.pips-row i.live {
  border-color: var(--primary);
  background: linear-gradient(90deg, var(--primary) 50%, transparent 50%);
}

/* --- the finish line ------------------------------------------------------ */

.stage--outcome > div {
  padding-bottom: var(--s-3);
}

.stage--outcome .stage-node {
  border-style: dashed;
  border-color: var(--reward);
  background: var(--reward-wash);
  color: var(--reward-text);
}

.stage--outcome .stage-node svg {
  stroke-width: 2;
}

.stage--outcome .stage-top strong {
  font-size: var(--step-2);
}

.stage--outcome-won .stage-node {
  border-style: solid;
  background: var(--reward);
  color: var(--reward-text);
}

/* --- Today's compact strip ------------------------------------------------ */

.program-strip {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-4);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  color: inherit;
  text-decoration: none;
  transition:
    border-color 140ms ease,
    box-shadow 140ms ease;
}

.program-strip-body {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.program-strip-top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-2);
}

.program-strip-top strong {
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
}

.program-strip-count {
  font-size: var(--step--1);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  color: var(--primary);
}

.program-strip small {
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.program-strip svg {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  transition: transform 140ms ease;
  fill: none;
  stroke: var(--primary);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* "Activity N of M", the first line inside the strip. It replaces a separate
   36px link that used to sit between the hero metadata and "Start session" —
   a secondary target in the thumb zone directly above the primary button, and
   a second route to a destination the strip already reached. */
/* The accelerator, folded into the hero. A quiet second option under the
   primary button rather than a card of its own further down the page. */
.hero-skip {
  display: block;
  width: 100%;
  min-height: var(--tap);
  margin-top: var(--s-2);
  padding: var(--s-2);
  border: 0;
  background: none;
  color: var(--primary);
  font-family: var(--font);
  font-size: var(--step--1);
  font-weight: 600;
  cursor: pointer;
}

@media (hover: hover) {
  .hero-skip:hover {
    color: var(--primary-dark);
    text-decoration: underline;
    text-underline-offset: 3px;
  }
}

.hero-skip:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

.program-strip-where {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  font-size: 0.6875rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-weight: 700;
  /* Was --primary; same demotion as the hero eyebrow. "Activity 1 of 4" is
     a caption, not a control. */
  color: var(--text-secondary);
}

/* The program as the same row of marks the welcome route draws, one size
   down. It used to be four proportional pill segments here and four numbered
   circles there, for the same four activities — so nothing the household
   learned on the welcome carried over to the screen they actually live on.
   The shared markup is stageNodes(); only the scale changes. */
.program-track {
  display: block;
  margin: var(--s-2) 0 var(--s-1);
}

/* The compact scale. Everything inside is driven off --route-node, so this is
   the only number that changes between the welcome and Today. */
.route-line.route-line--compact {
  --route-node: 26px;
}

.route-line.route-line--compact .route-node svg {
  width: 15px;
  height: 15px;
}

/* Every mark says its own name. This row went wordless for a build because
   spelling the labels out cost a two-line "CALM HELLO" and pushed Today's
   primary button under the tab bar — but the fix for that was to make the
   last label one word like the rest, not to drop all five. A mark nobody can
   name is decoration.

   One line, and never more: these are single words by design, and a wrap here
   would push the button back down. */
.route-line.route-line--compact .route-label {
  font-size: 0.625rem;
  letter-spacing: 0.04em;
  min-height: 0;
  white-space: nowrap;
}

/* The mark and its word are one object, so they sit closer to each other than
   the row does to anything else. */
.route-line.route-line--compact .route-stop {
  gap: 3px;
}

/* Discs, not controls.
   ------------------------------------------------------------------------
   The whole strip is one link to the program map, but five *outlined* circles
   with icons in them read as five buttons — so tapping "Place" looked like it
   would open Place and instead opened a menu. The outline is what does it: a
   bordered circle is a button, a filled one is a token.

   So the circles stay and the strokes go. Each mark sits on a soft disc of the
   page color, which reads as a seat rather than a target, and where the
   household is now is carried by fill instead of by an outline.

   Applied at both scales. Scoping this to the compact strip left the welcome
   route drawing outlined rings for the same five marks Today drew as discs —
   which is the split this whole row was introduced to close. */
.route-line .route-node {
  border: 0;
  background: var(--surface-sunken);
  box-shadow: none;
}

/* Where they are. Tinted rather than ringed. */
.route-line .route-stop--next .route-node {
  background: var(--primary-wash);
  color: var(--primary);
}

/* Finished is the one stop in this row that is *filled* rather than washed —
   see the rule further down that sets its background. So it is also the one
   stop whose glyph must not be the state color, and there is deliberately no
   `color` here.

   There used to be one, `color: var(--success)`, left over from before the
   fill existed. It out-specified the white set alongside the fill (three
   classes to two) and painted a #427459 check on a #427459 disc — the mark was
   rendered, correctly, and invisible. It read as a stop with no icon at all
   while its neighbors all had one.

   The lesson is the specificity, not the color: state rules for this row live
   in two blocks, and the `.route-line`-scoped one silently wins. Set a color
   here only alongside a background here. */

/* The finish keeps its gold, as a wash rather than a border. */
.route-line .route-stop--end .route-node {
  background: var(--reward-wash);
  color: var(--reward-text);
}

/* The road behind the compact row is shorter than the one on the welcome,
   because the row itself is narrower and an 8% inset left it stranded well
   inside the first and last marks. */
.route-line.route-line--compact::before {
  left: 11%;
  right: 11%;
  /* The welcome draws its road once, on arrival. Today re-renders this strip
     on every navigation, and re-running the draw each time made a static
     summary flicker. */
  animation: none;
}

.route-line.route-line--compact .route-stop {
  animation: none;
}

/* A caret on the bottom edge, lined up with the active segment, aimed at the
   card below. This is the whole connection between "where you are in the
   program" and "here is today's session" — without it they were two unrelated
   cards that happened to be stacked. */
.program-strip--points::after {
  content: '';
  position: absolute;
  top: 100%;
  left: var(--caret-x, 50%);
  width: 14px;
  height: 14px;
  transform: translate(-50%, -50%) rotate(45deg);
  background: var(--surface);
  border-right: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  border-bottom-right-radius: 3px;
  transition: border-color 140ms ease;
}

/* The strip is the first thing on Today, so it does not need the full section
   gap above it pushing it down the screen. The bottom margin is not optional:
   `.section` spaces with margin-top only, and `.hero` has none of its own, so
   before this the two cards sat flush at a measured 0px apart with the caret
   nowhere to go. */
.section--tight {
  margin-top: var(--s-3);
  margin-bottom: var(--s-5);
}

/* Hover: lift it, do not tint it.
   ------------------------------------------------------------------------
   This card used to wash blue on hover, which broke in two ways. The caret is
   a separate rotated square painted `--surface`, so it stayed white while the
   card went blue and the speech bubble's tail visibly detached from it. And
   the row of marks inside sits on discs of the page color, which stop
   reading as seats once the surface behind them changes hue.

   Both of those are the tint's fault rather than the caret's, so the tint
   goes. Raising the card off the page says "this is one object and it is
   yours to press" without repainting anything inside it, the caret comes
   along because it is drawn from the same two tokens as the card, and the
   underlined title matches how every other link-shaped card here answers a
   pointer. */
@media (hover: hover) {
  .program-strip:hover {
    border-color: var(--border-strong);
    box-shadow: var(--shadow-lift);
  }

  .program-strip--points:hover::after {
    border-color: var(--border-strong);
  }

  .program-strip:hover .program-strip-top strong {
    text-decoration: underline;
    text-underline-offset: 3px;
  }

  /* The arrow leans the way the card goes. Direct child only: the five marks
     in the row are svgs inside this card too, and they stay put. */
  .program-strip:hover > svg {
    transform: translateX(2px);
  }

  .stage > a:hover .stage-top strong {
    text-decoration: underline;
    text-underline-offset: 3px;
  }
}

.stage > a:focus-visible,
.program-strip:focus-visible,
.detail-crumb:focus-visible,
.next-up:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
  border-radius: var(--r-md);
}

/* --- the activity screen's place in the sequence -------------------------- */

/* 48px tall like every other target in the app, and pulled left by its own
   padding so the label still lines up with the heading beneath it. */
.detail-crumb {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  min-height: var(--tap);
  margin: calc(var(--s-3) * -1) 0 0 calc(var(--s-2) * -1);
  padding: 0 var(--s-2);
  color: var(--primary);
  font-size: var(--step--1);
  font-weight: 600;
  text-decoration: none;
}

/* The "book" glyph, not the back chevron: this link goes forward to a screen
   you may never have opened. A left-pointing arrow next to it was a lie. */
.detail-crumb svg {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.stage-strip {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-3);
  margin-top: var(--s-4);
  padding: var(--s-3);
  border-radius: var(--r-md);
  background: var(--surface-sunken);
}

.stage-strip-label {
  font-size: var(--step--1);
  font-weight: 700;
}

/* Library cards carry the same pips as the map, so a card and a stage never
   tell different stories about the same activity. */
.card-progress {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s-1) var(--s-2);
  margin-top: var(--s-3);
  padding-top: var(--s-3);
  border-top: 1px solid var(--border);
}


/* Running an activity out of order is allowed and sometimes correct. Say what
   the usual order is, then get out of the way. */
.stage-warn {
  display: flex;
  align-items: flex-start;
  gap: var(--s-2);
  margin: var(--s-3) 0 0;
  padding: var(--s-3);
  border-radius: var(--r-md);
  background: var(--caution-wash);
  color: var(--caution-text);
  font-size: var(--step--1);
  line-height: 1.45;
}

.stage-warn svg {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  margin-top: 1px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* A cleared level keeps its number slot but swaps in a tick, so the level list
   doubles as a checklist without adding a column.
   Spruce, and no longer the accent: when cleared wore the accent it was the
   same disc as the selected row's, and "behind you" and "practicing this"
   looked like one state. The tick still is not the mastery badge's claim — a
   level can be ticked and still say "Improving"; done and quality are
   different facts and now different families. The selected row's disc stays
   violet and carries an arrow, not a tick — see detail.js. */
.level-row--cleared .n {
  background: var(--success);
  border-color: var(--success);
  color: var(--text-on-dark);
}

/* Selection outranks cleared on the disc — same specificity, so source order
   decides, and this rule exists to sit after the one above. The arrow rides
   violet even when the practiced row happens to be cleared. */
.level-row[aria-pressed='true'] .n {
  background: var(--primary);
  border-color: var(--primary);
  color: var(--text-on-dark);
}

.level-row .n svg {
  width: 16px;
  height: 16px;
  fill: none;
  stroke: currentColor;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* --- what to do after this one -------------------------------------------- */

.next-up {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-4);
  border-radius: var(--r-lg);
  border: 1px solid var(--border);
  background: var(--surface);
  box-shadow: var(--shadow-card);
  color: inherit;
  text-decoration: none;
}

.next-up > div {
  flex: 1;
  min-width: 0;
}

.next-up .eyebrow {
  margin: 0;
  font-size: 0.6875rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--primary);
}

.next-up h2 {
  margin: var(--s-1) 0 var(--s-2);
  font-size: var(--step-2);
}

.next-up p {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--step--1);
  line-height: 1.45;
}

.next-up-meta {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin-top: var(--s-3);
}

.next-up > svg {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  fill: none;
  stroke: var(--primary);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.next-up--outcome {
  display: block;
  border-color: var(--reward);
  background: var(--reward-wash);
}

.next-up--outcome .eyebrow {
  color: var(--reward-text);
}

.next-up--outcome p {
  color: var(--text-primary);
}

/* --- the program band on the done screen ---------------------------------- */

.program-band {
  margin-top: var(--s-5);
  padding: var(--s-4);
  border-radius: var(--r-md);
  background: var(--surface-sunken);
}

.program-band-top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-3);
  margin-bottom: var(--s-2);
}

.program-band-top span {
  font-size: var(--step--1);
  font-weight: 700;
}

.program-band-top b {
  font-family: var(--font-display);
  font-size: var(--step-2);
  color: var(--primary);
  font-variant-numeric: tabular-nums;
}

.program-band .meter {
  background: var(--border);
}

.program-band .meter > span {
  transition: width 900ms cubic-bezier(0.22, 1, 0.36, 1);
}

.program-band p {
  margin: var(--s-2) 0 0;
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.program-band--moved .program-band-top b {
  animation: band-pop 500ms 700ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
}

@keyframes band-pop {
  0% {
    transform: scale(1);
  }
  45% {
    transform: scale(1.22);
  }
  100% {
    transform: scale(1);
  }
}

/* --- finishing an activity, and finishing the program --------------------- */

.finale {
  position: relative;
  margin-top: var(--s-4);
  padding: var(--s-5) var(--s-4) var(--s-4);
  border-radius: var(--r-lg);
  border: 1px solid var(--reward);
  background: var(--reward-wash);
  text-align: center;
}

.finale > svg {
  width: 32px;
  height: 32px;
  fill: none;
  stroke: var(--reward-text);
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.finale .eyebrow {
  margin: var(--s-2) 0 0;
  font-size: 0.6875rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--reward-text);
}

.finale h2 {
  margin: var(--s-1) 0 var(--s-2);
  font-size: var(--step-3);
  line-height: 1.15;
}

.finale p {
  margin: 0;
  color: var(--text-primary);
  font-size: var(--step-0);
  line-height: 1.5;
}

.finale--program {
  border-width: 2px;
}

@media (prefers-reduced-motion: no-preference) {
  .finale {
    animation: finale-in 520ms 250ms cubic-bezier(0.22, 1, 0.36, 1) both;
  }
}

@keyframes finale-in {
  from {
    opacity: 0;
    transform: translateY(10px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .program-head .meter > span,
  .program-strip .meter > span,
  .program-band .meter > span {
    transition: none;
  }

  /* The lift keeps its color and shadow change — those are state, not
     motion — but the arrow stops traveling. */
  .program-strip:hover > svg {
    transform: none;
  }

  .program-band--moved .program-band-top b {
    animation: none;
  }
}

/* Handed across a table or printed for the file: the report is a document. */
@media print {
  .tabbar,
  .report-range,
  .report-actions,
  .storage-warning,
  .offline-note {
    display: none !important;
  }

  body::before {
    display: none;
  }

  .screen {
    padding-bottom: 0;
  }
}

/* =========================================================================
   The route
   Shown once, on the last welcome panel: the whole program as a road with the
   finish line on the end. Same four activities as the map and the track, from
   the same data, drawn empty because nothing has been practiced yet.
   ========================================================================= */

.route {
  padding: var(--s-5) 0 var(--s-6);
}

/* The road is drawn at the vertical center of the stations, so the node size
   lives here rather than being halved by hand in two places. */
.route-line {
  --route-node: 36px;
  position: relative;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* The road itself, behind the stations. Drawn left to right once, so the eye
   travels the route in the direction it will be walked. */
.route-line::before {
  content: '';
  position: absolute;
  top: calc(var(--route-node) / 2 - 1px);
  left: 8%;
  right: 8%;
  height: 2px;
  background: var(--border-strong);
  transform-origin: left center;
  animation: route-draw 620ms cubic-bezier(0.22, 1, 0.36, 1) both;
}

@keyframes route-draw {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

/* flex-grow comes from the markup, one unit per level, so an activity's width
   means the same thing here as it does on the Today track. The end marker is
   not an activity and does not scale with anything. */
.route-stop {
  position: relative;
  z-index: 1;
  flex-grow: 1;
  flex-shrink: 1;
  flex-basis: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-2);
  /* Each station lands after the road reaches it. --i is its place in line. */
  animation: route-stop-in 460ms cubic-bezier(0.34, 1.4, 0.64, 1) both;
  animation-delay: calc(260ms + var(--i) * 110ms);
}

@keyframes route-stop-in {
  from {
    opacity: 0;
    transform: translateY(9px) scale(0.86);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.route-node {
  display: grid;
  place-items: center;
  width: var(--route-node);
  height: var(--route-node);
  border-radius: 50%;
  background: var(--surface);
  border: 2px solid var(--border-strong);
  color: var(--text-secondary);
  font-size: var(--step--1);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* The mark itself. Sized off the node so both scales stay in proportion. */
.route-node svg {
  width: 19px;
  height: 19px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Live: the single thing to do next.
   ------------------------------------------------------------------------
   This was pinned to :first-child, then to state — and state was wrong the
   moment more than one activity was unparked, because "in progress" and
   "ready to start" both qualified and the row lit two rings pointing in two
   directions. One class, set on one stop by stageNodes(). */
.route-stop--next .route-node {
  border-color: var(--primary);
  color: var(--primary);
  /* Inset, not outset. A 4px halo sat outside the disc and read as a second
     circle around the mark — the row looked like five tokens and one target,
     which is the button-versus-token confusion the discs above were introduced
     to settle. A hairline on the edge of the disc itself does the same job:
     the tint still carries "you are here" and the ring only sharpens it. */
  box-shadow: inset 0 0 0 1px var(--primary-dark);
}

/* Finished. Filled, because a cleared activity is the one thing here that is
   genuinely done, and it carries the check rather than its own mark. */
.route-stop--complete .route-node {
  border-color: var(--success);
  background: var(--success);
  color: var(--surface);
}

/* Not in the app yet. Dashed reads as "not built" rather than "not done", and
   the label keeps full contrast — muting it by fading has failed AA three
   times in this app already. */
.route-stop--soon .route-node {
  border-style: dashed;
  color: var(--text-secondary);
}

.route-label {
  font-size: 0.625rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--text-secondary);
  text-align: center;
  line-height: 1.3;
  /* The column centers its children, which sizes them to their content and
     lets a long label sail past the station's width instead of wrapping.
     "Calm hello" reached within 2px of the screen edge that way. */
  max-width: 100%;
  /* Every label is a single word now, so nothing wraps and nothing needs a
     second line held open for it. This used to reserve 2.6em for the sake of
     "Calm hello", which left an empty line under all five. */
  white-space: nowrap;
}

.route-stop--next .route-label {
  color: var(--primary);
}

.route-stop--complete .route-label {
  color: var(--success);
}

/* The finish. Gold, like the outcome node on the map and the card that lands
   when an activity is finished, so arriving somewhere always looks the same.
   One column like the rest: the stops used to grow by their level count, which
   made the same activity a different width on every screen it appeared on. */
.route-stop--end {
  flex-grow: 1;
}

.route-stop--end .route-node {
  border-style: solid;
  border-color: var(--reward);
  background: var(--reward-wash);
  color: var(--reward-text);
}

.route-stop--end .route-node svg {
  width: 18px;
  height: 18px;
}

/* The one filled mark in the set.
   ------------------------------------------------------------------------
   Every icon here is line-work, so the shared rules hand each svg a 2px
   stroke and no fill. The goal star is the exception in both directions: it
   is filled because it is the prize at the end of a row of things still to
   do, and it drops the stroke because a 2px outline on a shape with five
   points thickens them until the notches between them close up.

   Written to out-specify the per-context svg rules — `.route-stop--end
   .route-node svg` is two classes deep — rather than relying on the svg's
   own fill attribute, which any of them would overwrite. */
.route-node svg.icon--solid,
.stage-node svg.icon--solid,
svg.icon--solid {
  fill: currentColor;
  stroke: none;
}

/* Gold, like the node above it. The width cap this used to carry was there to
   break "Calm hello" after its first word; the label is one word now. */
.route-stop--end .route-label {
  color: var(--reward-text);
}

/* The staggered arrival is the whole effect, so reduced motion drops it
   entirely rather than shortening it. The route is still there to read. */
@media (prefers-reduced-motion: reduce) {
  .route-line::before,
  .route-stop {
    animation: none;
    transform: none;
    opacity: 1;
  }
}

/* =========================================================================
   Mastery ladder
   Four rungs, filled to where this level has actually reached. Same segmented
   vocabulary as the program track, one scale down, so "how far through the
   program" and "how solid is this level" look like relatives.
   ========================================================================= */

.ladder {
  display: flex;
  gap: 5px;
  align-items: flex-end;
}

.rung {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 5px;
  min-width: 0;
}

.rung-bar {
  display: block;
  height: 6px;
  border-radius: var(--r-pill);
  background: var(--surface-sunken);
  box-shadow: inset 0 0 0 1px var(--border);
}

.rung-label {
  font-size: 0.625rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--text-secondary);
  text-align: center;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.rung--reached .rung-bar {
  /* Reached is banked, and banked is spruce; the rung you are on keeps its
     violet label and extra height below, so position stays the accent's job. */
  background: var(--success);
  box-shadow: none;
}

/* Where this level actually stands. Taller and named in the accent, so the
   rung you are on is the one the eye lands on and the ones above it read as
   the thing still to climb. */
.rung--here .rung-bar {
  height: 10px;
}

.rung--here .rung-label {
  color: var(--primary);
}

/* Reliable is the top of the ladder and the hardest thing in the app to earn,
   so arriving there is gold rather than more spruce. */
.ladder .rung:last-child.rung--here .rung-bar {
  background: var(--reward);
}

.ladder .rung:last-child.rung--here .rung-label {
  color: var(--reward-text);
}

/* A rung gained this session grows into place, so the climb is visible rather
   than simply already true by the time it is looked at. */
@media (prefers-reduced-motion: no-preference) {
  .ladder--climbed .rung--gained .rung-bar {
    animation: rung-gain 460ms cubic-bezier(0.34, 1.4, 0.64, 1) both;
    animation-delay: calc(160ms + var(--i) * 90ms);
    transform-origin: left center;
  }
}

@keyframes rung-gain {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

/* =========================================================================
   Level cleared
   The biggest thing earnable inside an activity, which until now landed as a
   clause in a sentence on the done screen.
   ========================================================================= */

.cleared {
  position: relative;
  margin-top: var(--s-5);
  padding: var(--s-5) var(--s-4) var(--s-4);
  border-radius: var(--r-lg);
  border: 1px solid var(--primary-wash);
  background: var(--primary-wash);
  text-align: center;
}

.cleared-seal {
  position: relative;
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  margin: 0 auto;
  border-radius: 50%;
  background: var(--primary);
  color: var(--text-on-dark);
}

.cleared-seal svg {
  width: 28px;
  height: 28px;
  fill: none;
  stroke: currentColor;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* One ring, once. It reads as the stamp landing rather than as a thing still
   loading, which a repeating pulse would. */
.cleared-seal::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid var(--primary);
}

@media (prefers-reduced-motion: no-preference) {
  .cleared-seal {
    animation: seal-in 520ms cubic-bezier(0.34, 1.5, 0.64, 1) both;
  }

  .cleared-seal::after {
    animation: seal-ring 620ms 220ms ease-out both;
  }
}

@keyframes seal-in {
  from {
    opacity: 0;
    transform: scale(0.5);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@keyframes seal-ring {
  from {
    opacity: 0.55;
    transform: scale(1);
  }
  to {
    opacity: 0;
    transform: scale(1.75);
  }
}

.cleared h2 {
  margin: var(--s-3) 0 var(--s-1);
  font-size: var(--step-2);
}

.cleared p {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--step--1);
}

.cleared .pips-row {
  margin-top: var(--s-4);
  justify-content: center;
}

.cleared-pips {
  display: flex;
  justify-content: center;
  margin-top: var(--s-4);
}

/* The mastery move, when a session changes which rung a level stands on. It
   replaces a chip that stated the new rung without ever showing the ladder it
   belonged to. */
.milestone-climb {
  padding: var(--s-4);
  border-radius: var(--r-md);
  background: var(--surface-sunken);
}

.milestone-climb p {
  margin: 0 0 var(--s-4);
  font-size: var(--step--1);
  text-align: center;
}

.milestone-climb strong {
  font-weight: 700;
}

/* --- named but not built --------------------------------------------------
   Same card geometry as a real activity so the library reads as one list, but
   flat and unlinked. It is a <div>, not an <a>: a card that looks tappable and
   is not is worse than no card at all. */

.activity-card--planned {
  background: transparent;
  border-style: dashed;
  box-shadow: none;
  cursor: default;
  align-items: flex-start;
}

/* The same 84px square a live card uses, so the two kinds of card line up down
   the list — but held back. Muted and softened, because a full-strength picture
   on an unavailable card competes with the four a household can actually open,
   and the point of the row is that these come later. The dashed border and the
   "Not written yet" tag say it in words; this says it at a glance. */
.planned-thumb {
  width: 84px;
  height: 84px;
  flex: none;
  border-radius: var(--r-md);
  object-fit: cover;
  background: var(--surface-sunken);
  filter: saturate(0.55) contrast(0.94);
  opacity: 0.72;
}

.planned-tag {
  font-size: 0.6875rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--text-secondary);
}

/* Escapes the two-line clamp that .activity-card puts on its blurb. This is a
   sentence, not a summary, and it was being cut mid-word. */
.planned-note {
  display: block;
  margin: var(--s-2) 0 0;
  color: var(--text-secondary);
  font-size: var(--step--1);
  font-style: italic;
  overflow: visible;
}

/* --- the two ways to answer a door you are not ready for ------------------ */

.branch + .branch {
  margin-top: var(--s-5);
  padding-top: var(--s-4);
  border-top: 1px solid var(--border);
}

.branch h3 {
  margin: 0 0 var(--s-2);
  font-size: var(--step-0);
}

.branch ol {
  margin: 0;
  padding-left: 1.15em;
  color: var(--text-secondary);
  font-size: var(--step--1);
  line-height: 1.5;
}

.branch li + li {
  margin-top: var(--s-2);
}

.goal-empty {
  margin: 0;
  padding: var(--s-4);
  border: 1px dashed var(--border-strong);
  border-radius: var(--r-lg);
  color: var(--text-secondary);
  font-size: var(--step--1);
}

/* --- handler avatar -------------------------------------------------------
   Initials, not a silhouette. Nothing in the app uploads a photo, so a generic
   person icon would be a placeholder for something that never arrives, and
   initials read as deliberate where a gray head reads as unfinished. */

.avatar {
  flex: 0 0 auto;
  display: grid;
  place-items: center;
  width: var(--tap);
  height: var(--tap);
  border-radius: 50%;
  background: var(--primary);
  color: var(--text-on-dark);
  font-size: var(--step--1);
  font-weight: 700;
  letter-spacing: 0.02em;
  text-decoration: none;
  /* It is a <button> on Today now, so the UA's border, padding and font have
     to be cleared or the circle renders as a gray rounded rectangle with the
     initials in the system face. */
  border: 0;
  font-family: inherit;
  cursor: pointer;
  /* Optical centering: uppercase initials have no descenders, so they sit low
     in the box by a hair without this. */
  padding: 1px 0 0;
}

/* In a list, where it labels a row rather than being the control. */
.avatar--sm {
  width: 34px;
  height: 34px;
  font-size: var(--step--2);
  cursor: inherit;
}

@media (hover: hover) {
  .avatar:hover {
    background: var(--primary-dark);
  }
}

.avatar:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 3px;
}

/* --- reduced motion, the rest of it ---------------------------------------
   The blocks above cover the pieces added alongside their own features. This
   sweeps up everything older that was never guarded: the done screen's whole
   entrance choreography, the sheet and toast slides, and the transforms that
   move things rather than recolor them.

   Color and background transitions stay. Reduced motion asks for less
   movement, not for state changes to stop being legible — a button that
   changes color instantly on press still reads as pressed, whereas a button
   with no feedback at all reads as broken. */

@media (prefers-reduced-motion: reduce) {
  .sheet,
  .toast,
  .recommend .verdict-art,
  .recommend h1,
  .recommend > p,
  .recommend + .stat-row {
    animation: none;
    opacity: 1;
    transform: none;
  }

  /* Transforms only. `background 0.15s` on the same shorthand is feedback and
     is deliberately kept. */
  .btn,
  .welcome-dots i {
    transition-property: background, color, border-color;
  }

  /* These report a value by moving. Landing on it instantly is still correct;
     sliding to it is the part reduced motion does not want. */
  .player-top .progress-track > span,
  .tally-meter > span,
  details.disclosure summary::after {
    transition: none;
  }
}

/* --- library marks --------------------------------------------------------
   The goal heading carries the same kind of mark the route does, at heading
   scale. Inline-flex so the mark sits on the heading's own line and wraps with
   it rather than hanging in the margin. */
.goal-title {
  display: flex;
  align-items: center;
  gap: var(--s-3);
}

.goal-mark {
  display: grid;
  place-items: center;
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  /* Denim, like the activity marks it sits above. A goal heading names what
     a group of activities is for — identity, not something you can tap — and
     the mark was violet only because violet was what everything was. */
  color: var(--secondary-text);
}

.goal-mark svg {
  width: 19px;
  height: 19px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* --- what happens next, after a level is earned --------------------------- */
.level-moved {
  text-align: center;
}

.level-moved p {
  color: var(--text-secondary);
}

.level-moved strong {
  color: var(--text-primary);
}

/* The activity the program has actually moved to. Accented because it is the
   instruction — the line above it is a receipt for something already done. */
.level-moved-next {
  margin-top: var(--s-2);
  color: var(--primary-dark);
}

.level-moved-next strong {
  color: var(--primary-dark);
}

.level-moved .btn {
  margin-top: var(--s-4);
}

/* ---------------------------------------------------------------------------
   Screen entrances and celebration moments.

   All of it is gated behind body.nav-fresh, which app.js sets only when a
   render is a navigation — refreshApp() re-painting the same screen after an
   edit drops the class, so nothing here ever replays behind a toggle. The
   player is untouched by design: its phases re-render on every rep tap, and
   its feedback (the tally kicks, the drawn checkmark, the growing band) is
   already tuned per element. This section is for the screens around it.

   Two rules of the house apply throughout. Entrances animate transform and
   opacity only, and every hidden state lives in a keyframe's `from` — never
   in a base rule — so the reduced-motion block at the bottom can kill the
   lot with `animation: none` and leave everything sitting visible in its
   final place with no cleanup. */

/* Sections drift up as a screen arrives, each a beat behind the last. Eight
   pixels and 260ms: felt, not watched. The cap at nth-child(6) keeps a long
   screen from making its tail wait — anything below the fold just arrives
   with the sixth beat. */
.nav-fresh .screen > * {
  animation: screen-rise 260ms var(--ease-soft) both;
}

.nav-fresh .screen > :nth-child(2) { animation-delay: 40ms; }
.nav-fresh .screen > :nth-child(3) { animation-delay: 80ms; }
.nav-fresh .screen > :nth-child(4) { animation-delay: 120ms; }
.nav-fresh .screen > :nth-child(5) { animation-delay: 160ms; }
.nav-fresh .screen > :nth-child(n + 6) { animation-delay: 200ms; }

@keyframes screen-rise {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* The active tab acknowledges the switch: one springy dip-and-recover on the
   icon that just became current. Gated on nav-fresh so an in-place refresh
   (Edit toggled on the log) does not make the tab jump about. */
.nav-fresh .tabbar a[aria-current='page'] svg {
  animation: tab-pop 380ms var(--spring);
}

@keyframes tab-pop {
  0% {
    transform: scale(0.82);
  }
  55% {
    transform: scale(1.12);
  }
  100% {
    transform: none;
  }
}

/* The practice-frequency bars grow out of the baseline, left to right. scaleY
   because the heights are inline styles the animation must not fight; the
   stagger makes seven bars read as one gesture rather than seven events. */
.nav-fresh .bars .bar i {
  animation: bar-grow 420ms var(--ease-soft) both;
  transform-origin: bottom;
}

.nav-fresh .bars .bar:nth-child(2) i { animation-delay: 40ms; }
.nav-fresh .bars .bar:nth-child(3) i { animation-delay: 80ms; }
.nav-fresh .bars .bar:nth-child(4) i { animation-delay: 120ms; }
.nav-fresh .bars .bar:nth-child(5) i { animation-delay: 160ms; }
.nav-fresh .bars .bar:nth-child(6) i { animation-delay: 200ms; }
.nav-fresh .bars .bar:nth-child(7) i { animation-delay: 240ms; }

@keyframes bar-grow {
  from {
    transform: scaleY(0);
  }
  to {
    transform: scaleY(1);
  }
}

/* The insight's spark turns in as the banner settles — one twinkle, late
   enough to be noticed after the headline is read, small enough to be missed
   without loss. */
.nav-fresh .insight svg {
  animation: spark-turn 600ms var(--ease-soft) 260ms both;
}

@keyframes spark-turn {
  from {
    opacity: 0;
    transform: rotate(-30deg) scale(0.6);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* Level cleared: the seal lands with a spring, a ring washes out from behind
   it, and six particles are thrown clear. This is the app's jackpot moment
   and the one place a burst is earned — it renders once, on the done screen,
   when a level is actually behind them.

   The ring is a border scaling up through transparency rather than a filled
   circle, so it reads as a shockwave and never covers the checkmark. */
.cleared-seal {
  animation: seal-land 480ms var(--spring) 100ms both;
}

@keyframes seal-land {
  from {
    opacity: 0;
    transform: scale(0.4);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.cleared {
  overflow: hidden;
}

.cleared-burst {
  position: absolute;
  /* Centered on the seal: the banner's top padding plus half the seal. */
  top: calc(var(--s-5) + 22px);
  left: 50%;
  width: 0;
  height: 0;
  pointer-events: none;
}

.cleared-burst::before {
  content: '';
  position: absolute;
  top: -22px;
  left: -22px;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: 2px solid var(--primary);
  animation: burst-ring 640ms var(--ease-soft) 180ms both;
}

@keyframes burst-ring {
  from {
    opacity: 0.55;
    transform: scale(0.6);
  }
  to {
    opacity: 0;
    transform: scale(2.4);
  }
}

.cleared-burst i {
  position: absolute;
  width: 6px;
  height: 6px;
  border-radius: 2px;
  background: var(--primary);
  /* Each particle flies along its own angle; the rotate spends the throw. */
  animation: burst-fly 700ms var(--ease-soft) 180ms both;
}

.cleared-burst i:nth-child(2) { --fx: 34px;  --fy: -44px; background: var(--success); }
.cleared-burst i:nth-child(3) { --fx: 52px;  --fy: 6px;   background: var(--caution-text); width: 5px; height: 5px; }
.cleared-burst i:nth-child(4) { --fx: -34px; --fy: -44px; background: var(--caution-text); width: 5px; height: 5px; }
.cleared-burst i:nth-child(5) { --fx: -52px; --fy: 6px;   background: var(--success); }
.cleared-burst i:nth-child(6) { --fx: 0;     --fy: -58px; }
.cleared-burst i:nth-child(1) { --fx: 14px;  --fy: -52px; }

@keyframes burst-fly {
  from {
    opacity: 1;
    transform: translate(0, 0) rotate(0deg);
  }
  to {
    opacity: 0;
    transform: translate(var(--fx, 20px), var(--fy, -40px)) rotate(140deg);
  }
}

/* The finale (activity or program complete) rises like a section but with the
   spring, because it is the bigger news and has earned the bigger landing. */
.finale {
  animation: finale-land 480ms var(--spring) 200ms both;
}

@keyframes finale-land {
  from {
    opacity: 0;
    transform: translateY(14px) scale(0.97);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .nav-fresh .screen > *,
  .nav-fresh .tabbar a[aria-current='page'] svg,
  .nav-fresh .bars .bar i,
  .nav-fresh .insight svg,
  .cleared-seal,
  .cleared-burst::before,
  .cleared-burst i,
  .finale {
    animation: none;
  }

  /* The particles only exist to fly; parked, they would sit on the banner as
     six specks. The end state of their animation is opacity 0 — mirror it. */
  .cleared-burst {
    display: none;
  }
}

/* The activity's program mark inside its library card. Tighter than the
   eyebrow on Today's hero, which is the first line of a full-width card and
   can afford the room; here it sits above a title in a compact row.

   Selector is this long on purpose. `.activity-card .body > p` line-clamps
   every paragraph in the card to two lines with `display: -webkit-box`, and
   at (0,2,1) it outranks `.eyebrow--marked` at (0,1,0) — so the mark came
   out stacked above its own label and clamped as a block of body copy. This
   has to beat that rule, not tie with it. */
.activity-card .body > p.activity-card-mark {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  margin-bottom: var(--s-1);
  /* The strip's own label style, copied deliberately: same size, tracking,
     weight and ink as .route-label above. The point of putting the mark on
     the card is that it is recognizably the same token as the one in the
     row — setting it in a different style would make it a third way of
     naming the activity rather than a repeat of the second. */
  font-size: 0.625rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  font-weight: 700;
  color: var(--text-secondary);
}

/* No ring on the library cards. The circle earns itself in the program strip,
   where five marks in a row need to read as tokens on a track, and on Today's
   hero where the mark is the only graphic on the line. Here it sits beside a
   thumbnail inside a bordered card, and a third enclosed shape in that stack
   is one too many. Dropping the fixed 22px box with it lets the glyph sit
   right against its label instead of centered in a circle's worth of air. */
.activity-card .body > p.activity-card-mark {
  gap: var(--s-1);
}

.activity-card .body > p.activity-card-mark .eyebrow-mark {
  width: auto;
  height: auto;
  border: 0;
}

/* --- welcome setup questions ---------------------------------------------
   The two screens that ask who this install is about. They reuse .field for
   the label and input styling; only the spacing and the avatar preview are
   new. */

.setup-field {
  margin-top: var(--s-5);
  text-align: left;
}

.setup-field .section-note {
  margin-top: var(--s-2);
}

/* "optional" as a word beside the label, rather than an asterisk on the two
   fields that are not. Marking the exception is one mark on the screen; the
   convention it replaces puts a mark on everything else. */
.setup-optional {
  margin-left: var(--s-2);
  font-weight: 500;
  font-size: var(--step--1);
  color: var(--text-secondary);
  text-transform: none;
  letter-spacing: 0;
}

/* The name field and its live avatar, side by side. The avatar is the reason
   this question gets a screen of its own. */
.setup-with-avatar {
  display: flex;
  align-items: center;
  gap: var(--s-3);
}

.setup-with-avatar input {
  flex: 1;
  min-width: 0;
}

/* Not a link, unlike the avatar on Today — a preview of a consequence. It is
   aria-hidden, because a screen reader gets the name from the field it is
   derived from and initials read aloud are noise. */
.avatar--preview {
  transition: opacity 160ms ease;
}

/* The placeholder mark is quieter than a set of initials, because it stands
   for an answer not yet given rather than one already there. */
.avatar--preview svg {
  width: 45%;
  height: 45%;
  stroke: currentColor;
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0.55;
}

/* --- tablets ---------------------------------------------------------------
   Last in the file on purpose. These are overrides of rules defined above,
   and at equal specificity the later rule wins — sitting near the top, this
   block lost `.sticky-action` to its own base rule three thousand lines
   later and threw the pinned bar half off the screen.
   Both dimensions have to be large, not just the width. A phone held sideways
   is 932px across and 430px tall, and a width-only breakpoint hands it a
   layout built for a screen with room to breathe — which is how "optimise for
   tablet" quietly ruins landscape on a phone.

   The column widens rather than the whole app stretching. Line length is the
   constraint that does not move: text set the full width of an iPad is
   unreadable no matter how much space is going spare, so the extra width buys
   a larger illustration and calmer margins, not longer lines. Type steps up
   once, because a tablet is held further away than a phone. */
@media (min-width: 45rem) and (min-height: 45rem) {
  :root {
    --page-max: 44rem;
    --step-0: 1.0625rem;
    --step-1: 1.25rem;
    --step-2: 1.5rem;
    --step-3: 2rem;
    --step-4: 2.5rem;
    --nav-h: calc(68px + env(safe-area-inset-bottom));
  }

  /* The step picture and Today's hero are deliberately absent from this block.
     ---------------------------------------------------------------------------
     Both used to be overridden here and both overrides are gone, because a
     bigger screen should show the same picture as a phone rather than a
     different one. Two attempts are worth recording so neither comes back by
     accident:

     `max-height: 46vh` with `cover` on the step figure took nine percent off
     the top of every illustration and nine off the bottom — the top of a head
     and a pair of shoes — on the one screen with the most room for them.
     Capping the width instead kept the whole composition but hung it in a
     frame narrower than the text under it, which reads as a picture that has
     come loose from its own column. The full-width 4:3 is what the phone
     shows, and it is what this shows.

     The cost is the fold, and it is accepted rather than solved: at 44rem a
     4:3 frame is 528px tall and the instruction under it sits low on a laptop.
     A short scroll on a desktop is cheaper than a different crop per device. */

  /* A session on a tablet is usually propped up rather than held, so the
     controls sit where a hand reaches rather than spanning the whole width. */
  .player-foot .btn-row,
  .player-inner {
    margin-inline: auto;
  }

  /* Sheets stop being full-bleed slabs on a screen this size. */
  .sheet {
    border-radius: var(--r-lg);
    margin-bottom: var(--s-6);
  }

  /* The column reads as a column.
     -------------------------------------------------------------------------
     On a phone the content fills the screen, so page and column being the
     same colour is invisible. Give it 400px of margin either side and the
     absence starts to show: the text simply stops, with nothing saying where
     the app ends and the window begins.

     The field steps back rather than the column stepping forward. Turning the
     column white was the obvious move and the wrong one — the cards are
     white, and this app puts almost everything in cards, so a white column
     would have left them outlined against their own colour. A deeper field
     leaves every one of those relationships alone and still gives the column
     an edge. */
  body {
    background: var(--field);
  }

  .screen,
  .player {
    background: var(--background);
  }

  /* Only the sides: the column runs off the top and bottom of the window, and
     a line across either end would draw a box around something unbounded. */
  .screen {
    min-height: 100dvh;
    border-inline: 1px solid var(--border);
  }

  /* The pinned bar keeps to the column too. Fixed to the window, it painted
     its own background the full width and laid a cream band straight across
     the field — cutting the column's edges off at the ankles, right where the
     eye goes to check them. */
  .sticky-action {
    left: 50%;
    right: auto;
    width: min(100%, var(--page-max));
    transform: translateX(-50%);
    border-inline: 1px solid var(--border);
  }
}
