/* ---------- the studio's two faces ----------
   Hosted in app/shared/fonts/, never fetched from a font service: a CDN
   link would call a third party from every visitor's browser on every
   page, which would quietly break the promise this site makes members.
   96 KB total, latin subset. Licensing and the swap rules are in
   app/shared/fonts/README.md — both faces are SIL OFL 1.1 and their
   license files ship beside them.

   font-display: swap means the page paints immediately in the fallback
   and upgrades when the file lands — text is never invisible. */

@font-face {
  font-family: "Anton";
  font-style: normal;
  font-weight: 400; /* Anton ships one weight — it is already the heavy one */
  font-display: swap;
  src: url("./fonts/anton-400.woff2") format("woff2");
}
@font-face {
  font-family: "Epilogue";
  font-style: normal;
  font-weight: 400 700; /* one variable file covers the whole range */
  font-display: swap;
  src: url("./fonts/epilogue-variable.woff2") format("woff2");
}

/* Pulse Studio — shared theme.
   TEAM-OWNED: change only by team agreement (see CLAUDE.md).

   The shared appearance rules this file enforces:
   1. Built-in light and dark backgrounds are white and black. A person may
      choose a custom background/text pair only through theme-boot's
      accessible appearance picker; every page still uses var(--bg).
   2. Every visible feature carries its developer's color, so anyone
      looking at a screen can tell who built what:
        Kerrian  · Product A · blue
        Manny    · Product B · amber
        Dennis   · Product C · green
        Rensley  · Product D · violet
      Put class="product-a|b|c|d" on your page's <body> and style your
      controls with var(--accent) / var(--accent-ink). */

:root {
  color-scheme: light dark;

  /* built-in backgrounds: white (light) / black (dark) */
  --bg: #ffffff;
  --fg: #0a0a0a;
  --line: #d4d4d4;
  --muted: #595959;

  /* TWO VOICES.
     A fitness studio does not sound like a settings screen. Anything that
     should carry energy — a headline, a page's name, a button — speaks in
     the DISPLAY voice: heavy, condensed-feeling, uppercase, tight. Anything
     a person has to actually read speaks in the BODY voice: quiet, roomy,
     sentence case.
     Both faces are SELF-HOSTED from this origin (the two @font-face rules
     at the top of this file). The promise that nothing about a visitor
     leaves their browser holds because no third party is ever contacted —
     no Google Fonts link, no CDN — and NOT, as this comment used to claim,
     because "no font is downloaded from anywhere". Two are, from here. The
     distinction is the whole point: a reader who believed the old sentence
     and added a hosted-font link would think they were preserving a
     property that self-hosting was preserving all along.

     ANTON SHIPS ONE WEIGHT and it is already the heavy one, so asking for
     800 or 900 makes a browser SYNTHESISE bold by smearing the glyphs —
     worst on a condensed face at small sizes. The display-voice rules keep
     their declared weight, because the fallback stack does have real bold
     cuts, and set font-synthesis-weight: none so the real Anton is used
     when Anton is what loaded. */
  --font-display: "Anton", "Helvetica Neue", "Arial Narrow", system-ui, sans-serif;
  /* Never fake a weight Anton does not have; a fallback with a real bold
     cut still uses it. Set wherever --font-display is applied. */
  --font-body: "Epilogue", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

  /* one color per developer */
  --kerrian: #3b82f6;  --kerrian-ink: #ffffff;
  --manny:   #f59e0b;  --manny-ink:   #ffffff;
  --dennis:  #10b981;  --dennis-ink:  #ffffff;
  --rensley: #8b5cf6;  --rensley-ink: #ffffff;

  /* THE READABLE COMPANION, and why a second token instead of a new hex.
   *
   * Every accent above is a fine SURFACE colour and a poor TEXT colour on
   * white. Measured against #ffffff: blue 3.68:1, amber 2.15:1, green
   * 2.54:1, violet 4.23:1. WCAG AA wants 4.5:1 for body-size text, so all
   * four fail as text and two of them fail as a button label on their own
   * fill. The colour law fixes the identity hexes, and rightly — they are
   * how anyone tells at a glance who built a screen — so the fix is not to
   * change them but to give the identity a companion that is safe to read.
   *
   * --accent stays the identity and keeps every decorative job: fills,
   * borders, rules, outlines. Those only need 3:1 as UI boundaries, which
   * all four already clear. --accent-strong is the same hue moved to a
   * lightness that clears 4.5:1, and it is used ONLY where a person has to
   * read something. It falls back to --accent, so a product that has not
   * defined one renders byte-for-byte as before.
   *
   * IT HAS TO BE THEME-AWARE. No single lightness of this hue clears 4.5:1
   * against both white and black: at 64% it is 4.71 on white and 4.46 on
   * black, and every step that helps one hurts the other. So the dark
   * blocks below move it back up and flip the ink.
   *
   * All four define one now, and docs/contrast-baseline.json is empty as a
   * result — it was never meant to hold anything permanently. Each hue was
   * darkened for light and left alone for dark, where it already reads. */
  --rensley-strong: #743df5;   /* 5.63:1 on white, white ink 5.63:1 */
  --kerrian-strong: #0b63f3;   /* 5.13:1 on white, white ink 5.13:1 */
  --manny-strong:   #996206;   /* 5.12:1 on white, white ink 5.12:1 */
  --dennis-strong:  #0b7c56;   /* 5.21:1 on white, white ink 5.21:1 */

  /* STATUS IS NOT A FIFTH DEVELOPER. The colour law spends colour on
   * PEOPLE — four accents, one per builder, and never a fifth. These two
   * are the carve-out the same law already makes on storytold.html, which
   * says it out loud: "Green and red here are status marks only; the four
   * builder colors keep meaning who built what." An alert's severity is
   * not authorship, and the readiness board has shown open gaps in red
   * since before either of these tokens existed.
   *
   * THEY ARE ALSO NOT CHECKED BY THE GATE. check-contrast.mjs measures the
   * four named developer accents and nothing else, by design — so these
   * two were measured by hand against the ground each one is used on, and
   * the ratio is written beside it the way the accents' are. Both are used
   * for a rule and a single word, never for body text on a fill.
   *
   * THE WORD CARRIES THE MEANING, NOT THE COLOUR. Every alert prints
   * "Notice", "Problem" or "Done" beside the rule, so a person who cannot
   * separate these two hues reads the same alert as everyone else. That is
   * why there are two colours and three levels rather than three of each. */
  --status-bad:  #b42318;      /* 6.57:1 on white */
  --status-good: #067647;      /* 5.69:1 on white */
}

/* CUSTOM OWNS ITS WHOLE PALETTE, so it opts out of this block entirely.
   Without the :not, a person on a system set to dark who chose a LIGHT
   custom background still got the dark-tuned accent: --bg and --fg are
   redefined by the custom block below and win, but the accent companions
   are only set here and in :root, so the media query kept them. Measured on
   a warm cream background: 3.02:1, under the 4.5:1 a person needs, with the
   tone correctly detected as light and nothing acting on it. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]):not([data-theme="custom"]) {
    --bg: #000000;
    --fg: #ffffff;
    --line: #333333;
    --muted: #a3a3a3;
    --rensley-strong: #9e77f8;  /* 6.45:1 on black, black ink 6.45:1 */
    --rensley-ink: #0a0a0a;
    /* The other three identity hues already read on black (blue 5.71:1,
       amber 9.78:1, green 8.28:1), so the companion IS the identity here.
       Only Kerrian's ink has to flip: white on that blue is 3.68:1. */
    --kerrian-strong: #3b82f6;  /* 5.71:1 on black, black ink 5.38:1 */
    --kerrian-ink: #0a0a0a;
    --manny-strong:  #f59e0b;  /* 9.78:1 on black, black ink 9.22:1 */
    --manny-ink: #0a0a0a;
    --dennis-strong: #10b981;  /* 8.28:1 on black, black ink 7.80:1 */
    --dennis-ink: #0a0a0a;
    --status-bad:  #ff7b72;     /* 8.33:1 on black */
    --status-good: #3fb950;     /* 8.27:1 on black */
  }
}

:root[data-theme="dark"] {
  --bg: #000000;
  --fg: #ffffff;
  --line: #333333;
  --muted: #a3a3a3;
  --rensley-strong: #9e77f8;  /* 6.45:1 on black, black ink 6.45:1 */
  --rensley-ink: #0a0a0a;
  --kerrian-strong: #3b82f6;  /* 5.71:1 on black, black ink 5.38:1 */
  --kerrian-ink: #0a0a0a;
  --manny-strong:  #f59e0b;  /* 9.78:1 on black, black ink 9.22:1 */
  --manny-ink: #0a0a0a;
  --dennis-strong: #10b981;  /* 8.28:1 on black, black ink 7.80:1 */
    --dennis-ink: #0a0a0a;
  --status-bad:  #ff7b72;     /* 8.33:1 on black */
  --status-good: #3fb950;     /* 8.27:1 on black */
}

:root[data-theme="custom"] {
  color-scheme: normal;
  --bg: var(--custom-bg, #ffffff);
  --fg: var(--custom-fg, #0a0a0a);
  --line: color-mix(in srgb, var(--fg) 24%, var(--bg));
  --muted: color-mix(in srgb, var(--fg) 68%, var(--bg));
}

/* A CUSTOM BACKGROUND STILL HAS A TONE, and the accent has to follow it.
   Every accent companion is tuned to one background; the light-tuned violet
   sits at about 3.7:1 on a dark custom background, under the 4.5:1 a person
   needs to read it. Built-in light and dark redefine the companions per
   theme, and custom cannot — the background is chosen at run time and CSS
   cannot measure it. theme-boot measures it and stamps data-custom-tone, so
   this is the same mechanism driven by a number instead of a media query.
   A developer adding their own companion adds one line here. */
:root[data-theme="custom"][data-custom-tone="dark"] {
  --rensley-strong: #9e77f8;
  --rensley-ink: #0a0a0a;
  --kerrian-strong: #3b82f6;  /* 5.71:1 on black, black ink 5.38:1 */
  --kerrian-ink: #0a0a0a;
  --manny-strong:  #f59e0b;  /* 9.78:1 on black, black ink 9.22:1 */
  --manny-ink: #0a0a0a;
  --dennis-strong: #10b981;  /* 8.28:1 on black, black ink 7.80:1 */
    --dennis-ink: #0a0a0a;
  --status-bad:  #ff7b72;
  --status-good: #3fb950;
}

/* ANTON HAS ONE WEIGHT, so the browser must not invent the others: 800 and
   900 on an already-heavy condensed face come out smeared, worst at small
   sizes. Set once here and INHERITED, because the first attempt listed the
   selectors by hand and missed one — .home .truths strong was still asking
   for 800 and still getting it faked. A hand-kept list of everything that
   speaks in the display voice is a list that goes stale; every face this
   site actually ships or falls back to (Anton, variable Epilogue, and the
   system stacks) has real cuts for the weights it claims, so nothing here
   needs a fake one. */
:root {
  font-synthesis-weight: none;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--fg);
  font: 16px/1.6 var(--font-body);
}

main { padding: 24px; max-width: 960px; margin: 0 auto; }

a { color: var(--accent-strong, var(--accent, var(--fg))); }

/* THE DISPLAY VOICE — every page's name and every headline.
   Uppercase and heavy is the fitness-studio convention (walk into any
   studio and read the wall); tight tracking keeps a heavy face from
   sprawling. Body copy is deliberately NOT included: shouting at someone
   who is trying to read a cancellation policy is how a page loses them. */
h1, h2, h3, .display, .page-head .role, .brand {
  font-family: var(--font-display);
  /* Anton has exactly one weight. Asking for 700/800 makes the browser
     SYNTHESISE a bold — smeared, heavier, wrong. Ask for what exists. */
  font-weight: 400;
  /* The luxury end of this category sets big uppercase TIGHT (Equinox runs
     -0.02em at 64px). Anton is already condensed, so a small negative is
     enough to make it feel set rather than typed. */
  letter-spacing: -0.01em;
  text-transform: uppercase;
  line-height: 1.0;
}

/* The small line above a headline — the studio's own label for what is
   coming. Used on every surface, so it lives here now instead of being
   re-invented per page. */
.eyebrow {
  font-family: var(--font-display);
  font-weight: 400;
  font-size: 0.8rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
}

/* ---------- photography ----------
   Four pictures, four photographers, four rooms. One grade here makes them
   one studio: a little desaturated so no stray colour fights the builder
   accents, contrast lifted so dark rooms stay dark instead of grey.
   Change it once and every picture on the site follows.

   Note on the colour law: these are IMAGES — content in the page — not
   page backgrounds. Every surface underneath is still var(--bg). */
.studio-photo {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: saturate(0.55) contrast(1.12) brightness(0.92);
}
.photo-frame {
  overflow: hidden;
  border-radius: 14px;
  border: 1px solid var(--line);
  background: var(--bg);
}

/* developer scoping — the color law in one class */
.product-a { --accent: var(--kerrian); --accent-ink: var(--kerrian-ink); --accent-strong: var(--kerrian-strong); }
.product-b { --accent: var(--manny);   --accent-ink: var(--manny-ink);   --accent-strong: var(--manny-strong); }
.product-c { --accent: var(--dennis);  --accent-ink: var(--dennis-ink);  --accent-strong: var(--dennis-strong); }
.product-d { --accent: var(--rensley); --accent-ink: var(--rensley-ink); --accent-strong: var(--rensley-strong); }

/* shared building blocks — every control a developer ships uses their accent */
.btn {
  background: var(--accent-strong, var(--accent));
  color: var(--accent-ink);
  border: 0;
  border-radius: 8px;
  padding: 11px 20px;
  font-family: var(--font-display);
  font-size: 1rem;
  font-weight: 400;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  cursor: pointer;
}
.btn:hover { filter: brightness(1.08); }
.btn:focus-visible { outline: 3px solid var(--fg); outline-offset: 2px; }

.owner-badge {
  display: inline-block;
  background: var(--accent-strong, var(--accent));
  color: var(--accent-ink);
  border-radius: 999px;
  padding: 2px 12px;
  font-size: 0.85rem;
  font-weight: 600;
}

.page-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px 12px;
  padding: 16px 24px;
  border-bottom: 3px solid var(--accent, var(--line));
}
.page-head h1 { font-size: 1.25rem; margin: 0; flex: 1; }
.page-head .back { text-decoration: none; color: var(--muted); }

.card {
  border: 1px solid var(--line);
  border-left: 6px solid var(--accent, var(--line));
  border-radius: 10px;
  padding: 16px 20px;
  margin: 12px 0;
}
.card h2 { margin: 0 0 4px; font-size: 1.05rem; }
.card p { margin: 4px 0; color: var(--muted); }

/* status lines state what ran — a stated result, never a blank panel */
.status { color: var(--muted); border-left: 4px solid var(--accent, var(--line)); padding-left: 12px; }

/* ---- appearance -------------------------------------------------------
   TWO SURFACES, ONE SETTING, AND THE SPLIT IS THE POINT.

   Every page's top bar used to carry the WHOLE settings surface: a
   <details> drawer holding the mode buttons, two canvas colour fields, a
   custom-colour button and a status line, hanging off a header that also
   has to hold a brand, a sign-in chip and a product's own navigation. A
   drawer is also a place a person has to find before they can use it — the
   same objection that turned the bare "◐" glyph into a labelled door, one
   level up.

   So: the header keeps LIGHT and DARK, the switch people reach for daily,
   and everything past it lives at shared/settings.html, which the footer
   links from all thirteen pages. `.appearance-panel` is a section on that
   page now rather than a dropdown, which is why it has no position, no
   z-index and no shadow. The `.settings-mark` / `.settings-label` pair the
   old door used is gone with it. */
.appearance-mode:focus-visible,
.appearance-custom:focus-visible,
.appearance-hue:focus-visible { outline: 3px solid var(--accent, var(--fg)); outline-offset: 2px; }
.appearance-panel {
  max-width: 320px;
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 16px;
}
.appearance-heading { margin: 0 0 10px; font-size: 1rem; font-weight: 700; }
.appearance-modes { display: flex; gap: 8px; margin-bottom: 12px; }
/* In a header the two buttons sit beside the sign-in chip, so they take no
   bottom margin and no more room than the switch needs. */
.header-modes { margin-bottom: 0; }

/* The settings page itself. Shared infrastructure, so no product accent:
   black, white and neutrals, like every other page nobody owns. */
.settings-main { max-width: 640px; margin: 0 auto; padding: 24px 20px 8px; }
.settings-main .lede { margin-top: 0; color: var(--muted); }
.settings-note { margin: 18px 0 0; max-width: 46ch; color: var(--muted); font-size: 0.86rem; }
.appearance-mode, .appearance-custom {
  min-height: 36px;
  color: var(--fg);
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 7px;
  font: inherit;
  cursor: pointer;
}
.appearance-mode { width: 42px; font-size: 1.1rem; }
.appearance-mode:disabled { color: var(--accent-strong, var(--accent, var(--fg))); border-color: var(--accent, var(--line)); cursor: default; opacity: 1; }
.appearance-editor { margin: 0 0 12px; padding-top: 12px; border-top: 1px solid var(--line); }
.appearance-editor-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; font-size: .8rem; }
.appearance-color-value { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .74rem; font-weight: 600; }
.appearance-color-field { display: block; width: 100%; height: 104px; border: 2px solid var(--accent, var(--line)); border-radius: 7px; cursor: crosshair; }
/* 24px tall, not 12. WCAG 2.2 asks for a 24x24 minimum target (2.5.8),
   and the exception for a control the browser sizes itself does not apply
   once a stylesheet sets the height — which this line did. The track still
   draws thin because a native range centres it in the box; what grew is
   the area a finger or a shaky hand can actually land on. */
.appearance-hue { width: 100%; height: 24px; margin-top: 5px; accent-color: var(--accent, var(--fg)); cursor: pointer; }
.appearance-editor-note { margin: 6px 0 0; color: var(--muted); font-size: .74rem; }
.appearance-custom { width: 100%; padding: 8px 10px; font-weight: 700; }
.appearance-custom:not(:disabled):hover { border-color: var(--accent, var(--fg)); }
.appearance-custom:disabled { cursor: not-allowed; opacity: .55; }
.appearance-status { margin: 9px 0 0; color: var(--muted); font-size: .76rem; }

/* The branded home link every product header carries — styled ONCE here,
   never per-page. The WORD inside renders from shared/brand.ts at runtime
   (components/brand-header.ts, called by theme-boot); page markup holds
   only a pre-module placeholder. */
.home-brand { align-items: center; color: var(--fg); display: inline-flex; font-size: 1.25rem; font-weight: 700; gap: 8px; letter-spacing: 0; text-decoration: none; }
.home-brand .home-arrow, .home-brand .brand-word span { color: var(--accent-strong, var(--accent, var(--fg))); }
.home-brand:focus-visible { outline: 3px solid var(--accent, var(--fg)); outline-offset: 3px; }

/* The label naming what a page IS, sitting beside the brand in the same
   header ("Book a class", "Member Re-engagement"). Every product header
   shows one, so it is styled ONCE here — a copy pasted into a product's
   own stylesheet is the drift this file exists to prevent. */
.page-head .role { color: var(--accent-strong, var(--accent, var(--fg))); font-size: 1.3rem; font-style: normal; }
/* On a narrow screen the page's name takes its own line rather than
   squeezing whoever is signed in into a column of single words. */
@media (max-width: 860px) {
  .page-head .role { flex-basis: 100%; font-size: 1.1rem; }
}


/* ---- the pulse mark ---------------------------------------------------
   One mark, three sizes, all from tokens so no page invents a local pixel
   value. The landing header uses the standard size on purpose — it is the
   front door's own header, not a bigger logo — and the wide line drawn
   across the landing hero is an ART treatment of the same path, not a
   fourth logo size. Product headers take the compact size beside the
   brand word. */
:root {
  --pulse-mark-compact: 20px;
  --pulse-mark-standard: 26px;
}
.home-brand .pulse-mark {
  width: var(--pulse-mark-compact);
  height: var(--pulse-mark-compact);
  flex: none;
}
.brand .pulse-mark {
  width: var(--pulse-mark-standard);
  height: var(--pulse-mark-standard);
  flex: none;
}
.home-brand { display: inline-flex; align-items: center; gap: 8px; }

/* THE RESTING STATE IS THE WHOLE MARK. The runner path below is invisible
   until an animation makes it otherwise, so everything outside the guarded
   block sees a complete, solid logo: reduced motion is not a degraded
   state, it is the default one. */
.pulse-mark-runner { opacity: 0; }

/* motion: pulse runner (guarded). One spark travels the line every 3.6s —
   the same beat the landing hero already keeps (home.css) — then the mark
   rests. Stroke movement only: no layout, no scale, nothing repainting
   outside the mark's own box, and at 3.6s per cycle it is nowhere near any
   flashing threshold. transform is never animated, so no transform-origin
   question arises. The path is ~52 units long; the dash pair below is a
   10-unit spark with a gap longer than the path, so exactly one spark is
   ever on the line. */
@media (prefers-reduced-motion: no-preference) {
  .pulse-mark-live .pulse-mark-runner {
    stroke-dasharray: 10 62;
    stroke-dashoffset: 72;
    animation: pulse-mark-run 3.6s linear infinite;
  }
  @keyframes pulse-mark-run {
    0%   { opacity: 0;    stroke-dashoffset: 72; }
    12%  { opacity: 0.9; }
    55%  { opacity: 0.9;  }
    70%  { opacity: 0;    stroke-dashoffset: 0; }
    100% { opacity: 0;    stroke-dashoffset: 0; }
  }
}

/* ---- alerts -----------------------------------------------------------
   The region is EMPTY until something is wrong, and it takes no space
   until then — a permanently reserved strip at the top of every page is a
   layout shift waiting for its first message. :empty is what makes that
   free: no padding, no border, no margin, nothing to see, and the element
   still in the document since first paint so a screen reader is already
   listening (components/alert.ts explains why that matters).

   The two status hues are the carve-out documented with the tokens above.
   They colour a 4px rule and one word — never body text on a fill — so the
   sentence a person has to read is always --fg on --bg. */
.alert-region { display: grid; gap: 10px; margin: 14px auto 0; max-width: 960px; padding: 0 20px; }
.alert-region:empty { display: none; }
.alert {
  align-items: start;
  background: var(--bg);
  border: 1px solid var(--line);
  border-left: 4px solid var(--muted);
  border-radius: 8px;
  color: var(--fg);
  display: grid;
  gap: 4px 12px;
  grid-template-columns: auto 1fr auto;
  padding: 12px 14px;
}
.alert-problem { border-left-color: var(--status-bad); }
.alert-done { border-left-color: var(--status-good); }
.alert-word {
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  padding-top: 2px;
  text-transform: uppercase;
  white-space: nowrap;
}
.alert-problem .alert-word { color: var(--status-bad); }
.alert-done .alert-word { color: var(--status-good); }
.alert-notice .alert-word { color: var(--muted); }
.alert-body { min-width: 0; }
.alert-message { margin: 0; font-size: 0.94rem; }
.alert-detail { margin: 4px 0 0; color: var(--muted); font-size: 0.84rem; }
.alert-dismiss {
  background: none;
  border: 0;
  border-radius: 6px;
  color: var(--muted);
  cursor: pointer;
  font-size: 1.2rem;
  line-height: 1;
  padding: 2px 7px;
}
.alert-dismiss:hover { color: var(--fg); }
.alert-dismiss:focus-visible { outline: 3px solid var(--accent, var(--fg)); outline-offset: 2px; }

/* ---- the site footer --------------------------------------------------
   ONE footer for the whole studio, built by components/site-footer.ts and
   appended by theme-boot, so all thirteen pages end the same way without a
   line of markup in any product folder. The styles live here rather than
   in the component for the reason check-styles.mjs exists: a footer pasted
   into four stylesheets is four footers that drift.

   It inherits --accent from the page's own body class, so the brand word
   at the bottom of a product page is that builder's colour exactly as the
   one at the top is — .home-brand already owns that rule and this reuses
   it rather than restating it. Nothing here introduces a new colour
   pairing: links are --muted, headings and hover are --fg. */
.site-footer {
  border-top: 1px solid var(--line);
  color: var(--muted);
  font-size: 0.92rem;
  margin-top: 64px;
}
.site-footer-inner {
  display: grid;
  gap: 28px 32px;
  grid-template-columns: minmax(190px, 1.5fr) repeat(4, minmax(112px, 1fr));
  margin: 0 auto;
  max-width: 1060px;
  padding: 34px 20px 22px;
}
.site-footer .home-brand { font-size: 1.1rem; }
.site-footer-promise { margin: 12px 0 0; max-width: 34ch; }
.site-footer-heading {
  color: var(--fg);
  font-family: inherit;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  margin: 0 0 10px;
  text-transform: uppercase;
}
.site-footer-group ul { display: grid; gap: 7px; list-style: none; margin: 0; padding: 0; }
/* A folded group: the heading IS the link, styled as the heading it
   replaces rather than as a list item, so the column keeps its shape. */
.site-footer-group[data-folded="true"] .site-footer-heading a {
  color: var(--fg);
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
}
.site-footer a { color: var(--muted); text-decoration: none; }
.site-footer a:hover { color: var(--fg); text-decoration: underline; }
.site-footer a:focus-visible { outline: 3px solid var(--accent, var(--fg)); outline-offset: 3px; }
/* The link back to the page you are already on stays a link — every route
   here is reachable by URL and hiding one would be the first lie about
   that — but it says so rather than pretending to go somewhere. */
.site-footer a[aria-current="page"] { color: var(--fg); text-decoration: underline; }
/* Where the studio is and how to reach it — a band of its own, because an
   address wants to be an address rather than a fifth column squeezed to
   112px. Every character of it is an ordinary text node: nothing here sets
   user-select, and no word is hidden inside a `content:` property, which is
   the trick that makes text look copyable and not be. A footer address that
   will not copy into a maps app is a footer address that does not work. */
.site-footer-contact {
  border-top: 1px solid var(--line);
  display: grid;
  gap: 22px 32px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  margin: 0 auto;
  max-width: 1060px;
  padding: 22px 20px;
}
.site-footer-address { font-style: normal; line-height: 1.6; }
.site-footer-reach { display: grid; gap: 7px; list-style: none; margin: 0; padding: 0; }

.site-footer-base {
  border-top: 1px solid var(--line);
  display: flex;
  flex-wrap: wrap;
  gap: 8px 20px;
  margin: 0 auto;
  max-width: 1060px;
  padding: 14px 20px 34px;
}
.site-footer-name { margin-right: auto; }

@media (max-width: 900px) {
  .site-footer-inner { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .site-footer-brand { grid-column: 1 / -1; }
}
@media (max-width: 520px) {
  .site-footer-inner, .site-footer-contact { grid-template-columns: 1fr; }
}

/* ---- reading pages ----------------------------------------------------
   The terms and the privacy policy: one column, measured for reading rather
   than for filling a screen. Shared infrastructure, so no product accent. */
.prose { max-width: 68ch; margin: 0 auto; padding: 8px 20px 16px; }
.prose h2 { margin: 30px 0 8px; font-size: 1.05rem; }
.prose p { margin: 0 0 12px; }
.prose ul { margin: 0 0 12px; padding-left: 20px; }
.prose li { margin-bottom: 8px; }
.prose .lede { margin-bottom: 16px; }
.prose-date { color: var(--muted); font-size: 0.84rem; }

/* ---- the studio floor -------------------------------------------------
   Four figures in the mark's own line, one per builder's colour: one
   lifts, one rows, one rides, one runs the length of the band.
   components/figures.ts draws them; this decides how they move.

   THE RESTING POSE IS A WHOLE DRAWING. Everything below the guard is
   motion; everything above it is the picture. With reduced motion the
   band still shows a person mid-press, mid-pull, a rider on a bike, and a
   runner in stride — a still, not a broken animation. That is the same
   promise the pulse mark makes a few rules up.

   ONE BEAT FOR THE WHOLE STUDIO. The mark's spark runs every 3.6s; the
   press, the pull and the pedal are multiples of it, so nothing on the
   page ever quite syncs but nothing fights either. The crossing is
   deliberately far slower — a runner who crosses in four seconds reads as
   a bug. */
.figure { display: block; width: 100%; height: 100%; overflow: visible; }
.figure line, .figure circle {
  fill: none;
  stroke: currentColor;
  stroke-width: 5;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* The arms scale on Y to press. Without this the stroke scales with
     them and the arms get thin at the top of the lift. */
  vector-effect: non-scaling-stroke;
}
/* THE FAR SIDE RECEDES. Two legs, a crank and two frame tubes all meet at
   a bottom bracket, and at this size that is one blob rather than a rider.
   Every side-view figure drawing solves it the same way: the limb on the
   far side of the body is lighter, so the eye reads one leg in front of
   another instead of four lines in a knot. It is depth, done with the one
   property that costs nothing to animate. */
.cycle-leg-far, .run-leg-far, .run-arm-far { opacity: 0.42; }

/* The resting poses: a press half-extended, a stride mid-flight. Set here
   rather than left at zero so the still picture is a POSE and not a figure
   standing perfectly straight with its arms welded to a bar. */
.lift-arms { transform: scaleY(0.86); }
.lift-bar { transform: translateY(4.5px); }
/* A knee bends ONE way. Every shin angle below is negative, and every
   forearm angle is negative, because a joint that folds the wrong way is
   the single thing that makes a rig read as broken rather than stylised. */
.run-leg-near { transform: rotate(36deg); }
.run-leg-far { transform: rotate(-30deg); }
.run-leg-near .run-shin { transform: rotate(-74deg); }
.run-leg-far .run-shin { transform: rotate(-14deg); }
.run-foot { transform: rotate(-24deg); }
.run-arm-near { transform: rotate(-42deg); }
.run-arm-far { transform: rotate(36deg); }
.run-arm-near .run-forearm { transform: rotate(-72deg); }
.run-arm-far .run-forearm { transform: rotate(-84deg); }
.cycle-leg-near { transform: rotate(-26deg); }
.cycle-leg-far { transform: rotate(8deg); }
.cycle-leg-near .cycle-shin { transform: rotate(-10deg); }
.cycle-leg-far .cycle-shin { transform: rotate(-66deg); }
.cycle-foot { transform: rotate(-30deg); }
/* The rower's resting pose is the catch: knees drawn up, torso forward,
   arm reaching for the handle — the geometry itself IS that reach, so only
   the leg and the lean need a rotation here. */
.row-torso { transform: rotate(8deg); }
.row-thigh { transform: rotate(58deg); }
.row-shin { transform: rotate(-58deg); }
.row-foot { transform: rotate(-14deg); }

/* The band itself. A row on the studio's own ground line, with the runner
   crossing behind the three who are working in place. */
.studio-floor {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: end;
  gap: 0 40px;
  max-width: 900px;
  margin: 26px auto 0;
  padding: 0 20px 34px;
  color: var(--muted);
}
/* One floor for all four, and every figure's box ends on it: the grid
   bottom-aligns them, the run lane sits at the same offset, and the rule
   below is drawn at that offset too. Before this the runner stood ten
   pixels under the line and the lifter drew a second floor of its own. */
.studio-floor [data-figure] { height: 132px; }
.studio-floor [data-figure="lift"] { color: var(--kerrian); justify-self: end; width: 118px; }
.studio-floor [data-figure="row"] { color: var(--manny); justify-self: center; width: 132px; }
.studio-floor [data-figure="cycle"] { color: var(--dennis); justify-self: start; width: 152px; }
.run-lane {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 34px;
  height: 100px;
  overflow: hidden;
  pointer-events: none;
}
.run-traveller { width: 78px; height: 100px; color: var(--rensley); }
.run-traveller .figure { height: 100px; }
/* The floor everyone is standing on. */
.studio-floor::after {
  content: "";
  position: absolute;
  left: 20px;
  right: 20px;
  bottom: 33px;
  border-bottom: 3px solid var(--line);
}

@media (prefers-reduced-motion: no-preference) {
  .lift-arms { animation: lift-press 3.6s ease-in-out infinite; }
  .lift-bar { animation: lift-bar 3.6s ease-in-out infinite; }
  @keyframes lift-press {
    0%, 100% { transform: scaleY(0.62); }
    45%      { transform: scaleY(1); }
    62%      { transform: scaleY(1); }
  }
  @keyframes lift-bar {
    0%, 100% { transform: translateY(12px); }
    45%      { transform: translateY(0); }
    62%      { transform: translateY(0); }
  }

  .cycle-wheel { animation: spin 1.8s linear infinite; }
  .cycle-crank { animation: spin 1.2s linear infinite; }
  @keyframes spin { to { transform: rotate(360deg); } }

  .cycle-leg-near { animation: pedal-thigh 1.2s linear infinite; }
  .cycle-leg-far  { animation: pedal-thigh 1.2s linear infinite -0.6s; }
  .cycle-leg-near .cycle-shin { animation: pedal-shin 1.2s linear infinite; }
  .cycle-leg-far  .cycle-shin { animation: pedal-shin 1.2s linear infinite -0.6s; }
  /* The thigh sweeps a small arc while the shin does most of the work —
     which is what a pedal stroke looks like from the side: the knee barely
     travels, the ankle goes round. */
  @keyframes pedal-thigh {
    0%, 100% { transform: rotate(-20deg); }
    50%      { transform: rotate(6deg); }
  }
  @keyframes pedal-shin {
    0%   { transform: rotate(-30deg); }
    25%  { transform: rotate(-8deg); }
    50%  { transform: rotate(-34deg); }
    75%  { transform: rotate(-66deg); }
    100% { transform: rotate(-30deg); }
  }

  /* The rower: catch, drive, finish, recovery — held at the catch a beat
     longer than the drive takes, the way an actual stroke does, rather
     than a symmetric back-and-forth. All four parts share one timeline so
     the pull always finishes exactly as the legs straighten. */
  .row-torso { animation: row-torso-swing 1.6s ease-in-out infinite; }
  .row-arms  { animation: row-arms-pull 1.6s ease-in-out infinite; }
  .row-thigh { animation: row-thigh-drive 1.6s ease-in-out infinite; }
  .row-shin  { animation: row-shin-drive 1.6s ease-in-out infinite; }
  @keyframes row-torso-swing {
    0%, 40% { transform: rotate(8deg); }
    75%     { transform: rotate(-16deg); }
    100%    { transform: rotate(8deg); }
  }
  @keyframes row-arms-pull {
    0%, 40% { transform: rotate(0deg); }
    75%     { transform: rotate(-95deg); }
    100%    { transform: rotate(0deg); }
  }
  @keyframes row-thigh-drive {
    0%, 40% { transform: rotate(58deg); }
    75%     { transform: rotate(4deg); }
    100%    { transform: rotate(-18deg); }
  }
  @keyframes row-shin-drive {
    0%, 40% { transform: rotate(-58deg); }
    75%     { transform: rotate(-4deg); }
    100%    { transform: rotate(52deg); }
  }

  /* Opposition is the whole thing: the near arm swings with the FAR leg.
     Same duration, offset by half, on both pairs. */
  .run-leg-near { animation: stride 0.62s ease-in-out infinite; }
  .run-leg-far  { animation: stride 0.62s ease-in-out infinite -0.31s; }
  .run-leg-near .run-shin { animation: stride-shin 0.62s ease-in-out infinite; }
  .run-leg-far  .run-shin { animation: stride-shin 0.62s ease-in-out infinite -0.31s; }
  .run-arm-near { animation: swing 0.62s ease-in-out infinite -0.31s; }
  .run-arm-far  { animation: swing 0.62s ease-in-out infinite; }
  @keyframes stride {
    0%, 100% { transform: rotate(40deg); }
    50%      { transform: rotate(-32deg); }
  }
  /* Knee bent hard on the way through, straight at the strike. Never
     positive: a shin that swings forward past the thigh is a broken leg. */
  @keyframes stride-shin {
    0%   { transform: rotate(-14deg); }
    30%  { transform: rotate(-82deg); }
    60%  { transform: rotate(-64deg); }
    100% { transform: rotate(-14deg); }
  }
  @keyframes swing {
    0%, 100% { transform: rotate(-44deg); }
    50%      { transform: rotate(38deg); }
  }
  /* A slight bob, so the runner rises and falls the way a stride does
     rather than gliding along a rail. Twice the stride rate: one rise per
     footfall, two per cycle. */
  .run-traveller .figure { animation: run-bob 0.31s ease-in-out infinite; }
  @keyframes run-bob {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(-4px); }
  }

  .run-traveller { animation: run-across 19s linear infinite; }
  @keyframes run-across {
    from { transform: translateX(-90px); }
    to   { transform: translateX(calc(100vw + 90px)); }
  }
}

@media (max-width: 560px) {
  .studio-floor { gap: 0 16px; }
  .studio-floor [data-figure="lift"] { width: 92px; }
  .studio-floor [data-figure="row"] { width: 100px; }
  .studio-floor [data-figure="cycle"] { width: 118px; }
}

/* ---- the assistant launcher --------------------------------------------
   ONE CHATBOX, BOTTOM-RIGHT, ON EVERY PAGE THAT ASKED FOR ONE. The button
   and panel are built by components/assistant.ts; this is their only look,
   so a page cannot restyle the one thing every audience shares. It carries
   NO product accent — the assistant speaks for the studio, not for one
   product's colour, the same reason shared infrastructure carries none. */
.assistant-launcher {
  position: fixed;
  right: 20px;
  bottom: 20px;
  z-index: 30;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  padding: 13px 22px 13px 18px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--fg);
  color: var(--bg);
  font: inherit;
  font-weight: 700;
  letter-spacing: 0.01em;
  cursor: pointer;
  box-shadow: 0 10px 26px color-mix(in srgb, var(--fg) 22%, transparent);
  transition: box-shadow 0.15s ease, transform 0.15s ease;
}
/* The mark carries the studio's own beat (guarded, reduced-motion-safe —
   see the pulse-mark rules above) instead of a generic chat glyph, so the
   one new fixed element on every page still reads as part of the same
   system as the header and footer mark rather than a bolted-on widget. */
.assistant-launcher svg.pulse-mark { width: 18px; height: 18px; flex: none; }
.assistant-launcher:hover { box-shadow: 0 14px 32px color-mix(in srgb, var(--fg) 28%, transparent); transform: translateY(-1px); }
.assistant-launcher:focus-visible { outline: 3px solid var(--fg); outline-offset: 3px; }
.assistant-launcher[aria-expanded="true"] { display: none; }

/* `hidden` LOSES TO A CLASS RULE AT EQUAL SPECIFICITY, SOURCE ORDER
   DECIDES. This is the same bug `.home-product[hidden]` was fixed for a
   few sections up in this same file — found again, in the very panel that
   fix should have made this session careful about. `.assistant-panel {
   display: flex }` matches the element whether or not it carries `hidden`,
   and being later in the sheet than the UA's `[hidden] { display: none }`,
   it won: the panel rendered open on every page load until a person
   clicked anything. Watched in a browser, not assumed from the code. */
.assistant-panel[hidden] { display: none; }

/* THE STAFF DOOR. Styled once here because both staff surfaces mount the
   same panel — see app/shared/auth/staff-gate.ts. The accent arrives from
   the page's product class, so a person still sees whose screen they are
   standing in front of while they sign in. */
.staff-door { max-width: 34rem; margin: 8vh auto; padding: 28px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
.staff-door h2 { margin: 0 0 10px; color: var(--accent-strong, var(--accent, var(--fg))); }
.staff-door-said { margin: 0 0 18px; color: var(--muted); line-height: 1.55; }
.staff-door-form { display: flex; flex-direction: column; gap: 8px; }
.staff-door-form label { font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.staff-door-form input { padding: 10px 12px; font: inherit; color: var(--fg); background: var(--bg); border: 1px solid var(--line); border-radius: 6px; }
.staff-door-form input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.staff-door-form button { margin-top: 6px; padding: 10px 16px; font: inherit; font-weight: 600; color: var(--accent-ink); background: var(--accent-strong, var(--accent)); border: 0; border-radius: 6px; cursor: pointer; }
.staff-door-form button:disabled { cursor: default; opacity: .7; }
.staff-door-problem { margin: 4px 0 0; color: var(--status-bad, var(--fg)); }
/* The same trap the assistant panel hit twice: a class rule that sets
   display beats the UA's [hidden] unless we say so here. */
.staff-door-problem[hidden] { display: none; }

/* A THUMB IS NOT A MOUSE POINTER.
 *
 * Measured on a 375px-wide viewport, this site had SEVENTEEN controls under
 * the 44px minimum a finger needs — eleven of them the footer's navigation
 * links, at fifteen pixels tall. On a desktop with a cursor that is fine and
 * invisible; on the phone most members actually carry, it is a column of
 * links you cannot reliably hit, right where somebody goes looking for
 * "Book a class".
 *
 * The query is `pointer: coarse`, not a width breakpoint, because the thing
 * that changed is the INPUT, not the screen. A tablet with a pen and a
 * narrow desktop window are different problems; a finger is a finger at any
 * width. Nothing moves visually — the text stays where it was and the box
 * around it grows to meet the hand. */
@media (pointer: coarse) {
  .site-footer li a,
  .site-footer-base a,
  .site-footer-brand a,
  .topnav .brand,
  .page-head .home-brand {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
  .appearance-mode {
    min-height: 44px;
    min-width: 44px;
  }
  /* The list gets its rhythm back: the links are taller now, so the gap
     between them no longer has to carry the separation on its own. */
  .site-footer li + li { margin-top: 0; }
}
.assistant-panel {
  position: fixed;
  right: 20px;
  bottom: 20px;
  z-index: 30;
  display: flex;
  flex-direction: column;
  width: min(368px, calc(100vw - 40px));
  max-height: min(560px, calc(100vh - 40px));
  background: var(--bg);
  color: var(--fg);
  border: 1px solid var(--line);
  border-radius: 18px;
  box-shadow: 0 24px 56px color-mix(in srgb, var(--fg) 22%, transparent);
  overflow: hidden;
}
/* A ONE-SHOT ENTRANCE, not a persistent effect. `display: none` removes an
   element from the animation timeline entirely, so an animation named on
   the visible state restarts from 0% every time `hidden` toggles off —
   the same mechanism `hidden` fighting `display` almost broke a few rules
   up, used on purpose here instead. Guarded like every other motion on
   this site: reduced motion gets the panel exactly as it ends, with
   nothing to sit through. */
@media (prefers-reduced-motion: no-preference) {
  .assistant-panel:not([hidden]) {
    transform-origin: bottom right;
    animation: assistant-in 0.22s cubic-bezier(0.16, 1, 0.3, 1);
  }
  @keyframes assistant-in {
    from { opacity: 0; transform: translateY(10px) scale(0.96); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
  }
}
.assistant-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 14px 18px;
  border-bottom: 1px solid var(--line);
}
.assistant-heading { display: flex; align-items: center; gap: 9px; min-width: 0; }
.assistant-heading svg.pulse-mark { width: 16px; height: 16px; flex: none; }
.assistant-title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.05rem;
  font-weight: 400;
  letter-spacing: 0.01em;
  text-transform: uppercase;
}
.assistant-close {
  background: none;
  border: 0;
  font-size: 1.25rem;
  line-height: 1;
  color: var(--muted);
  cursor: pointer;
  width: 30px;
  height: 30px;
  border-radius: 999px;
  flex: none;
}
.assistant-close:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 8%, transparent); }
.assistant-close:focus-visible { outline: 3px solid var(--fg); outline-offset: 2px; }

.assistant-log {
  flex: 1;
  min-height: 160px;
  overflow-y: auto;
  padding: 16px 18px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.assistant-line { margin: 0; padding: 10px 13px; border-radius: 14px; max-width: 86%; font-size: 0.93rem; line-height: 1.45; }
.assistant-you { align-self: flex-end; background: var(--fg); color: var(--bg); border-bottom-right-radius: 4px; }
.assistant-studio {
  align-self: flex-start;
  background: color-mix(in srgb, var(--fg) 5%, var(--bg));
  border: 1px solid var(--line);
  border-bottom-left-radius: 4px;
}

.assistant-form { display: flex; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--line); }
.assistant-input {
  flex: 1;
  min-width: 0;
  padding: 11px 14px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--bg);
  color: var(--fg);
  font: inherit;
}
.assistant-input:focus-visible { outline: 3px solid var(--fg); outline-offset: 1px; }
.assistant-send {
  padding: 10px 18px;
  border: 0;
  border-radius: 999px;
  background: var(--fg);
  color: var(--bg);
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}
.assistant-send:hover:not(:disabled) { filter: brightness(1.1); }
.assistant-send:disabled { opacity: 0.5; cursor: not-allowed; }
.assistant-send:focus-visible { outline: 3px solid var(--fg); outline-offset: 2px; }

.assistant-status { margin: 0; padding: 0 18px 12px; min-height: 1.1em; color: var(--muted); font-size: 0.78rem; }

@media (max-width: 480px) {
  .assistant-launcher, .assistant-panel { right: 12px; bottom: 12px; }
  .assistant-panel { width: calc(100vw - 24px); }
}
