/* Ascend App — internal preview.
 *
 * Brand comes from ascendspeech.org, not invented here: Cormorant Garamond for
 * display, DM Sans for everything else, crimson #BC1E2D, graphite ink #15171B,
 * cream #F1F1F1. A family should not be able to tell they left the website.
 *
 * ascendspeech.org is the single source of truth, decided 2026-08-24. The camp
 * platform is a deliberate near-miss and must not be copied from: it runs
 * DM Serif Display, #BD1F2E, pure black on cream. Where the two disagree the
 * customer-facing value wins, because families see the marketing site first and
 * the difference is only invisible if we never split it.
 * Reference files, both local:
 *   ascend-main-website/styles.css                 (source of truth)
 *   Downloads/Platform_NEW VERSION/styles.css      (do not copy)
 *
 * The family and student lenses get the design attention, because they are the
 * only surfaces a customer sees. The admin and records lenses stay deliberately
 * plainer: they are tools, and dressing them up would cost scanning speed.
 *
 * Mobile first. Every view has to survive 360px, and every tap target clears
 * 40px. Both are asserted in the browser, not eyeballed.
 */

/* Self-hosted, the same files the marketing site ships. */
@font-face { font-family: 'Cormorant Garamond'; font-style: normal; font-weight: 400 700; font-display: swap; src: url('/fonts/cormorant-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'Cormorant Garamond'; font-style: normal; font-weight: 400 700; font-display: swap; src: url('/fonts/cormorant-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
@font-face { font-family: 'Cormorant Garamond'; font-style: italic; font-weight: 400; font-display: swap; src: url('/fonts/cormorant-italic-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'DM Sans'; font-style: normal; font-weight: 400 600; font-display: swap; src: url('/fonts/dmsans-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: 'DM Sans'; font-style: normal; font-weight: 400 600; font-display: swap; src: url('/fonts/dmsans-latin-ext.woff2') format('woff2'); unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }
/* The login card's handwriting, and nothing else's: one greeting in script is
   a signature, script everywhere is a wedding invitation. */
@font-face { font-family: 'Great Vibes'; font-style: normal; font-weight: 400; font-display: swap; src: url('/fonts/great-vibes-latin.woff2') format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }

:root {
  /* Marketing-site tokens, verbatim. */
  --crimson: #BC1E2D;
  --crimson-deep: #8E1622;
  --crimson-light: #d42a3a;
  --ink: #15171B;
  --ink-soft: #2A2E36;
  /* Verbatim from ascendspeech.org, which is the source of truth for anything
     a family sees. The app had drifted warmer and lighter than the site on all
     three, which is most of why it read as adjacent to the brand rather than
     part of it. */
  --text-muted: #444444;
  --cream: #F1F1F1;
  --white: #ffffff;
  --border: #d9d9d9;
  --border-strong: #c6c6c6;

  /* Was #6E7278, which measured 4.44:1 on --page and so failed AA on the very
     small text it mostly carries. Darkened to clear it, and toward the site's
     considerably darker --text-muted rather than away from it. */
  --muted: #5E6268;
  --page: #F6F5F2;
  --surface: #ffffff;
  --surface-2: #FAF9F7;
  --ok: #1A7F43;
  --ok-bg: #EAF6EE;
  --warn-bg: #FFF8E6;
  --warn-line: #E3C77A;

  /* A scale for text, deliberately not for display.
     ascendspeech.org is disciplined at the small end (10-16px, tight cluster)
     and free at the display end, where serif headings are set per context.
     Copying that is the point: the app's drift was all in the text range, where
     9/9.5/10.5/12.5/13.5/14.5 had accumulated with no rule behind them. Serif
     sizes stay explicit per component, as they are on the site.
     --t-micro is the site's floor; --t-label is what it uses for nearly every
     uppercase label. The app had been running both a step small. */
  --t-micro: 10px;
  --t-label: 11px;
  --t-xs: 12px;
  --t-sm: 13px;
  --t-base: 14px;
  /* The size the app actually reads at: list row titles, message bubbles,
     program names, the composer, channel names. It was hand-typed at all six
     because the scale jumped 14 to 16 and left out the step in between. */
  --t-row: 15px;
  --t-md: 16px;

  /* One spacing scale, so rhythm is a decision rather than an accident. */
  --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px;
  --s5: 24px; --s6: 32px; --s7: 48px; --s8: 64px;

  /* The positive set. --ok was already here carrying the border and the icon,
     but the tint's edge and its text were typed by hand in three places
     (.freebie, .held, .flash), which is how a fourth would have drifted. */
  --ok-line: #BCE3C9;
  --ok-ink: #12602F;

  /* The caution set, completing --warn-bg / --warn-line, which had no text
     colour and no accent and so grew both inline. */
  --warn-ink: #7A5008;
  --warn-tint: #FFF0D0;
  --warn-strong: #C9922A;

  /* Crimson at low alpha, for hovers and hairlines over cream. Fifteen distinct
     alphas had accumulated here. Only the three that repeat are named, and only
     exact matches were folded onto them: collapsing 0.03/0.07/0.10/0.14 onto a
     shared value would change what people see, which is a separate decision
     from giving the repeats a name. */
  --crimson-wash: rgba(188, 30, 45, 0.05);
  --crimson-tint: rgba(188, 30, 45, 0.07);
  --crimson-line: rgba(188, 30, 45, 0.24);

  /* The cash card is the app's one dark surface, so its ink is white at alpha
     and its accents cannot come from the light palette. */
  --on-dark: rgba(255, 255, 255, 0.78);
  --on-dark-dim: rgba(255, 255, 255, 0.20);
  --on-dark-hi: #FFD9DD;
  --earn: #46C97F;
  --earn-lift: #63E29A;
  --earn-held: rgba(99, 226, 154, 0.40);
  --enrolled-ink: #1C6B45;
  --enrolled-bg: rgba(28, 107, 69, 0.09);

  /* Chat keeps the iOS system palette on purpose: the brief was the AscendChat
     screenshots, and a crimson bubble would read as an alert rather than as a
     message. Naming the values does not change them, it just stops the next
     chat surface from guessing which grey was which. */
  --chat-accent: #007AFF;
  --chat-them-bg: #E9E9EB;
  --chat-them-ink: #111111;
  --chat-meta: #6E6E73;
  --chat-line: #E5E5EA;
  --chat-chip: #ECECF0;
  --chat-online: #34C759;
  --chat-hover: #DFDFE2;
  --chat-focus-line: #9A9AA0;

  --r-sm: 6px; --r-md: 10px; --r-lg: 16px; --r-full: 999px;

  /* Two-layer shadows: a tight contact shadow plus a soft ambient one. A
     single blurred shadow reads as fog; the pair reads as paper. */
  --e1: 0 1px 2px rgba(21,23,27,0.05), 0 1px 3px rgba(21,23,27,0.04);
  --e2: 0 2px 4px rgba(21,23,27,0.05), 0 6px 16px rgba(21,23,27,0.06);
  --e3: 0 4px 8px rgba(21,23,27,0.06), 0 16px 32px rgba(21,23,27,0.09);

  --focus: 0 0 0 3px rgba(188,30,45,0.22);
  --t: 160ms cubic-bezier(0.4, 0, 0.2, 1);

  --serif: 'Cormorant Garamond', Georgia, serif;
  --sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}

*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body {
  font-family: var(--sans);
  background: var(--page);
  color: var(--ink);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-feature-settings: 'kern' 1;
}
::selection { background: rgba(188,30,45,0.14); color: var(--ink); }
.hidden { display: none !important; }
picture { display: contents; }

/* Money and dates line up in columns only with tabular figures. Without this a
   ledger of prices visibly wobbles row to row. */
.cash-v, .ledger-amt, .kv-v, .cat-price, .when-day, .meter-key, .skill-v { font-variant-numeric: tabular-nums; }

:where(a, button, input, select, [tabindex]):focus-visible {
  outline: none; box-shadow: var(--focus); border-radius: var(--r-sm);
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}

/* ── Buttons ──────────────────────────────────────────────────────────── */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--s2);
  font-family: var(--sans); font-size: var(--t-xs); font-weight: 600;
  letter-spacing: 0.07em; text-transform: uppercase; text-decoration: none;
  padding: 13px 24px; min-height: 46px; border-radius: var(--r-sm);
  border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink);
  cursor: pointer; transition: background var(--t), border-color var(--t), transform var(--t), box-shadow var(--t);
  box-shadow: var(--e1);
}
.btn:hover { background: var(--surface-2); border-color: var(--ink); }
.btn:active { transform: translateY(1px); box-shadow: none; }
.btn-primary {
  background: var(--fill-crimson); border-color: var(--fill-crimson); color: var(--white);
  box-shadow: 0 1px 2px rgba(142,22,34,0.24), 0 4px 12px rgba(188,30,45,0.20);
}
.btn-primary:hover { background: var(--fill-crimson-hover); border-color: var(--fill-crimson-hover); }
.btn-ink { background: var(--ink-fixed); border-color: var(--ink-fixed); color: var(--white); }

/* A partner's programme, inside Ascend's app.
   The chrome stays ours — topbar, tab bar, the shell — because it is still our
   app. The content of their page takes their colour, so a family can see whose
   programme they are actually looking at and who will be delivering it.

   This works because of the token pass: every accent surface on these screens
   already reads var(--crimson), so redefining the accent family on one scope
   turns the whole page over at once. Buttons, the section rules, the eyebrow,
   the bullet dots, the focus ring. Nothing below had to be touched.

   The values are Montara's own, taken from their site and MontaraOS, where all
   three repos agree on #00674B.

   The token being called --crimson while holding green is the one ugly part.
   Renaming it to --accent is the right fix and a much larger change than this
   one, touching every file; scoping it here is honest about what it is doing
   until then. */
.vendor-montara {
  --crimson:       #00674B;
  --crimson-deep:  #004F39;
  --crimson-light: #2EA37E;
  --crimson-tint:  rgba(0, 103, 75, 0.07);
  --crimson-wash:  rgba(0, 103, 75, 0.05);
  --crimson-line:  rgba(0, 103, 75, 0.24);
  --focus: 0 0 0 3px rgba(0, 103, 75, 0.22);
}
/* The only accent on these screens that was still a literal, so the only one
   the scope above cannot reach. */
.vendor-montara .btn-primary {
  box-shadow: 0 1px 2px rgba(0, 79, 57, 0.24), 0 4px 12px rgba(0, 103, 75, 0.20);
}
.btn-ink:hover { background: var(--ink-fixed-soft); border-color: var(--ink-fixed-soft); }
.btn:disabled { opacity: 0.42; cursor: not-allowed; box-shadow: none; transform: none; }
.linklike {
  display: inline-flex; align-items: center; min-height: 40px;
  background: none; border: none; color: var(--muted); font-size: var(--t-xs);
  cursor: pointer; font-family: var(--sans); padding: var(--s1) 2px;
  text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px;
  transition: color var(--t);
}
.linklike:hover { color: var(--crimson); }

/* A link inside a sentence, rather than a control standing on its own.
   .linklike is built as a standalone tap target: inline-flex, 40px tall, set at
   --t-xs. Dropped mid-paragraph all three misfire. The 40px forces the whole
   line box to 40px, which is the gap that made "School year" look like it had
   fallen onto a line of its own; inline-flex cannot break, so it wraps whole;
   and 12px against the paragraph's 14px reads as a smaller, different voice.
   Here it inherits the sentence it sits in and keeps only the underline.

   Giving up the 40px target is right rather than a compromise: WCAG 2.5.5
   exempts a link inside a block of text, precisely because padding one out is
   what breaks the line. Standalone .linklike keeps its full target. */
.linklike.inline {
  display: inline;
  min-height: 0;
  padding: 0;
  font-size: inherit;
  line-height: inherit;
  color: inherit;
  vertical-align: baseline;
}

/* ── Shell ────────────────────────────────────────────────────────────── */
.app { min-height: 100vh; display: flex; flex-direction: column; }
.topbar {
  position: sticky; top: 0; z-index: 20;
  display: flex; align-items: center; gap: var(--s3);
  padding: var(--s2) var(--s4);
  background: var(--topbar-bg);
  backdrop-filter: saturate(180%) blur(14px);
  -webkit-backdrop-filter: saturate(180%) blur(14px);
  border-bottom: 1px solid var(--border);
}
.topbar-back {
  background: none; border: none; color: var(--ink); font-size: 20px; line-height: 1;
  cursor: pointer; padding: 0 var(--s2); min-height: 40px; min-width: 36px;
  border-radius: var(--r-sm); transition: background var(--t), transform var(--t);
}
.topbar-back:hover { background: var(--hover-wash); transform: translateX(-2px); }
.topbar-brand { display: flex; align-items: center; gap: var(--s3); flex: 1; min-width: 0; }
/* The crest carries its own alpha now, so it needs no help sitting on the page.
   It used to be a white rectangle keyed out with mix-blend-mode: multiply, which
   blends against the nearest stacking context rather than the page — and .topbar
   makes one, because it has a backdrop-filter. So the blend never reached the
   cream and the white box showed anyway. */
.topbar-brand img { height: 42px; width: auto; object-fit: contain; flex: none; }
#topbar-text {
  font-family: var(--serif); font-size: 19px; font-weight: 700; letter-spacing: -0.01em;
  color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.view { flex: 1; padding: var(--s6) var(--s4) var(--s8); max-width: 760px; margin: 0 auto; width: 100%; }
/* The edge-swipe back drags .view with the finger by setting transform inline.
   No transition while a finger is on it, or the screen lags behind; this is the
   spring back when the drag is abandoned, and it applies only when the inline
   transform has been cleared. */
.view:not([style*="translateX"]) { transition: transform 0.2s ease-out, opacity 0.2s ease-out; }
/* Chat screens drop the warm page ground for white, like AscendChat and
   CampChat. iOS grey bubbles on cream read washed; on white they read right. */
/* background-color, not the background shorthand: this element is also the
   watermark's scroll container, and the shorthand resets background-image to
   none, which silently wiped the programme mark out from under it. */
.view.chat-screen { background-color: var(--surface); }
.foot {
  display: flex; justify-content: space-between; align-items: center; gap: var(--s3);
  flex-wrap: wrap; padding: var(--s4); max-width: 760px; margin: 0 auto; width: 100%;
  font-size: var(--t-label); color: var(--muted); border-top: 1px solid var(--border);
}
.loading { padding: var(--s8) 0; text-align: center; color: var(--muted); font-size: var(--t-base); }

/* ── Type ─────────────────────────────────────────────────────────────── */
.eyebrow {
  font-size: var(--t-label); font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--crimson); margin: 0 0 var(--s2);
}
h2.screen-title {
  font-family: var(--serif); font-size: clamp(32px, 8vw, 46px); font-weight: 700;
  line-height: 1.04; letter-spacing: -0.015em;
  /* The site's signature: ink bleeding into crimson across the glyphs. Solid
     colour is declared first so a browser without background-clip gets ink
     rather than nothing. */
  color: var(--ink);
  /* --ink, not --ink-fixed: the same #15171B in light, but in dark the fixed
     ink is a charcoal that vanished into the page, so "Good evening" opened
     invisible and only surfaced where the crimson took over (Varun's
     screenshot, 2026-09-03). Starting from the text colour keeps the bleed
     legible on either ground. */
  background: linear-gradient(135deg, var(--ink) 0%, var(--crimson) 100%);
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
  background-clip: text;
  /* The gradient paints only inside the background box, and Cormorant's
     descenders hang below this line box, so every g/y/p in a title would lose
     its tail. ascendspeech.org hit the same bug and fixed it the same way.
     A little more padding here than the site uses, because 1.04 is tighter
     than its 1.1; the bottom margin above is trimmed to keep spacing level. */
  padding-bottom: 0.14em;
  margin: 0 0 calc(var(--s2) - 0.14em);
}
/* A hairline above the label, so sections read as chapters rather than a stack
   of same-weight boxes. */
h3.section-head {
  font-family: var(--serif); font-size: 25px; font-weight: 700; letter-spacing: -0.01em;
  margin: var(--s7) 0 var(--s4); padding-top: var(--s4);
  border-top: 1px solid var(--border);
}
/* text-wrap: pretty so the last line is never a single orphaned word. It read
   worst where the sentence ends in a link: "Summer." alone on its own line
   looked like a deliberate break rather than a wrap. */
/* 56ch was tight enough to break sentences that plainly fit. It put "School
   year" on a line of its own under a paragraph ending three inches short of the
   container, which reads as a fault rather than as a measure. 68ch is 651px in
   a 760px column: still inside the 65-75ch most typography settles on, and it
   takes that sentence to one line and the summer one from three to two.
   Measured, not guessed, and text-wrap:pretty turned out to change nothing
   either way. Phones are narrower than either figure, so nothing moves there. */
.screen-sub { margin: 0 0 var(--s6); color: var(--text-muted); font-size: var(--t-base); max-width: 68ch; text-wrap: pretty; }
.muted { color: var(--muted); }
.small { font-size: var(--t-xs); }

.card {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md);
  padding: var(--s5); margin-bottom: var(--s3); box-shadow: var(--e1);
}
.card-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s3); }

/* ── Family lens ──────────────────────────────────────────────────────── */
.hello { margin-bottom: var(--s6); }
.hello .eyebrow { margin-bottom: var(--s2); }

.kids { display: flex; flex-wrap: wrap; gap: var(--s2); margin-bottom: var(--s6); }
.kid {
  display: inline-flex; align-items: center; gap: var(--s2);
  border: 1px solid var(--border-strong); background: var(--surface);
  border-radius: var(--r-full); padding: 5px 16px 5px 5px; font-size: var(--t-sm); font-weight: 500;
  font-family: var(--sans); color: var(--ink); cursor: pointer; min-height: 42px;
  transition: border-color var(--t), background var(--t), box-shadow var(--t);
}
.kid:hover { border-color: var(--ink); }
.kid[aria-pressed="true"] {
  border-color: var(--crimson); background: var(--crimson-wash);
  box-shadow: 0 1px 2px rgba(188,30,45,0.16);
}
.kid-av {
  width: 30px; height: 30px; border-radius: 50%; flex: none;
  background: var(--ink-fixed); color: var(--white);
  display: grid; place-items: center; font-size: var(--t-label); font-weight: 600;
  transition: background var(--t);
}
.kid[aria-pressed="true"] .kid-av { background: var(--fill-crimson); }

/* Program tiles. Each program carries a quiet accent so the five are
   distinguishable at a glance rather than five identical white rectangles. */
.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr)); gap: var(--s3); }
.tile {
  position: relative; overflow: hidden;
  border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface);
  padding: var(--s5); min-width: 0; text-align: left; font-family: var(--sans);
  color: inherit; cursor: pointer; display: block; width: 100%;
  box-shadow: var(--e1); transition: transform var(--t), box-shadow var(--t), border-color var(--t);
}
.tile::before {
  content: ''; position: absolute; inset: 0 auto 0 0; width: 3px;
  background: var(--accent, var(--ink-fixed)); opacity: 0.9;
}
.tile[data-prog="camp"]     { --accent: var(--crimson); }
.tile[data-prog="ms"]       { --accent: #1F3A5F; }
.tile[data-prog="sandel"]   { --accent: #1B6B4A; }
.tile[data-prog="aalp"]     { --accent: #2F6B4F; }
.tile[data-prog="coaching"] { --accent: #5B5F66; }
button.tile:hover { transform: translateY(-2px); box-shadow: var(--e3); border-color: var(--border-strong); }
button.tile:active { transform: translateY(0); box-shadow: var(--e1); }
.tile-name { font-family: var(--serif); font-size: 22px; font-weight: 700; line-height: 1.12; letter-spacing: -0.01em; }
.tile-who { font-size: var(--t-xs); color: var(--muted); margin-top: 3px; }
.tile-when { font-size: var(--t-sm); color: var(--ink); margin-top: var(--s3); font-weight: 500; }
.tile-cta {
  font-size: var(--t-label); font-weight: 600; letter-spacing: 0.11em; text-transform: uppercase;
  color: var(--crimson); margin-top: var(--s4);
}
.tile.explore { background: var(--surface-2); box-shadow: none; }
.tile.explore::before { opacity: 0.32; }
.tile.explore .tile-name { color: var(--text-muted); }
.tile.explore .tile-cta { color: var(--ink); }

.status {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: var(--t-micro); font-weight: 600; letter-spacing: 0.11em; text-transform: uppercase;
  padding: 4px 9px; border-radius: var(--r-full); margin-bottom: var(--s3);
}
.status-on { background: var(--ok-bg); color: var(--ok); }
.status-soon { background: rgba(188,30,45,0.08); color: var(--crimson); }
/* Waiting on the family to do something. The amber tokens were already defined
   and nothing was using them, so every state that is not "enrolled" or "not
   enrolled" was borrowing one of those two colours: an offered Sandel place
   read as green, which says settled, when the whole point of it is that it is
   not. Amber is the only one of the three that means "your move". */
.status-wait { background: var(--warn-bg); color: var(--warn-ink); }
.status-off { background: rgba(21,23,27,0.06); color: var(--muted); }

/* ── AscendCash: the hero object ──────────────────────────────────────── */
.cash {
  position: relative; overflow: hidden;
  /* Three layers deep: a whisper of film grain so the surface reads as
     material rather than as flat pixels, a deep-crimson bloom rising from the
     lower left, and the base gradient. The base runs crimson-black at the top
     left through --cash-sheen (ink in light, brushed silver in dark) to
     ink-black at the bottom right, so the red weight and the brighter bloom on
     ::before both live on the left, under the balance, and the sheen crosses
     the right half where there is nothing to read over it. */
  background:
    url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E") repeat,
    radial-gradient(120% 90% at -12% 112%, rgba(142,22,34,0.42), transparent 62%),
    linear-gradient(150deg, #241016 0%, var(--cash-sheen) 62%, #1B1E24 100%);
  background-size: 128px 128px, auto, auto;
  color: var(--white);
  border-radius: var(--r-lg); padding: var(--s6) var(--s5) var(--s5);
  margin-bottom: var(--s3);
  box-shadow: 0 2px 6px rgba(21,23,27,0.20), 0 18px 40px rgba(21,23,27,0.22);
}
/* A crimson bloom in the top-left corner, behind the balance, and a hairline
   highlight along the top edge so the card catches light like a physical
   object. The bloom sat top-right until 2026-09-03; with the dark card's
   silver sheen it read as red on the wrong side. */
.cash::before {
  content: ''; position: absolute; top: -60%; left: -30%; width: 90%; height: 180%;
  background: radial-gradient(closest-side, rgba(188,30,45,0.55), transparent 72%);
  pointer-events: none;
}
.cash::after {
  content: ''; position: absolute; inset: 0 0 auto 0; height: 1px;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.28), transparent);
}
.cash > * { position: relative; }
.cash-l { font-size: var(--t-label); font-weight: 600; letter-spacing: 0.2em; text-transform: uppercase; color: rgba(255,255,255,0.62); text-shadow: 0 1px 2px rgba(0,0,0,0.25); }
.cash-v {
  font-family: var(--serif); font-size: clamp(46px, 13vw, 60px); font-weight: 700;
  line-height: 1; letter-spacing: -0.02em; margin: var(--s3) 0 var(--s2);
  /* The sheen is kept to the right half, but a long balance can reach it;
     a soft shadow keeps white numerals readable where it does. */
  text-shadow: 0 1px 2px rgba(0,0,0,0.28);
}
.cash-sub { font-size: var(--t-sm); color: rgba(255,255,255,0.76); max-width: 46ch; text-shadow: 0 1px 2px rgba(0,0,0,0.25); }
/* The empty state is one sentence and reads better unbroken, but 46ch split it
   across two lines on a card with room to spare. The cap stays for the other
   copy on this card, which is genuinely two sentences and does want a measure.
   No nowrap: at 13px this needs about 500px, so a phone still has to wrap it
   and forcing one line there would push it off the card. */
.cash-sub-wide { max-width: none; }
.cash-sub strong { color: var(--on-dark-hi); }
.cash-cta { margin-top: var(--s5); }
/* --ink-fixed, not --ink: the button is white in both schemes, and in dark --ink
   is near-white too, which left "Spend it" as white on white. */
.cash-cta .btn { background: var(--white); border-color: var(--white); color: var(--ink-fixed); box-shadow: none; }
.cash-cta .btn:hover { background: var(--cream); border-color: var(--cream); }
/* The whole card is the way in, since 2026-08-26; the Open button it
   replaced was furniture on top of an object that already begged a tap. */
.cash-tap { cursor: pointer; transition: transform var(--t), box-shadow var(--t); }
.cash-tap:hover { transform: translateY(-2px); box-shadow: 0 4px 10px rgba(21,23,27,0.24), 0 22px 48px rgba(21,23,27,0.26); }
.cash-tap:active { transform: translateY(0); }

.meter { display: flex; height: 8px; border-radius: var(--r-full); overflow: hidden; background: rgba(255,255,255,0.14); margin: var(--s4) 0 var(--s3); }
.meter > span { display: block; height: 100%; transition: width var(--t); }
.meter-a { background: linear-gradient(90deg, var(--earn-lift), var(--earn)); }
/* Held for the next subscription bill. Still the family's money, so it reads as
   a dimmed version of available rather than as a fourth unrelated colour. */
.meter-h { background: var(--earn-held); }
.meter-r { background: rgba(255,255,255,0.66); }
.meter-x { background: var(--on-dark-dim); }
.meter-key { display: flex; flex-wrap: wrap; gap: var(--s4); font-size: var(--t-label); color: var(--on-dark); margin-bottom: var(--s3); }
.meter-key i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; margin-right: 6px; }
.meter-key .k-a { background: var(--earn); }
.meter-key .k-h { background: var(--earn-held); }
.meter-key .k-r { background: rgba(255,255,255,0.66); }
.meter-key .k-x { background: var(--on-dark-dim); }

/* ── The portal's ultra layer ─────────────────────────────────────────── */
/* The signed-in home learned its drama from AscendOS's home page, translated
   into this brand: where the dashboard goes navy glass, the portal goes ink,
   crimson and serif. Everything in this section is decoration over screens
   that already work without it, and all of it dies under the global
   reduced-motion kill switch. */

@keyframes rise {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: translateY(0); }
}
@keyframes bloom-drift {
  from { transform: translate(0, 0) scale(1); }
  to { transform: translate(6%, 7%) scale(1.1); }
}
@keyframes spark-draw {
  from { stroke-dashoffset: var(--spark-len, 600); }
  to { stroke-dashoffset: 0; }
}
@keyframes pulse-dot {
  0%, 100% { box-shadow: 0 0 0 0 rgba(26,127,67,0.45); }
  50% { box-shadow: 0 0 0 5px rgba(26,127,67,0); }
}
@keyframes bar-sheen {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
/* No `to`, so the fill animates from empty up to whatever width the markup
   set. Scoped to .reveal: it runs on the dressed first paint only. */
@keyframes bar-fill { from { width: 0; } }
.reveal .bar > span { animation: bar-fill 1100ms cubic-bezier(0.22, 1, 0.36, 1) 400ms backwards; }

/* First paint of the home rises in, section by section. The class goes on
   once per sign-in; re-renders from chat traffic paint the settled state. */
.reveal > * { animation: rise 480ms cubic-bezier(0.22, 1, 0.36, 1) backwards; }
.reveal > *:nth-child(1) { animation-delay: 30ms; }
.reveal > *:nth-child(2) { animation-delay: 90ms; }
.reveal > *:nth-child(3) { animation-delay: 150ms; }
.reveal > *:nth-child(4) { animation-delay: 210ms; }
.reveal > *:nth-child(5) { animation-delay: 270ms; }
.reveal > *:nth-child(6) { animation-delay: 330ms; }
.reveal > *:nth-child(7) { animation-delay: 390ms; }
.reveal > *:nth-child(8) { animation-delay: 450ms; }
.reveal > *:nth-child(9) { animation-delay: 510ms; }
.reveal > *:nth-child(10) { animation-delay: 570ms; }
.reveal > *:nth-child(11) { animation-delay: 630ms; }
.reveal > *:nth-child(12) { animation-delay: 690ms; }

/* The crimson bloom breathes, slowly enough to be felt rather than seen. */
.cash::before { animation: bloom-drift 11s ease-in-out infinite alternate; }

/* The family's earning history, drawn along the card's floor. The data only
   ever climbs (it is a running total), so the line always ascends. */
.cash-spark {
  position: absolute; left: 0; right: 0; bottom: -1px; width: 100%; height: 64px;
  pointer-events: none; opacity: 0.6;
}
.cash-spark path {
  fill: none; stroke: rgba(255,132,145,0.55); stroke-width: 2; stroke-linecap: round;
  stroke-dasharray: var(--spark-len, 600); stroke-dashoffset: var(--spark-len, 600);
  animation: spark-draw 1.6s cubic-bezier(0.4, 0, 0.2, 1) 400ms forwards;
}
.cash-spark .spark-area {
  stroke: none; fill: rgba(188,30,45,0.16);
  opacity: 0; animation: rise 900ms ease 900ms forwards;
}

/* Photography, used sparingly and always the marketing site's own shots, so
   the portal keeps a family on the same campus they saw before signing in.
   A card earns a photo when it stands for a place (the chamber, the practice
   room); nothing personal ever sits under one. */
.card-photo {
  display: block; overflow: hidden;
  margin: calc(-1 * var(--s5)) calc(-1 * var(--s5)) var(--s4);
  border-radius: calc(var(--r-md) - 1px) calc(var(--r-md) - 1px) 0 0;
}
.card-photo img {
  display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover;
  transition: transform 500ms ease;
}
/* Photographs are shown whole. Every photo box takes the picture's own
   proportions instead of a fixed height, so cover-fit never has anything to
   crop and nobody loses their head or their feet (Aditya, 2026-09-03). The
   cost is height: a 3:2 photo in the 728px column is 485px tall. Most of the
   shots are 3:2, which is the default above; the exceptions are listed here
   by their real pixel sizes. Add a line when a photo of another shape lands,
   or the box falls back to 3:2 and trims it. */
.card-photo img[src*="shop-ca-congress"] { aspect-ratio: 900 / 456; }
.card-photo img[src*="shop-ca-novice"] { aspect-ratio: 900 / 480; }
.card-photo img[src*="learning-hub"] { aspect-ratio: 1000 / 800; }
.card-photo img[src*="learning-practice"] { aspect-ratio: 1000 / 750; }
.card-photo img[src*="staff-team"] { aspect-ratio: 1200 / 674; }
.photo-band img[src*="staff-team"] { aspect-ratio: 1200 / 674; }
.cardlink:hover .card-photo img { transform: scale(1.04); }
/* A partner's own mark, shown whole on white rather than cover-cropped. Their
   logo is square and carries a wordmark; cover-fitting it into a 150px band
   would cut both. No hover zoom either — a zooming logo reads as a glitch. */
.card-photo.is-logo { background: var(--logo-tile); border-bottom: 1px solid var(--border); }
.card-photo.is-logo img { object-fit: contain; padding: 14px 18px; height: 116px; }
.cardlink:hover .card-photo.is-logo img { transform: none; }

/* A wide editorial band under a screen's greeting, in the voice of the
   site's article heroes: rounded, covered, a quiet scrim carrying a label. */
.photo-band {
  position: relative; overflow: hidden; border-radius: var(--r-lg);
  margin-bottom: var(--s5); box-shadow: var(--e2);
}
.photo-band img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; }
.photo-band::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(21,23,27,0) 52%, rgba(21,23,27,0.42));
}
.photo-band-cap {
  position: absolute; left: var(--s4); bottom: var(--s3); z-index: 1;
  color: var(--white); font-size: var(--t-label); font-weight: 600;
  letter-spacing: 0.14em; text-transform: uppercase;
  text-shadow: 0 1px 8px rgba(0,0,0,0.45);
}

/* A note from the CEO, carried over from the camp platform's parent overview.
   The photograph and the signature are the whole point: a paragraph signed with
   a typed name is a policy, and a face with handwriting under it is a person.
   The script is Great Vibes, which the login screen already loads to write its
   greeting, so this costs no extra font.
   flex-basis is small enough that the text stays beside the portrait on a
   360px phone rather than dropping under it, and min-width:0 is what actually
   lets it narrow instead of forcing the card wider than the screen. */
.ceo-note {
  display: flex; gap: var(--s4); align-items: flex-start; flex-wrap: wrap;
  margin: var(--s6) 0 0; padding: var(--s5);
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-lg); box-shadow: var(--e1);
}
.ceo-note img {
  flex: none; width: 84px; height: 84px; border-radius: 50%; object-fit: cover;
  box-shadow: 0 0 0 3px var(--white), 0 4px 14px rgba(21,23,27,0.20);
}
.ceo-note figcaption { flex: 1 1 170px; min-width: 0; }
.ceo-line {
  font-family: var(--serif); font-size: 19px; line-height: 1.35;
  color: var(--ink); margin: 6px 0 var(--s3);
}
.ceo-sign {
  font-family: 'Great Vibes', cursive; font-size: 34px; line-height: 1.25;
  color: var(--ink); margin: var(--s4) 0 0;
  padding-top: var(--s4); border-top: 1px solid var(--border);
}
.ceo-role {
  font-size: var(--t-label); font-weight: 500; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--muted); margin: 4px 0 0;
}

/* Somebody asking to be added to this family. Marked rather than plain, because
   it is a consent decision about the household's money and its children's
   records, and it is the one thing on the home screen asking a question.
   The two answers wrap and stay full-size on a phone: this is not a control to
   make small, and least of all the one that means no. */
.famreq { border-left: 3px solid var(--crimson); }
/* The educator home's opening card: a photograph, one line in the serif,
   and two buttons. It leads the screen because the call is the ask. */
.team-hero .team-hero-lead {
  font-family: var(--serif); font-size: 24px; font-weight: 700; line-height: 1.15;
  letter-spacing: -0.01em; margin: 0; color: var(--ink);
}
.team-hero .famreq-acts .btn { text-align: center; text-decoration: none; }
.famreq-acts { display: flex; flex-wrap: wrap; gap: var(--s3); }
.famreq-acts .btn { flex: 1 1 auto; min-width: 150px; min-height: 44px; }
.famreq-msg { min-height: 18px; }

/* A row with a leading mark (the channel monogram) and a body beside it. */
.med-row { display: flex; align-items: flex-start; gap: var(--s4); }
.med-body { flex: 1; min-width: 0; }

/* Channel monogram: a serif initial pair, ink normally, crimson when the
   channel is holding unread messages for you. */
.chan-av {
  flex: none; width: 42px; height: 42px; border-radius: 12px;
  display: grid; place-items: center;
  font-family: var(--serif); font-size: 18px; font-weight: 700; letter-spacing: 0.02em;
  background: var(--ink-fixed); color: var(--white);
  transition: background var(--t);
}
.chan-av.has-unread { background: var(--fill-crimson); }

/* Presence gets a heartbeat. */
.online-note::before {
  content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%;
  background: var(--ok); margin-right: 5px; vertical-align: 1px;
  animation: pulse-dot 2s ease-in-out infinite;
}

/* Every render after the first paints the finished state: line drawn, area
   settled, sheen spent. innerHTML rebuilds restart CSS animations, and chat
   traffic re-renders the home constantly. */
.settled .cash-spark path { animation: none; stroke-dashoffset: 0; }
.settled .cash-spark .spark-area { animation: none; opacity: 1; }
.settled .bar > span::after { animation: none; }

/* A single sheen crosses the progress bar after it fills, then rests. */
.bar > span { position: relative; overflow: hidden; }
.bar > span::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(105deg, transparent 32%, rgba(255,255,255,0.38) 50%, transparent 68%);
  background-size: 220% 100%; background-position: 200% 0;
  animation: bar-sheen 2.4s ease-in-out 1.5s 2;
}

/* ── The store ────────────────────────────────────────────────────────── */
.prod:hover .card-photo img { transform: scale(1.04); }
.prod-foot {
  margin-top: auto; padding-top: var(--s4);
  display: flex; justify-content: space-between; align-items: center;
  gap: var(--s3); flex-wrap: wrap;
}
.prod-thumb {
  flex: none; width: 72px; height: 72px; border-radius: 12px; overflow: hidden;
  border: 1px solid var(--border);
}
.prod-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

.store-cash-note {
  background: linear-gradient(180deg, var(--crimson-tint), rgba(188,30,45,0.03));
  border: 1px solid var(--crimson-line); border-left: 3px solid var(--crimson);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  padding: var(--s4); font-size: var(--t-sm); margin-bottom: var(--s5);
}
.prod-name { font-family: var(--serif); font-size: 21px; font-weight: 700; line-height: 1.15; letter-spacing: -0.01em; display: block; }
.prod-price { font-family: var(--serif); font-size: 24px; font-weight: 700; letter-spacing: -0.01em; white-space: nowrap; font-variant-numeric: tabular-nums; }
.prod-select, .reg-field select, .reg-field input, .reg-field textarea {
  width: 100%; box-sizing: border-box; min-height: 44px;
  font-family: var(--sans); font-size: var(--t-base); color: var(--ink);
  background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r-sm);
  padding: 10px 12px;
}
.reg-field textarea { resize: vertical; min-height: 60px; }
.reg-grid {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
  gap: var(--s3) var(--s4);
}
.reg-field label {
  display: block; font-size: var(--t-xs); font-weight: 600; color: var(--muted);
  margin-bottom: 4px;
}
.reg-req { color: var(--crimson); }
.reg-field [aria-invalid="true"] { border-color: var(--crimson); }
.reg-full { grid-column: 1 / -1; }
/* The reward, named where the price is. Crimson because it is the brand's
   good news, small because it must never outshout the price itself. */
/* Partner products are somebody else's brand on our shelf. The badge says so
   plainly rather than letting a family assume Ascend delivers it. */
.prod-partner {
  display: inline-block; font-size: var(--t-micro); font-weight: 700;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--white); background: var(--ink-fixed);
  padding: 2px 7px; border-radius: var(--r-full); vertical-align: 2px;
}

.earn-line {
  margin: 10px 0 0; font-size: var(--t-xs); color: var(--crimson); font-weight: 500;
}
.earn-line strong { font-weight: 700; font-variant-numeric: tabular-nums; }

/* ── Topbar: safe areas and reclaimed space ───────────────────────────── */
/*
 * Two problems, one real bug and one waste.
 *
 * THE BUG: index.html sets viewport-fit=cover, which extends the page under
 * the status bar, notch and Dynamic Island. The tab bar respected
 * safe-area-inset-bottom but the topbar never respected the top, so on every
 * notched iPhone the logo and back arrow rendered underneath the clock. The
 * left and right insets matter too, in landscape on those devices.
 *
 * THE WASTE: a 58px band carrying a single small logo on every screen. The
 * crest went to 30px and the padding got tighter, which gave roughly 20px back
 * on a phone. 30px turned out to lose the brand (Varun, 2026-09-03: too small
 * on mobile), so the crest is 38px and the padding keeps the saving. Pushed screens (a program, checkout, a
 * chat thread) still get the back arrow and the title, so the bar earns its
 * height exactly where it needs to.
 */
.topbar {
  padding-top: max(var(--s2), env(safe-area-inset-top, 0px));
  padding-left: max(var(--s4), env(safe-area-inset-left, 0px));
  padding-right: max(var(--s4), env(safe-area-inset-right, 0px));
  padding-bottom: 7px;
  gap: var(--s2);
  min-height: 0;
}
.topbar-brand img { height: 38px; }
#topbar-text { font-size: 17px; }

/* Desktop. 30px is a phone size, and on a wide screen the bar stranded the mark
   twice over: too small, and pinned to the window edge while the content sat in
   a 760px column starting hundreds of pixels away, so the logo pointed at
   nothing. Padding the bar in to the column's own left edge puts the mark
   directly above the greeting, and the bar keeps its full-width blur. */
@media (min-width: 640px) {
  .topbar {
    padding-left: max(var(--s4), env(safe-area-inset-left, 0px),
                      calc((100% - 760px) / 2 + var(--s4)));
    padding-right: max(var(--s4), env(safe-area-inset-right, 0px),
                       calc((100% - 760px) / 2 + var(--s4)));
    padding-top: var(--s3);
    padding-bottom: var(--s3);
  }
  .topbar-brand img { height: 46px; }
}
.topbar-back {
  /* Was easy to miss against the blur and under-sized for a thumb. */
  min-width: 40px; min-height: 40px;
}

/* Landscape on a notched phone puts content under the camera housing without
   these; the tab bar and page both need the horizontal insets. */
.view, .foot {
  padding-left: max(var(--s4), env(safe-area-inset-left, 0px));
  padding-right: max(var(--s4), env(safe-area-inset-right, 0px));
}
.tabbar {
  padding-left: env(safe-area-inset-left, 0px);
  padding-right: env(safe-area-inset-right, 0px);
}

/* ── Motion and materials ─────────────────────────────────────────────── */
/*
 * The last gap: the AscendCash card was the only surface with any material to
 * it, and everything appeared and disappeared instantly. Two rules hold here.
 *
 *   Motion fires on NAVIGATION only. The chat screens re-render on every
 *   inbound message, and animating those would turn the app into a
 *   screensaver (we already learned this once with the home's entrance).
 *   render() adds .nav-in only when the view actually changed.
 *
 *   Everything below is decoration over screens that work without it, and all
 *   of it is switched off by the global prefers-reduced-motion rule.
 */

@keyframes nav-in {
  from { opacity: 0; transform: translateY(7px); }
  to   { opacity: 1; transform: none; }
}
.view.nav-in { animation: nav-in 200ms cubic-bezier(0.22, 1, 0.36, 1); }

/* Paper. The page ground was a flat fill; this is a whisper of grain, the
   same trick the cash card uses, at a fraction of the strength. Fixed
   attachment so it reads as the surface the app sits on rather than
   something that scrolls with the content. */
body::before {
  content: ''; position: fixed; inset: 0; z-index: 0; pointer-events: none;
  opacity: 0.5;
  background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.035'/%3E%3C/svg%3E");
  background-size: 180px 180px;
}
.app { position: relative; z-index: 1; }

/* Photographs become objects rather than pasted rectangles: a scrim so any
   text below them sits on something, and an inner hairline so the image has
   an edge instead of bleeding into the card. */
.card-photo, .detail-hero, .photo-band { position: relative; }
.card-photo::after, .detail-hero::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px rgba(21,23,27,0.07),
              inset 0 -28px 34px -28px rgba(21,23,27,0.30);
}
.card-photo.is-logo::after, .detail-hero.is-logo::after { box-shadow: inset 0 0 0 1px rgba(21,23,27,0.05); }

/* Press. A row that does not answer the finger feels like a webpage. */
.rowlink { transition: opacity var(--t), background var(--t); border-radius: var(--r-sm); }
.rowlink:active { background: rgba(21,23,27,0.035); opacity: 1; }
.card.cardlink:active { transform: translateY(0) scale(0.995); }
.tab:active .tab-ico { transform: scale(0.9); }
.tab-ico { transition: transform 140ms cubic-bezier(0.34, 1.56, 0.64, 1); }
.tab.on .tab-ico { transform: translateY(-1px); }

/* The featured program is the one commerce object on the shelf, so it gets
   the same two-layer lift the cash card has rather than the flat card shadow. */
.prod-feature {
  box-shadow: 0 2px 5px rgba(21,23,27,0.06), 0 14px 30px rgba(21,23,27,0.10);
  transition: transform var(--t), box-shadow var(--t);
}
.prod-feature:hover {
  transform: translateY(-3px);
  box-shadow: 0 4px 9px rgba(21,23,27,0.08), 0 22px 44px rgba(21,23,27,0.13);
}

/* ── The refinement pass ──────────────────────────────────────────────── */
/*
 * The app read as competent rather than considered, for three reasons, all
 * fixed here:
 *
 *   1. Photography was wallpaper. Every card carried an image, so eight
 *      programs read as eight stock tiles. Imagery is punctuation now: one
 *      hero per screen, and secondary things carry none.
 *   2. There was no typographic contrast. The scale ran 10-16px with a 40px
 *      serif title above it and nothing in between, so every block of text
 *      weighed the same. --t-lead fills that gap for the one paragraph per
 *      screen that deserves to be read.
 *   3. Density was uniform. Every card had the same padding, border and
 *      shadow, which means no hierarchy. Secondary content is rows inside one
 *      container now, not a stack of equal cards.
 */

:root {
  /* The missing step between body copy and a display serif. */
  --t-lead: 17px;
}

/* One paragraph per screen, set to be read rather than scanned. */
.lede {
  margin: 0 0 var(--s6); max-width: 58ch;
  font-size: var(--t-lead); line-height: 1.6; color: var(--text-muted);
}

/* ── Rows: the quiet half of the hierarchy ────────────────────────────── */
/* Secondary destinations live as rows inside a single card, so they read as
   a list of options rather than as competing headlines. */
.rows {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-md); box-shadow: var(--e1);
  padding: 0 var(--s5); margin-bottom: var(--s3);
}
.rowlink {
  display: flex; align-items: center; gap: var(--s4); width: 100%;
  padding: var(--s4) 0; min-height: 64px;
  background: none; border: 0; border-bottom: 1px solid var(--border);
  font-family: var(--sans); color: inherit; text-align: left; text-decoration: none;
  cursor: pointer; transition: opacity var(--t);
}
.rowlink:last-child { border-bottom: none; }
.rowlink:hover { opacity: 0.62; }
.row-ico {
  flex: none; width: 34px; height: 34px; border-radius: 10px;
  display: grid; place-items: center; color: var(--crimson);
  background: var(--crimson-tint); border: 1px solid rgba(188,30,45,0.14);
}
.row-ico svg { width: 17px; height: 17px; display: block; }
/* Two letters where a state outline used to be. Set in the UI sans rather than
   the serif so it reads as a label on the chip and not as a dropped capital. */
.row-mono {
  font-family: var(--sans); font-size: var(--t-label); font-weight: 600;
  letter-spacing: 0.03em; line-height: 1; color: var(--crimson);
}
/* A real brand mark instead of a glyph. The tinted chip and its crimson border
   exist to give a one-colour icon somewhere to sit; a logo that is already
   crimson on a crimson wash only muddies, so the chip steps aside and the mark
   fills the slot. */
/* An explanation you ask for.
   A <details>, not a hover tooltip: this app is used on phones, where hover
   does not exist, so a title attribute would be an explanation layer most
   people could never reach. Closed it is one quiet line; open it is a panel.
   No JavaScript — the element does the work. */
.info { margin: var(--s4) 0 var(--s5); }
.info > summary {
  display: inline-flex; align-items: center; gap: var(--s2);
  min-height: 40px;                 /* a real tap target, per the mobile rules */
  padding: 0 var(--s2) 0 0;
  font-size: var(--t-sm); font-weight: 600; color: var(--muted);
  cursor: pointer; list-style: none;
  transition: color var(--t);
}
.info > summary::-webkit-details-marker { display: none; }
.info > summary:hover { color: var(--ink); }
.info > summary:focus-visible { outline: none; box-shadow: var(--focus); border-radius: var(--r-sm); color: var(--ink); }
.info-i {
  display: grid; place-items: center;
  width: 18px; height: 18px; flex: none;
  border-radius: var(--r-full);
  border: 1.5px solid currentColor;
  font-size: 11px; font-weight: 700; font-style: italic;
  line-height: 1;
}
/* The marker turns, so open and closed are distinguishable without colour. */
.info > summary::after {
  content: '›';
  font-size: 17px; line-height: 1;
  transform: rotate(90deg);
  transition: transform var(--t);
}
.info[open] > summary::after { transform: rotate(-90deg); }
.info-body {
  margin-top: var(--s3);
  padding: var(--s4) var(--s5);
  background: var(--surface);
  border: 1px solid var(--line, var(--border));
  border-left: 3px solid var(--crimson);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  max-width: 62ch;
}
.info-body p { margin: 0 0 var(--s3); font-size: var(--t-sm); line-height: 1.6; }
.info-body p:last-child { margin-bottom: 0; }
.info-body p.muted { color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
  .info > summary::after { transition: none; }
}

/* The progress line under a description. Quieter than .row-sub because it is
   the second thing you want from that row, not the first. */
.row-stat {
  display: block;
  margin-top: 3px;
  font-size: var(--t-xs);
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.row-ico.is-mark { background: none; border: 0; border-radius: 0; }
.row-ico.is-mark img { width: 30px; height: 30px; object-fit: contain; display: block; }
/* A partner's mark sits on its own small tile, the same 34px and radius as the
   chips beside it, so it reads as one of the row icons rather than as a
   sticker. Only partner marks: our shield is cut out and needs no ground. */
.row-ico.is-mark img[src*="montara"] {
  width: 28px; height: 28px; padding: 3px; border-radius: 9px;
  background: var(--logo-tile); box-shadow: inset 0 0 0 1px rgba(21,23,27,0.08);
}
/* A photograph in the slot. Larger than the glyph chip, because a face at 34px
   is a smudge and at 44px is a person; the row's own padding absorbs the
   difference. The chip's tint and border go, since a photo is its own edge. */
.row-ico.is-photo {
  width: 44px; height: 44px; border-radius: 10px;
  background: var(--surface-2); border: 0; overflow: hidden;
}
.row-ico.is-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Square crops want the face, not the room. Tuned by rendering the thumbnail. */
.row-ico.is-photo img[src*="shop-ca-congress"] { object-position: 42% 12%; }
.row-ico.is-photo img[src*="shop-ca-novice"] { object-position: 62% 30%; }
.row-ico.is-photo img[src*="shop-ca-1on1"] { object-position: 68% 25%; }
.row-ico.is-photo img[src*="shop-fl-congress"] { object-position: 45% 40%; }
.row-ico.is-photo img[src*="shop-fl-novice"] { object-position: 35% 45%; }
.row-ico.is-photo img[src*="shop-online"] { object-position: 55% 40%; }
.row-body { flex: 1; min-width: 0; }
/* display:block is load-bearing. These are spans, so without it the title and
   subtitle only appear stacked when the text happens to be long enough to
   wrap: "Your family" and "Varun, Anita" sat on one line while every other
   row looked correct. Accidental layout that works for some content is worse
   than layout that is obviously wrong. */
.row-title { display: block; font-size: var(--t-row); font-weight: 600; letter-spacing: -0.005em; }

/* A catalogue row listed among programme rows.
   .prod-row sets a serif display title, which is right on the Summer shelf
   where every row in the list shares it. Inside the Paid programs card it was
   wrong: that row sits directly beneath Middle School and Sandel, and a serif
   19px title next to their sans 15px ones, with no icon slot to line up
   against, read as a different kind of thing rather than the third item in a
   list. This puts it back on the same footing as its neighbours. */
.prod-row.as-program .row-title {
  font-family: var(--sans);
  font-size: var(--t-row);
  font-weight: 600;
  letter-spacing: -0.005em;
}
.row-sub {
  display: block; margin-top: 2px; font-size: var(--t-sm); color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.row-meta {
  flex: none; display: flex; align-items: center; gap: var(--s3);
  font-size: var(--t-sm); color: var(--muted); font-variant-numeric: tabular-nums;
}
.row-chev { flex: none; color: var(--border-strong); font-size: 18px; line-height: 1; }

/* A progress bar inside a row stays hairline-thin; it is a hint, not a chart. */
.row-bar { display: block; height: 4px; border-radius: var(--r-full); background: var(--cream); overflow: hidden; margin-top: 7px; }
.row-bar > span { display: block; height: 100%; background: linear-gradient(90deg, var(--crimson), var(--crimson-light)); }

/* ── Programs: one featured, the rest as a list ───────────────────────── */
.prod-feature { margin-bottom: var(--s5); }
.prod-feature .prod-name { font-size: 27px; }
.prod-feature .prod-summary { -webkit-line-clamp: 4; font-size: var(--t-base); }
.feature-flag {
  display: inline-block; margin-bottom: var(--s3);
  font-size: var(--t-micro); font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--crimson);
}
/* Secondary programs: no photograph, tighter, priced on the right. */
.prod-row .row-title { font-family: var(--serif); font-size: 19px; font-weight: 700; letter-spacing: -0.01em; }
.prod-row .row-price { font-weight: 600; color: var(--ink); white-space: nowrap; }

/* ── The tab bar ──────────────────────────────────────────────────────── */
/* Five destinations, fixed to the bottom because this is a phone product
   first. Deliberately quiet: a hairline, a near-white ground, and colour only
   on the tab you are standing in. */
/* Crimson, and still a material: kept translucent over the blur so content
   scrolling under it tints the bar rather than disappearing behind a flat
   slab. Literal rgba rather than the token because --crimson is a hex and this
   needs an alpha, which is the same reason .status-soon spells it out. */
.tabbar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
  display: flex; align-items: stretch;
  background: rgba(188,30,45,0.94);
  -webkit-backdrop-filter: saturate(180%) blur(12px);
  backdrop-filter: saturate(180%) blur(12px);
  border-top: 1px solid rgba(255,255,255,0.14);
  padding-bottom: env(safe-area-inset-bottom, 0);
}
.tab {
  position: relative; flex: 1 1 0; min-width: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  padding: 9px 2px 8px; min-height: 56px;
  background: none; border: 0; cursor: pointer;
  font-family: var(--sans);
  /* 0.85 rather than something dimmer: at 10px this has to clear 4.5:1 against
     the crimson, and it lands at 4.8. Anything quieter reads as disabled and
     fails the contrast besides. */
  color: rgba(255,255,255,0.85);
  transition: color var(--t);
}
.tab-ico { display: block; width: 22px; height: 22px; }
.tab-ico svg { width: 100%; height: 100%; display: block; }
.tab-label {
  font-size: 10px; font-weight: 600; letter-spacing: 0.02em;
  max-width: 100%; overflow: hidden;
  /* Ascend Academy needs two lines at 360px, where five tabs leave about 72px
     each. Centred and tightly led so a two-line label sits level with the
     one-line ones rather than pushing the bar taller. */
  text-align: center; line-height: 1.15; white-space: normal;
}
/* The active tab used to be marked by turning crimson, which is no longer
   available on a crimson bar. Full white is only a shade up from 0.85, so the
   real signal is a short rule at the top of the tab — the same move the camp
   platform's nav makes, upside down. */
.tab.on { color: var(--white); }
.tab.on::before {
  content: ''; position: absolute; top: 0; left: 50%; transform: translateX(-50%);
  width: 26px; height: 2px; border-radius: 0 0 2px 2px; background: var(--white);
}
/* Unread lives on the tab, so a message is visible from anywhere in the app.
   Inverted with the bar: crimson on white was right when the bar was white, and
   on a crimson one it is a hole rather than a badge. */
.tab-dot {
  position: absolute; top: 5px; left: 50%; margin-left: 5px;
  min-width: 16px; height: 16px; padding: 0 4px; border-radius: var(--r-full);
  background: var(--white); color: var(--crimson);
  font-size: 9px; font-weight: 700; line-height: 16px; text-align: center;
}
/* The bar floats over content, so the page needs room to finish above it. */
body.has-tabs .view { padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px)); }
body.has-tabs .foot { margin-bottom: calc(64px + env(safe-area-inset-bottom, 0px)); }

.res-kind {
  font-size: var(--t-micro); font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--muted); white-space: nowrap;
}

/* Browse card: the shelf answers "what does Ascend run", so the card carries
   dates and a summary rather than a variant picker. */
.prod-when {
  margin: 8px 0 0; font-size: var(--t-xs); font-weight: 600; color: var(--ink);
}
.prod-summary {
  margin: 6px 0 0; font-size: var(--t-sm); color: var(--muted); line-height: 1.5;
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}

/* Program detail. The hero runs wider than a shelf thumbnail because this is
   the screen where someone decides. */
.detail-hero {
  border-radius: var(--r-lg); overflow: hidden; margin-bottom: var(--s5); box-shadow: var(--e2);
}
.detail-hero img { display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; }
.detail-hero img[src*="shop-ca-congress"] { aspect-ratio: 900 / 456; }
.detail-hero img[src*="shop-ca-novice"] { aspect-ratio: 900 / 480; }
.detail-hero.is-logo { background: var(--logo-tile); border: 1px solid var(--border); box-shadow: none; }
.detail-hero.is-logo img { object-fit: contain; padding: 24px; height: clamp(130px, 22vw, 180px); }
.detail-lede {
  margin: 0 0 var(--s5); font-size: var(--t-md); line-height: 1.65; color: var(--ink); max-width: 60ch;
}
.includes { margin: 0; padding: 0; list-style: none; }
.includes li {
  position: relative; padding: 8px 0 8px 26px; font-size: var(--t-sm);
  border-bottom: 1px solid var(--border);
}
.includes li:last-child { border-bottom: none; padding-bottom: 0; }
.includes li::before {
  content: ''; position: absolute; left: 4px; top: 15px;
  width: 7px; height: 7px; border-radius: 50%; background: var(--crimson);
}

.store-pay { margin-top: var(--s6); }
.store-pay .btn { width: 100%; max-width: 420px; }
.cover-full {
  display: flex; align-items: flex-start; gap: 10px; cursor: pointer;
  background: linear-gradient(180deg, var(--crimson-tint), rgba(188,30,45,0.03));
  border: 1px solid var(--crimson-line); border-radius: var(--r-sm);
  padding: 12px 14px; margin: 0 0 var(--s4); font-size: var(--t-sm); font-weight: 600;
}
.cover-full input { width: 18px; height: 18px; margin: 2px 0 0; accent-color: var(--crimson); flex: none; }

.ledger-row {
  display: flex; justify-content: space-between; gap: var(--s3);
  padding: var(--s3) 0; border-bottom: 1px solid var(--border); font-size: var(--t-sm);
}
.ledger-row:first-child { padding-top: 0; }
.ledger-row:last-child { border-bottom: none; padding-bottom: 0; }
.ledger-amt { font-weight: 600; color: var(--ok); white-space: nowrap; }
.ledger-amt.spent { color: var(--muted); }
.ledger-amt.is-spend { color: var(--crimson); }
/* Two numbers in the amount column: what was paid, in ink, and what it did to
   the AscendCash balance beneath, small and labelled. The credit used to stand
   alone and read as the price. */
.ledger-money { display: flex; flex-direction: column; align-items: flex-end; text-align: right; flex: none; line-height: 1.25; }
.ledger-paid { font-weight: 600; color: var(--ink); white-space: nowrap; font-variant-numeric: tabular-nums; }
.ledger-amt.is-earn { font-size: var(--t-xs); font-weight: 600; }
.ledger-note { font-size: var(--t-micro); letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted); white-space: nowrap; }
/* The receipt line under a payment: a button that becomes the links. Compact
   controls, because they sit inside a 13px ledger row; the 40px .linklike
   target would double the row's height. */
.receipt-act { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; margin-top: 4px; font-size: var(--t-xs); }
.receipt-act .linklike { min-height: 28px; padding: 0; font-size: var(--t-xs); color: var(--crimson); font-weight: 600; text-decoration: none; }
.receipt-act .linklike:hover { text-decoration: underline; }
.receipt-act .linklike:disabled { color: var(--muted); cursor: default; text-decoration: none; }
.receipt-act a { color: var(--crimson); font-weight: 600; text-decoration: none; min-height: 28px; display: inline-flex; align-items: center; }
.receipt-act a:hover { text-decoration: underline; }
.receipt-dot { color: var(--muted); }
.receipt-err { flex-basis: 100%; color: var(--warn-ink); }
/* The fold at the foot of the ledger: full width, so the tap target is the
   whole strip rather than a word, and set off from the last row by a rule. */
.feed-toggle {
  display: flex; justify-content: center; width: 100%; min-height: 44px;
  margin-top: var(--s3); padding-top: var(--s3); border-top: 1px solid var(--border);
  font-size: var(--t-sm); font-weight: 600; color: var(--crimson); text-decoration: none;
}
.feed-toggle:hover { text-decoration: underline; }

.expiring {
  background: linear-gradient(180deg, var(--crimson-tint), rgba(188,30,45,0.03));
  border: 1px solid var(--crimson-line);
  border-left: 3px solid var(--crimson); border-radius: 0 var(--r-sm) var(--r-sm) 0;
  padding: var(--s4); font-size: var(--t-sm); margin-bottom: var(--s5);
}

/* Catalog */
.catalog { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); gap: var(--s3); }
.cat {
  border: 1px solid var(--border); border-radius: var(--r-md); background: var(--surface);
  padding: var(--s5); min-width: 0; display: flex; flex-direction: column;
  box-shadow: var(--e1); transition: box-shadow var(--t), border-color var(--t);
}
.cat:hover { box-shadow: var(--e2); }
.cat-short { background: var(--surface-2); box-shadow: none; }
.cat-wide { grid-column: 1 / -1; }
.cat-top { display: flex; justify-content: space-between; align-items: baseline; gap: var(--s3); margin-bottom: 6px; }
.cat-name { font-family: var(--serif); font-size: 21px; font-weight: 700; line-height: 1.12; letter-spacing: -0.01em; }
.cat-name-sm { font-size: 17px; }
.cat-price { font-weight: 600; font-size: var(--t-base); white-space: nowrap; }
.cat-note { font-size: var(--t-xs); color: var(--muted); margin: 0 0 var(--s4); flex: 1; }
.cat-btn { width: 100%; }
.cat-lock { font-size: var(--t-xs); color: var(--muted); margin-bottom: var(--s2); }
.cat .bar { margin-top: 0; }
.cat .status { align-self: flex-start; }
.cat-toward {
  font-size: var(--t-label); font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--muted); margin-bottom: var(--s3);
}

.chips { display: flex; flex-wrap: wrap; gap: var(--s2); }
.chip {
  font-family: var(--sans); font-size: var(--t-sm); font-weight: 600;
  padding: 9px 15px; min-height: 42px; border-radius: var(--r-full);
  border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink); cursor: pointer;
  transition: border-color var(--t), color var(--t), background var(--t), transform var(--t);
}
.chip:hover:not(:disabled) { border-color: var(--crimson); color: var(--crimson); }
.chip:active:not(:disabled) { transform: translateY(1px); }
.chip-all { border-color: var(--ink-fixed); background: var(--ink-fixed); color: var(--white); }
.chip-all:hover:not(:disabled) { background: var(--fill-crimson); border-color: var(--fill-crimson); color: var(--white); }
.chip:disabled { opacity: 0.42; cursor: not-allowed; }
.opts { display: flex; flex-wrap: wrap; gap: var(--s2); margin-bottom: var(--s5); }

.cat-wide { }
.switch { display: flex; align-items: flex-start; gap: var(--s3); font-size: var(--t-base); cursor: pointer; line-height: 1.45; min-height: 44px; padding: var(--s2) 0; }
.switch input { width: 20px; height: 20px; margin: 2px 0 0; flex: none; accent-color: var(--crimson); cursor: pointer; }
.switch input:disabled { cursor: not-allowed; }
.freebie {
  background: var(--ok-bg); border: 1px solid var(--ok-line); border-left: 3px solid var(--ok);
  border-radius: 0 var(--r-sm) var(--r-sm) 0; padding: var(--s4); font-size: var(--t-sm);
  color: var(--ok-ink); margin-bottom: var(--s4);
}

/* Checkout */
.kv { display: flex; justify-content: space-between; gap: var(--s4); padding: var(--s3) 0; border-bottom: 1px solid var(--border); font-size: var(--t-base); }
.kv:first-child { padding-top: 0; }
.kv:last-child { border-bottom: none; padding-bottom: 0; }
.kv-k { color: var(--muted); }
.kv-v { text-align: right; font-weight: 500; }
.kv-total { border-top: 1.5px solid var(--ink); border-bottom: none; padding-top: var(--s4); margin-top: var(--s2); }
.kv-total .kv-k { color: var(--ink); font-weight: 600; }
.kv-total .kv-v { font-family: var(--serif); font-size: 26px; font-weight: 700; letter-spacing: -0.01em; }
.kv select {
  font-family: var(--sans); font-size: var(--t-base); padding: 9px 11px; min-height: 44px;
  border: 1px solid var(--border-strong); border-radius: var(--r-sm); background: var(--surface);
  color: var(--ink); max-width: 100%;
}
.held {
  background: var(--ok-bg); border: 1px solid var(--ok-line); border-left: 3px solid var(--ok);
  border-radius: 0 var(--r-sm) var(--r-sm) 0; padding: var(--s4); font-size: var(--t-sm);
  color: var(--ok-ink); margin: var(--s5) 0;
}
.amt-row { margin-top: var(--s5); }
.amt-label { display: block; font-size: var(--t-label); font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin-bottom: var(--s2); }
.amt-input { display: flex; align-items: stretch; gap: var(--s2); flex-wrap: wrap; }
.amt-cur { display: grid; place-items: center; padding-left: 2px; font-weight: 600; color: var(--muted); }
.amt-input input {
  flex: 1; min-width: 110px; padding: 12px; font-size: var(--t-md); font-family: var(--sans);
  border: 1px solid var(--border-strong); border-radius: var(--r-sm); background: var(--surface); color: var(--ink);
  transition: border-color var(--t), box-shadow var(--t);
}
.amt-input input:focus { border-color: var(--crimson); outline: none; box-shadow: var(--focus); }
.amt-err { color: var(--crimson); font-size: var(--t-xs); margin: var(--s2) 0 0; }

.live-actions { display: flex; flex-wrap: wrap; gap: var(--s2); }
.live-actions .btn { flex: 1; min-width: 140px; }

.flash {
  background: var(--ok-bg); border: 1px solid var(--ok-line); border-left: 3px solid var(--ok);
  border-radius: 0 var(--r-sm) var(--r-sm) 0; padding: var(--s4); font-size: var(--t-sm);
  color: var(--ok-ink); margin-bottom: var(--s5);
  animation: flash-in 260ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes flash-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }

/* ── Student lens ─────────────────────────────────────────────────────── */
.viewer { display: flex; flex-wrap: wrap; gap: var(--s3); align-items: center; margin-bottom: var(--s6); }
.viewer-row { display: flex; flex-wrap: wrap; gap: var(--s3); align-items: center; justify-content: space-between; }
.viewer-row .viewer { margin-bottom: 0; }
.viewer-row { margin-bottom: var(--s6); }
.hh-switch {
  font-family: var(--sans); font-size: var(--t-sm); color: var(--ink);
  padding: var(--s2) var(--s3); min-height: 38px; max-width: 100%;
  border: 1px solid var(--border-strong); border-radius: var(--r-sm);
  background: var(--surface);
}
.viewer select {
  font-family: var(--sans); font-size: var(--t-sm); padding: 9px 11px; min-height: 40px;
  border: 1px solid var(--border-strong); border-radius: var(--r-sm); background: var(--surface); color: var(--ink);
  max-width: 100%;
}
.cardlink { display: block; width: 100%; text-align: left; font-family: var(--sans); font-size: var(--t-base); color: inherit; cursor: pointer; text-decoration: none; transition: transform var(--t), box-shadow var(--t), border-color var(--t); }
.cardlink:hover { transform: translateY(-2px); box-shadow: var(--e3); border-color: var(--border-strong); }
.cardlink:active { transform: translateY(0); }

.bar { display: block; height: 7px; border-radius: var(--r-full); background: var(--cream); overflow: hidden; margin-top: var(--s3); }
.bar > span { display: block; height: 100%; background: linear-gradient(90deg, var(--crimson), var(--crimson-light)); border-radius: var(--r-full); transition: width var(--t); }

.when-row { display: flex; gap: var(--s4); padding: var(--s4) 0; border-bottom: 1px solid var(--border); }
.when-row:first-child { padding-top: 0; }
.when-row:last-child { border-bottom: none; padding-bottom: 0; }
.when-date { flex: none; width: 52px; text-align: center; font-family: var(--serif); line-height: 1.02; padding-top: 2px; }
.when-mon { display: block; font-size: var(--t-micro); font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--crimson); font-family: var(--sans); }
.when-day { display: block; font-size: 27px; font-weight: 700; letter-spacing: -0.02em; }
.when-body { min-width: 0; flex: 1; }
.when-title { font-weight: 600; font-size: var(--t-base); }
.when-sub { font-size: var(--t-xs); color: var(--muted); }

.skills { margin-top: var(--s4); }
.skill { display: flex; align-items: center; gap: var(--s3); margin-bottom: var(--s2); font-size: var(--t-xs); }
.skill-n { flex: none; width: 108px; color: var(--muted); }
.skill .bar { flex: 1; margin-top: 0; min-width: 0; }
.skill-v { flex: none; width: 16px; text-align: right; font-weight: 600; }

.mod { display: flex; gap: var(--s3); align-items: baseline; padding: var(--s2) 0; font-size: var(--t-sm); color: var(--muted); }
.mod.is-done { color: var(--ink); }
.mod-tick { flex: none; width: 16px; color: var(--ok); font-weight: 700; }
.mod-body { min-width: 0; }

.badges { display: flex; flex-wrap: wrap; gap: var(--s2); }
.badge {
  font-size: var(--t-xs); padding: 8px 13px; border-radius: var(--r-full);
  border: 1px solid var(--border); background: var(--surface-2); color: var(--muted);
  opacity: 0.5; transition: opacity var(--t);
}
.badge.is-earned { opacity: 1; background: var(--surface); color: var(--ink); border-color: var(--ink); box-shadow: var(--e1); }

/* ── Admin and records lenses: tools, kept plain on purpose ───────────── */
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 138px), 1fr)); gap: var(--s2); margin-bottom: var(--s5); }
.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--s4); min-width: 0; box-shadow: var(--e1); }

.search {
  width: 100%; padding: 13px 15px; font-size: var(--t-md); font-family: var(--sans);
  border: 1px solid var(--border-strong); border-radius: var(--r-sm); margin-bottom: var(--s4); background: var(--surface);
  transition: border-color var(--t), box-shadow var(--t);
}
.search:focus { border-color: var(--crimson); outline: none; box-shadow: var(--focus); }

.warn {
  background: var(--warn-bg); border: 1px solid var(--warn-line);
  border-left: 3px solid var(--warn-strong); border-radius: 0 var(--r-sm) var(--r-sm) 0;
  padding: var(--s4); font-size: var(--t-sm); margin-bottom: var(--s3);
}
.warn code, .card code { font-size: var(--t-label); color: var(--muted); }
/* The boot-failed card's button, on its own line under the sentence. */
.boot-retry { margin-top: var(--s3); }
.card .btn:disabled, .kv select:disabled { opacity: 0.42; cursor: not-allowed; }

/* ── Chat ─────────────────────────────────────────────────────────────── */
.chan-row { text-align: left; }
/* nowrap inside a flex/grid child needs min-width:0 on the child or it pushes
   past the viewport instead of truncating. This has bitten AscendChat twice. */
.chan-last {
  margin: var(--s2) 0 0; font-size: var(--t-sm); color: var(--text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
}

/* The thread, in AscendChat's bubble language: own messages right-aligned in a
   solid crimson bubble, everyone else left on a warm grey, 18px corners that
   tighten to 6px where a same-author run stacks. The geometry is borrowed from
   AscendChat, and so is the blue: Varun chose the literal iMessage colour over
   the brand crimson (2026-08-24). Measured honestly: white on #007aff is
   4.02:1, under the 4.5 AA threshold for 15px text. iMessage itself ships this
   exact pairing and AscendChat copies it, so we accept it knowingly rather
   than by accident; the accessible alternative on record is the crimson this
   replaced, at 6.25:1. */
/* The channel screen is a column that fills the viewport: thread grows, the
   composer dock rides the bottom edge, and an empty channel centers its one
   line instead of leaving the furniture floating at the top of a white page. */
.view.chat-screen { display: flex; flex-direction: column; padding-bottom: 0; }
.thread { display: flex; flex-direction: column; margin-bottom: var(--s5); flex: 1; }
.thread-empty {
  flex: 1; display: flex; align-items: center; justify-content: center;
  min-height: 200px; text-align: center;
  color: var(--muted); font-size: var(--t-base); line-height: 1.7;
}
.thread-empty p { margin: 0; }

.composer-dock {
  position: sticky; bottom: 0; z-index: 10;
  margin-top: auto; background: var(--surface);
  padding: 0 0 max(var(--s3), env(safe-area-inset-bottom));
}

/* ── The programme's mark, behind its conversation ──────────────────────────
   Two background layers on the scroll container: the lockup underneath, and a
   flat wash of the thread's own ground over the top of it. The wash is what
   makes it faint, because CSS cannot set the opacity of a background image by
   itself, and a pseudo-element would have to fight the composer dock for a
   stacking context it already owns.

   On the container rather than on the thread, because a background on a
   scrolling box holds still while its content moves. A watermark that scrolls
   away is just a picture at the top of the room.

   Deliberately not on the inbox: it belongs to one programme, and the channel
   list belongs to all of them. */
.has-watermark {
  background-image: linear-gradient(var(--wm-veil), var(--wm-veil)), var(--chat-wm);
  background-repeat: no-repeat, no-repeat;
  background-position: center, center;
  /* Contained and capped: large enough to read as the room's own mark, never
     so large that a wide desktop panel turns it into wallpaper. */
  background-size: auto, min(58%, 300px) auto;
}

/* ── Thread gestures ────────────────────────────────────────────────────────
   Drag a bubble right to reply to it; drag the thread left to read the times.
   Both are additive: the reply menu item and the per-group timestamp are still
   there for anyone who does not gesture, and on a desktop nothing here fires. */
.msg-row { position: relative; touch-action: pan-y; }
/* Follows the finger. No transition while dragging, or the row lags behind it;
   the snap back on release is the only part that should be animated. */
.msg-row.swipe-armed { transition: transform 0.16s ease-out; }
/* The reply mark, sitting in the space the row opens up as it slides right. */
.msg-row::before {
  content: '↩';
  position: absolute; left: -30px; top: 50%;
  transform: translateY(-50%);
  font-size: 15px; color: var(--chat-meta);
  opacity: 0; transition: opacity var(--t);
  pointer-events: none;
}
.msg-row.swipe-armed::before { opacity: 1; color: var(--chat-accent); }

/* The times, parked off the right edge until the thread slides out from under
   them. Sliding the rows rather than inserting a column means the bubbles keep
   their widths and nothing reflows mid-drag. */
.msg-peek {
  /* display: none until the thread is peeking: parked at right:-56px, a
     visible-but-transparent label widens every phone thread by 41px and the
     page scrolls sideways. */
  display: none;
  position: absolute; right: -56px; top: 50%;
  transform: translateY(-50%);
  font-size: 10px; color: var(--chat-meta);
  white-space: nowrap; opacity: 0;
  pointer-events: none;
}
.thread { --peek: 0px; }
.thread.peeking .msg-row { transform: translateX(var(--peek)); }
.thread.peeking .msg-peek { display: block; opacity: 0.85; }
/* Snaps back rather than jumping when the finger comes off. */
.thread:not(.peeking) .msg-row { transition: transform 0.18s ease-out; }

/* ── Link preview card ──────────────────────────────────────────────────────
   Sits under the bubble rather than inside it, so the card keeps the app's own
   surface instead of inheriting a blue or grey ground it was never designed
   against, and so a long title cannot stretch the bubble. */
.unfurl:empty { display: none; }
.uf-card {
  display: flex; margin-top: 4px; max-width: 100%;
  border: 1px solid var(--chat-line); border-radius: var(--r-sm);
  overflow: hidden; background: var(--surface);
  text-decoration: none; color: inherit;
}
.uf-card:hover { border-color: var(--border-strong); }
.uf-img { flex: none; width: 72px; background: var(--chat-them-bg); }
.uf-img img { width: 72px; height: 100%; object-fit: cover; display: block; }
.uf-body {
  display: flex; flex-direction: column; gap: 1px;
  padding: 8px 10px; min-width: 0;
}
.uf-site { font-size: 10px; color: var(--chat-meta); text-transform: uppercase; letter-spacing: 0.04em; }
/* Two lines of title, one of description, and an ellipsis after: someone
   else's page should not be able to set the height of a message. */
.uf-title {
  font-size: 12px; font-weight: 650; color: var(--ink); line-height: 1.3;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
}
.uf-desc {
  font-size: 11px; color: var(--muted); line-height: 1.35;
  display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical;
  overflow: hidden;
}
/* The card is the app's, not the bubble's, so it keeps its own link colour
   rather than the white-on-blue the bubble sets for links. */
.msg-mine .msg-col .uf-card, .msg-them .msg-col .uf-card { color: var(--ink); }

/* ── Photo lightbox ─────────────────────────────────────────────────────────
   An attachment used to open in a new browser tab, which on an installed PWA
   means leaving the app to look at a photo. */
.lightbox {
  position: fixed; inset: 0; z-index: 60;
  background: rgba(0, 0, 0, 0.92);
  display: flex; align-items: center; justify-content: center;
  padding: 40px 12px;
}
.lightbox-img {
  max-width: 100%; max-height: 100%;
  object-fit: contain; border-radius: var(--r-sm);
}
.lightbox-close, .lightbox-nav {
  position: absolute; border: 0; cursor: pointer;
  background: rgba(255, 255, 255, 0.14); color: var(--white);
  border-radius: 50%; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
}
.lightbox-close { top: 14px; right: 14px; width: 40px; height: 40px; font-size: 24px; }
.lightbox-nav { top: 50%; transform: translateY(-50%); width: 44px; height: 44px; font-size: 30px; }
.lightbox-prev { left: 10px; }
.lightbox-next { right: 10px; }
.lightbox-close:hover, .lightbox-nav:hover { background: rgba(255, 255, 255, 0.26); }
.lightbox-close:focus-visible, .lightbox-nav:focus-visible { outline: 2px solid var(--white); outline-offset: 2px; }
.lightbox-count {
  position: absolute; bottom: 14px; left: 0; right: 0;
  margin: 0; text-align: center;
  font-size: var(--t-xs); color: rgba(255, 255, 255, 0.75);
}

/* Jump to latest. Zero height and sticky to the bottom of the scroll port, so
   it floats over the last of the thread instead of taking a line from it, and
   sits just under the composer's z-index rather than over it. */
.jump-wrap {
  position: sticky; bottom: 0; z-index: 9;
  height: 0; display: flex; justify-content: flex-end;
  pointer-events: none;
}
.jump-latest {
  pointer-events: auto;
  transform: translateY(-14px);
  width: 38px; height: 38px; border-radius: 50%;
  border: 1px solid var(--chat-line);
  background: var(--surface); color: var(--chat-meta);
  font-size: 17px; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer;
  box-shadow: 0 4px 14px rgba(21, 23, 27, 0.14);
  transition: color var(--t), border-color var(--t);
}
.jump-latest:hover { color: var(--ink); border-color: var(--border-strong); }
.jump-latest:focus-visible { outline: none; box-shadow: var(--focus); }
body.chat-chrome .foot { display: none; }

.msg-row {
  display: flex; gap: 6px; align-items: flex-end;
  padding: 1px 4px; min-width: 0; border-radius: 6px;
}
.msg-row:hover { background: rgba(0,0,0,0.03); }
.msg-them { justify-content: flex-start; }
.msg-mine { justify-content: flex-end; }
/* Runs stack tight; a new speaker or a pause gets air. 8px/1px is CampChat's
   spacing verbatim. */
.msg-solo, .msg-first { margin-top: 8px; }
.msg-mid, .msg-last { margin-top: 1px; }

.msg-avatar {
  width: 28px; height: 28px; border-radius: 50%; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--white); font-size: 11px; font-weight: 700;
  margin-bottom: 2px; user-select: none;
}
/* Above the bubble's tail. The tail is drawn as two pieces, the outer one
   painted in the thread's own background to trim the curve, and that piece
   reaches 20px to the left of the bubble: measured, it covered 14px of the
   28px avatar and sheared the circle flat. The tail still shows in the 6px
   gap between them, which is all of it that was ever meant to be visible. */
.msg-avatar { position: relative; z-index: 1; }
.presence-dot {
  position: absolute; right: -1px; bottom: -1px; width: 9px; height: 9px;
  border-radius: 50%; background: var(--chat-online); border: 2px solid var(--white);
}
.msg-avatar-spacer { width: 28px; flex: none; }
.online-note { color: var(--ok); font-weight: 600; }
.read-receipts {
  font-size: 11px; color: var(--chat-meta); text-align: right; margin: 2px 6px 0;
}

.msg-col { display: flex; flex-direction: column; max-width: 82%; min-width: 0; }
.msg-them .msg-col { align-items: flex-start; }
.msg-mine .msg-col { align-items: flex-end; }

.msg-head { margin: 0 6px 3px; }
/* iOS secondary grey, not the brand's muted token: this surface follows the
   references, and the difference between #444 and #6e6e73 is visible. */
.msg-who { font-size: 11px; font-weight: 600; color: var(--chat-meta); }
.msg-who.is-staff { color: var(--crimson); }

.msg-bubble {
  padding: 9px 14px; border-radius: 18px;
  font-size: var(--t-row); line-height: 1.4;
  /* Long unbroken strings, which teenagers paste, wrap rather than scroll. */
  overflow-wrap: anywhere; max-width: 100%;
  /* Blank lines and runs of spaces survive the trip. They always did survive
     it: the composer sends them and Postgres stores them, and two of the live
     messages have carried newlines the whole time. HTML was collapsing them at
     the last moment, so a message typed as a list arrived as one paragraph.
     pre-wrap keeps the whitespace and still wraps at the bubble's edge, and
     because nothing was ever lost it repairs the messages already sent. */
  white-space: pre-wrap;
}
.msg-them .msg-bubble { background: var(--chat-them-bg); color: var(--chat-them-ink); }
/* Links keep bubble contrast: blue on grey, white-underlined on blue, exactly
   as CampChat renders them. Long URLs already wrap via overflow-wrap. */
.msg-them .msg-bubble a { color: var(--chat-accent); text-decoration: underline; }
.msg-mine .msg-bubble a { color: var(--white); text-decoration: underline; }
.msg-mine .msg-bubble { background: var(--chat-accent); color: var(--white); }
/* The corner squares off on the speaker's side mid-run, which is what makes a
   stack read as one turn rather than several. */
.msg-mine.msg-first .msg-bubble { border-bottom-right-radius: 6px; }
.msg-mine.msg-mid .msg-bubble { border-top-right-radius: 6px; border-bottom-right-radius: 6px; }
.msg-mine.msg-last .msg-bubble { border-top-right-radius: 6px; }
.msg-them.msg-first .msg-bubble { border-bottom-left-radius: 6px; }
.msg-them.msg-mid .msg-bubble { border-top-left-radius: 6px; border-bottom-left-radius: 6px; }
.msg-them.msg-last .msg-bubble { border-top-left-radius: 6px; }

/* The tail. Squaring the corners mid-run already groups a stack into one turn;
   what marks the end of that turn in iMessage is the little flick back toward
   the speaker, and without it a thread reads as neat rectangles rather than as
   speech. Two pieces: a rounded square bulging out of the bubble's bottom
   corner, and a second in the thread's own white that trims it back to a
   curve. Only on the last of a run and on a message standing alone, which is
   what makes it punctuation rather than decoration. */
.msg-solo .msg-bubble, .msg-last .msg-bubble { position: relative; }
.msg-solo .msg-bubble::before, .msg-last .msg-bubble::before,
.msg-solo .msg-bubble::after, .msg-last .msg-bubble::after {
  content: ''; position: absolute; bottom: 0; height: 17px; pointer-events: none;
}
.msg-mine.msg-solo .msg-bubble::before, .msg-mine.msg-last .msg-bubble::before {
  right: -7px; width: 20px;
  background: var(--chat-accent);
  border-bottom-left-radius: 16px 14px;
}
.msg-mine.msg-solo .msg-bubble::after, .msg-mine.msg-last .msg-bubble::after {
  right: -20px; width: 20px;
  background: var(--surface);
  border-bottom-left-radius: 10px;
}
.msg-them.msg-solo .msg-bubble::before, .msg-them.msg-last .msg-bubble::before {
  left: -7px; width: 20px;
  background: var(--chat-them-bg);
  border-bottom-right-radius: 16px 14px;
}
.msg-them.msg-solo .msg-bubble::after, .msg-them.msg-last .msg-bubble::after {
  left: -20px; width: 20px;
  background: var(--surface);
  border-bottom-right-radius: 10px;
}
/* The tail leans out past the bubble, so the column has to stop clipping it. */
.msg-col { overflow: visible; }
/* A failed message keeps its outline instead, and a tail in the accent colour
   under a bordered bubble would read as a second, healthy message. */
.msg-failed .msg-bubble::before, .msg-failed .msg-bubble::after { display: none; }

/* Day divider: CampChat's sticky centered pill, which stays pinned while a
   backlog scrolls under it. The bubble rewrite of v=38 dropped the old divider
   rules entirely, so these are also a bug fix, not only a restyle. */
.day-divider {
  display: flex; align-items: center; justify-content: center;
  margin: 12px 0 6px; position: sticky; top: 4px; z-index: 2; pointer-events: none;
}
.day-divider span {
  padding: 2px 10px; background: var(--chat-chip); border-radius: var(--r-full);
  font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--chat-meta); box-shadow: 0 1px 2px rgba(0,0,0,0.04);
}
.new-divider {
  display: flex; align-items: center; gap: 8px;
  margin: 10px 6px 4px; color: var(--crimson);
  font-size: 10px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
}
.new-divider::before, .new-divider::after {
  content: ''; flex: 1; height: 1px; background: var(--crimson); opacity: 0.4;
}

.msg-when { font-size: 10px; color: var(--chat-meta); opacity: 0.7; margin: 2px 6px 0; }
.msg-pending { opacity: 0.6; }

/* A message that failed to send stays on screen holding its text, so the two
   ways out belong on it rather than in a toast that has already gone. Not
   dimmed like .msg-pending: nothing is happening to it, and it needs reading. */
.msg-failed .msg-bubble { border: 1px solid var(--crimson-line); }
.msg-failnote {
  display: flex; align-items: center; flex-wrap: wrap; gap: 2px;
  font-size: var(--t-xs); color: var(--crimson); opacity: 1;
}
.msg-mine .msg-failnote { justify-content: flex-end; }
/* .linklike is a 40px standalone target and .linklike.inline has no target at
   all. These sit in a meta line but are still the only way out of a failed
   send, so: small, and thumb-sized anyway. */
.msg-failnote .linklike {
  min-height: 32px; padding: 4px 8px;
  font-size: var(--t-xs); color: var(--crimson);
}
.msg-failnote .linklike:hover { color: var(--crimson-deep); }

.replies { margin-left: 34px; display: flex; flex-direction: column; }
.msg-reply .msg-bubble { font-size: var(--t-base); }

.msg-col .fwd-tag { margin: 0 10px 2px; }
.msg-col .rx-row { margin-top: 4px; }
.msg-col .poll { align-self: stretch; min-width: min(68vw, 300px); }

/* Actions appear when a message is tapped, which is the messaging-app
   convention; a permanent row under every bubble reads as clutter. */
.msg-tool {
  background: none; border: none; cursor: pointer;
  /* 36px minimum, and the row gap shrinks to compensate so the extra hit area
     does not push the messages apart. Text-only buttons default to about 26px,
     which is under the floor this project holds itself to on touch. */
  min-height: 36px; padding: 0 var(--s2);
  display: inline-flex; align-items: center;
  font-family: var(--sans); font-size: var(--t-label); font-weight: 600;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted);
}
.msg-tool:hover { color: var(--crimson); }

/* The composer, per the references: a white strip over a hairline, the input
   rounded to match the bubbles, and a round blue send button. The brand's
   uppercase SEND read as a form control, not a chat. */
.composer-dock { border-top: 1px solid var(--chat-line); padding-top: 10px; }
.composer textarea { border-radius: 18px; }
.send-btn {
  width: 38px; height: 38px; border-radius: 50%; border: none; flex: none;
  background: var(--chat-accent); color: var(--white); font-size: 18px; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
}
.send-btn:hover { filter: brightness(1.08); }
.send-btn:focus-visible { outline: none; box-shadow: var(--focus); }

.rx-row { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s3); }
.rx {
  font-family: var(--sans); font-size: var(--t-sm); line-height: 1;
  padding: 7px 10px; min-height: 36px; cursor: pointer;
  border: 1px solid var(--border); background: var(--surface);
  border-radius: var(--r-full); color: var(--ink);
  transition: background var(--t), border-color var(--t);
}
.rx-mine { border-color: var(--crimson); background: var(--crimson-tint); }
.rx:hover { border-color: var(--border-strong); }
/* ── The message context menu ───────────────────────────────────────────────
   The inline version of this pushed the actions into the thread and shoved the
   conversation around them. This is what AscendChat does in AscendOS, and what
   iOS does: dim everything, lift the bubble out of the flow, float the
   reactions above it and the actions below. The bubble here is a clone, so the
   thread behind it is untouched. */
.ctx-overlay {
  position: fixed; inset: 0; z-index: 70;
  display: flex; align-items: center; justify-content: center;
  padding: 20px 12px;
}
.ctx-backdrop {
  position: absolute; inset: 0;
  background: rgba(0, 0, 0, 0.48);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
.ctx-stack {
  position: relative;
  display: flex; flex-direction: column; gap: var(--s2);
  width: min(360px, 92vw);
  animation: ctx-pop 190ms cubic-bezier(0.2, 0.9, 0.3, 1.1);
}
.ctx-them { align-items: flex-start; }
.ctx-mine { align-items: flex-end; }
@keyframes ctx-pop {
  from { transform: scale(0.88); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) { .ctx-stack { animation: none; } }

.ctx-reactions {
  display: flex; gap: 2px; padding: 5px 6px;
  background: var(--surface); border-radius: var(--r-full);
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
  max-width: 100%;
}
.ctx-react {
  width: 40px; height: 40px; padding: 0; flex: none;
  border: 0; border-radius: 50%; background: transparent;
  font-size: 21px; line-height: 1; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  transition: transform 120ms ease, background 120ms ease;
}
.ctx-react:hover, .ctx-react:active { transform: scale(1.22); background: var(--chat-hover); }
.ctx-react:focus-visible { outline: none; box-shadow: var(--focus); }
/* The way out of the six into all of them. Marked as a control rather than an
   emoji, so it does not read as a seventh thing to react with. */
.ctx-react-more {
  font-size: 18px; font-weight: 600; color: var(--muted);
  background: var(--surface-2);
}

/* The full set, in place of the pill. The stack's own width holds it, and the
   grid scrolls rather than growing past the bubble it belongs to. */
.ctx-picker {
  width: 100%;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 14px; overflow: hidden;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.42);
}
.ctx-picker .emoji-cats { border-bottom: 1px solid var(--border); }
.ctx-emoji-grid {
  max-height: min(46vh, 300px); overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

/* The clone keeps the thread's own bubble styling; it only gains a lift. */
.ctx-bubble-wrap { display: flex; max-width: 100%; }
.ctx-bubble-wrap .msg-bubble { box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); }
/* The tail's trim piece is painted in the thread's ground, which is not what is
   behind the bubble here, so it would show as a notch against the backdrop. */
.ctx-bubble-wrap .msg-bubble::before,
.ctx-bubble-wrap .msg-bubble::after { display: none; }

.ctx-menu {
  min-width: 216px; max-width: 100%;
  display: flex; flex-direction: column;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 14px; overflow: hidden;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.42);
}
.ctx-action {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s4);
  width: 100%; min-height: 48px; padding: 12px 16px;
  background: transparent; border: 0; border-top: 1px solid var(--border);
  font-family: var(--sans); font-size: var(--t-md); color: var(--ink);
  text-align: left; cursor: pointer;
}
.ctx-action:first-child { border-top: 0; }
.ctx-action:hover, .ctx-action:active { background: var(--surface-2); }
.ctx-action:focus-visible { outline: none; box-shadow: var(--focus); position: relative; }
.ctx-ico { font-size: 15px; color: var(--muted); flex: none; }
.ctx-danger { color: var(--crimson); }
.ctx-danger .ctx-ico { color: var(--crimson); }

.unread-pill {
  background: var(--fill-crimson); color: var(--white);
  font-size: var(--t-label); font-weight: 600; line-height: 1;
  padding: 5px 9px; border-radius: var(--r-full); font-variant-numeric: tabular-nums;
}

.reply-bar {
  display: flex; flex-wrap: wrap; gap: var(--s2) var(--s3);
  align-items: baseline; justify-content: space-between;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--r-sm); padding: var(--s2) var(--s3);
  margin-bottom: var(--s2); font-size: var(--t-sm); min-width: 0;
}
.reply-bar > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }

.typing { font-size: var(--t-sm); color: var(--muted); margin: 0 0 var(--s2); font-style: italic; }
/* A quiet centered pill, shown only for a real drop after a successful join.
   The old version was an uppercase banner that shouted at every blip. */
.rt-note {
  align-self: center; margin: 0 0 var(--s3);
  font-size: var(--t-xs); color: var(--muted);
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--r-full); padding: 4px 12px;
}

/* Attachments. Images are capped rather than given free rein over the column,
   because a phone screenshot is taller than the viewport and would otherwise
   push every other message off the screen. */
.att-row { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s3); }
.att-img {
  display: block; max-width: 220px; border-radius: var(--r-sm);
  overflow: hidden; border: 1px solid var(--border); line-height: 0;
}
.att-img img { display: block; width: 100%; height: auto; max-height: 260px; object-fit: cover; }
.att-loading { width: 160px; height: 110px; background: var(--surface-2); }
.att-file {
  display: inline-flex; align-items: baseline; gap: var(--s2); min-height: 36px;
  padding: var(--s2) var(--s3); border: 1px solid var(--border);
  border-radius: var(--r-sm); background: var(--surface-2);
  font-size: var(--t-sm); color: var(--ink); text-decoration: none; min-width: 0;
}
.att-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
.att-size { color: var(--muted); font-size: var(--t-label); flex: none; }

.fwd-tag {
  font-size: var(--t-label); font-weight: 600; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--muted); margin-bottom: var(--s2);
}

/* Polls. The bar is a background layer inside the button rather than a separate
   track, so the label always sits on top of its own result. */
.poll { margin-top: var(--s3); display: flex; flex-direction: column; gap: var(--s2); }
.poll-meta { font-size: var(--t-label); color: var(--muted); }
.poll-opt {
  position: relative; display: flex; align-items: center; gap: var(--s3);
  width: 100%; min-height: 40px; padding: var(--s2) var(--s3); cursor: pointer;
  border: 1px solid var(--border); border-radius: var(--r-sm);
  background: var(--surface); font-family: var(--sans); font-size: var(--t-sm);
  color: var(--ink); text-align: left; overflow: hidden;
}
.poll-opt:disabled { cursor: default; opacity: 0.75; }
.poll-mine { border-color: var(--crimson); }
.poll-bar {
  position: absolute; inset: 0 auto 0 0; background: rgba(188,30,45,0.10);
  transition: width var(--t); pointer-events: none;
}
.poll-label { position: relative; flex: 1; min-width: 0; }
.poll-count { position: relative; color: var(--muted); font-variant-numeric: tabular-nums; }

/* Bottom sheet. Anchored low because this is a phone-first surface and the
   top of the screen is the hardest place to reach one-handed. */
.sheet { position: fixed; inset: 0; z-index: 50; display: flex; align-items: flex-end; }
.sheet-backdrop { position: absolute; inset: 0; background: rgba(21,23,27,0.42); }
.sheet-card {
  position: relative; width: 100%; max-width: 560px; margin: 0 auto;
  background: var(--surface); border-radius: var(--r-lg) var(--r-lg) 0 0;
  /* The bottom inset, like every other fixed bar: a sheet's Cancel otherwise
     sits under the iPhone home bar. */
  padding: var(--s5) var(--s4) calc(var(--s6) + env(safe-area-inset-bottom)); box-shadow: var(--e3);
  display: flex; flex-direction: column; gap: var(--s3);
  max-height: 84vh; overflow-y: auto;
}
.sheet-title { font-family: var(--serif); font-size: 25px; font-weight: 700; margin: 0 0 var(--s2); }
.sheet-row {
  min-height: 44px; text-align: left; padding: var(--s3);
  border: 1px solid var(--border); border-radius: var(--r-sm);
  background: var(--surface); font-family: var(--sans); font-size: var(--t-base);
  color: var(--ink); cursor: pointer;
}
.sheet-row:hover { border-color: var(--border-strong); background: var(--surface-2); }
.sheet-card input[type="text"], .sheet-card textarea {
  width: 100%; box-sizing: border-box; font-family: var(--sans); font-size: var(--t-md);
  padding: var(--s3); border: 1px solid var(--border-strong);
  border-radius: var(--r-sm); background: var(--surface); color: var(--ink);
}

/* Two columns at 360px, more when there is room. Fixed cell heights so the grid
   does not reflow as each GIF loads at its own aspect ratio. */
.gif-grid {
  display: grid; gap: var(--s2); max-height: 46vh; overflow-y: auto;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 104px), 1fr));
  -webkit-overflow-scrolling: touch;
}
.gif-cell {
  padding: 0; border: 1px solid var(--border); border-radius: var(--r-sm);
  overflow: hidden; background: var(--surface-2); cursor: pointer;
  height: 104px; line-height: 0;
}
.gif-cell img { width: 100%; height: 100%; object-fit: cover; }
.gif-cell:hover { border-color: var(--crimson); }

.search-row { display: flex; flex-wrap: wrap; gap: var(--s2); margin-bottom: var(--s5); }
.search-row input {
  flex: 1; min-width: 200px; box-sizing: border-box; min-height: 44px;
  font-family: var(--sans); font-size: var(--t-md); padding: var(--s2) var(--s3);
  border: 1px solid var(--border-strong); border-radius: var(--r-sm);
  background: var(--surface); color: var(--ink);
}

.staged { display: flex; flex-wrap: wrap; gap: var(--s2); margin-bottom: var(--s2); }
.staged-chip {
  display: inline-flex; align-items: center; gap: var(--s2); min-width: 0;
  padding: var(--s2) var(--s3); border: 1px solid var(--border);
  border-radius: var(--r-full); background: var(--surface-2); font-size: var(--t-sm);
  max-width: 100%; overflow: hidden;
}
.staged-chip button {
  border: none; background: none; cursor: pointer; color: var(--muted);
  font-size: var(--t-md); line-height: 1; padding: 0 2px; min-height: 36px;
}

/* Mentions: a quiet tint for anyone, the brand red when it is you, readable on
   both bubble grounds. */
.mention { font-weight: 600; }
.msg-them .msg-bubble .mention { color: var(--chat-accent); }
.msg-mine .msg-bubble .mention { color: var(--white); text-decoration: underline; }
.msg-them .msg-bubble .mention-me {
  color: var(--crimson); background: rgba(188,30,45,0.10);
  border-radius: 4px; padding: 0 3px;
}
.msg-mine .msg-bubble .mention-me { background: rgba(255,255,255,0.22); border-radius: 4px; padding: 0 3px; }

.mention-menu {
  position: absolute; bottom: calc(100% + 6px); left: 0;
  display: flex; flex-direction: column; min-width: 200px; max-width: 90%;
  background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: var(--r-md); box-shadow: var(--e3); overflow: hidden; z-index: 30;
}
.mention-opt {
  text-align: left; padding: 10px 14px; min-height: 40px;
  background: var(--surface); border: none; cursor: pointer;
  font-family: var(--sans); font-size: var(--t-base); color: var(--ink);
}
.mention-opt:hover { background: var(--surface-2); }

.composer { position: relative; display: flex; gap: var(--s2); align-items: flex-end; }
.composer textarea {
  flex: 1; min-width: 0; width: auto; box-sizing: border-box;
  font-family: var(--sans); font-size: var(--t-md); line-height: 1.5;
  padding: 9px var(--s4); border: 1px solid var(--border-strong);
  background: var(--surface); color: var(--ink);
  /* No resize grip and no scrollbar chrome: grow() sizes it to its content,
     so the native controls only ever showed up as visual lint. */
  resize: none; overflow-y: hidden;
}
/* The chat composer keeps focus visible but neutral; the crimson ring next to
   the blue send button read as an error state, not focus. */
.composer textarea:focus-visible { outline: none; border-color: var(--chat-focus-line); }
.composer .btn { min-height: 44px; }
.composer-plus {
  width: 38px; height: 38px; border-radius: 50%; border: none; flex: none;
  margin-bottom: 2px;
  background: var(--chat-them-bg); color: var(--chat-meta); font-size: 22px; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
}
.composer-plus:hover { background: var(--chat-hover); }
.composer-plus:focus-visible { outline: none; box-shadow: var(--focus); }
/* The + menu: attach, poll, GIF in one popover over the composer, same card
   voice as the mention menu. */
.plus-menu {
  position: absolute; bottom: calc(100% + 8px); left: 0; z-index: 30;
  display: flex; flex-direction: column; min-width: 180px;
  background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: var(--r-md); box-shadow: var(--e3); overflow: hidden;
}
.plus-opt {
  text-align: left; padding: 12px 16px; min-height: 44px;
  background: var(--surface); border: none; cursor: pointer;
  font-family: var(--sans); font-size: var(--t-base); color: var(--ink);
}
.plus-opt:hover { background: var(--surface-2); }
.plus-opt + .plus-opt { border-top: 1px solid var(--border); }

/* left AND right pin it to the composer's own width, and the max-width plus
   margin-left:auto pull it to the right on a wide screen, under the button it
   belongs to. Without the left pin it would overflow a 360px phone. */
.emoji-pop {
  position: absolute; bottom: calc(100% + 8px); left: 0; right: 0; z-index: 30;
  max-width: 340px; margin-left: auto;
  background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: var(--r-md); box-shadow: var(--e3); overflow: hidden;
}
.emoji-cats { display: flex; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.emoji-cat {
  flex: 1 1 0; min-width: 0; padding: 8px 0; min-height: 40px;
  background: none; border: 0; border-bottom: 2px solid transparent;
  cursor: pointer; font-size: 17px; line-height: 1; opacity: 0.55;
}
.emoji-cat:hover { opacity: 1; }
.emoji-cat.on { opacity: 1; background: var(--surface); border-bottom-color: var(--crimson); }
.emoji-grid {
  display: grid; grid-template-columns: repeat(8, 1fr); gap: 2px;
  padding: 8px; max-height: 232px; overflow-y: auto; -webkit-overflow-scrolling: touch;
}
.emoji-btn {
  background: none; border: 0; cursor: pointer; border-radius: var(--r-sm);
  font-size: 21px; line-height: 1; padding: 5px 0; min-height: 38px;
}
.emoji-btn:hover { background: var(--surface-2); }
.emoji-btn:focus-visible { outline: none; box-shadow: var(--focus); }
/* Ours are pictures rather than glyphs, drawn to the same optical size as the
   characters beside them so the grid does not go lumpy at the last tab. */
.emoji-btn.is-custom img { width: 24px; height: 24px; display: block; margin: 0 auto; }
.emoji-cat-img { width: 19px; height: 19px; display: block; margin: 0 auto; }
/* A custom emoji sitting inside a sentence. Sized in em so it tracks the text
   it is set in, and nudged onto the baseline the way a glyph would sit. */
.cemoji {
  height: 1.35em; width: auto; vertical-align: -0.28em;
  display: inline-block; margin: 0 1px;
}

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ── Signed in ────────────────────────────────────────────────────────── */
.foot-actions { display: flex; flex-wrap: wrap; gap: var(--s4); align-items: center; }

/* ── Lens switcher + live panel ───────────────────────────────────────── */
.lens { display: flex; border: 1px solid var(--border-strong); border-radius: var(--r-sm); overflow: hidden; flex: none; background: var(--surface); }
.lens button {
  font-family: var(--sans); font-size: var(--t-micro); font-weight: 600; letter-spacing: 0.08em;
  text-transform: uppercase; padding: 8px 10px; min-height: 38px;
  border: none; background: transparent; color: var(--muted); cursor: pointer;
  transition: background var(--t), color var(--t);
}
.lens button:hover { color: var(--ink); background: var(--surface-2); }
.lens button[aria-pressed="true"] { background: var(--ink-fixed); color: var(--white); }

.live-panel {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
  background: var(--surface); border-top: 1px solid var(--border);
  padding: var(--s5) var(--s4); box-shadow: 0 -8px 40px rgba(21,23,27,0.16);
  animation: panel-up 200ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes panel-up { from { transform: translateY(12px); opacity: 0; } to { transform: none; opacity: 1; } }
.live-panel input {
  width: 100%; padding: 12px; margin: var(--s3) 0;
  border: 1px solid var(--border-strong); border-radius: var(--r-sm); font-size: var(--t-md); font-family: var(--sans);
}
.live-err { color: var(--crimson); font-size: var(--t-xs); min-height: 16px; margin: var(--s2) 0 0; }
@media (min-width: 640px) {
  .live-panel { left: auto; right: var(--s4); bottom: var(--s4); max-width: 400px; border: 1px solid var(--border); border-radius: var(--r-md); }
}

/* ── The front door ──────────────────────────────────────────────────────
   The same recipe AscendOS and the camp platform use, because a family who
   has seen one Ascend login should recognize the next: the wall photo both
   of them ship (their SAWall3.jpeg, which is a WebP wearing a .jpeg name —
   stored honestly here), a dark scrim so the card reads, and one white card
   in the middle of the room. */
body.login-screen .topbar { display: none; }
body.login-screen .view { padding: 0; max-width: none; }
/* On the login screen the footer is a real bar, fixed to the viewport bottom:
   its own dark scrim (matching the hero's) so the staff link stays legible
   even when the white card scrolls beneath it on a short phone screen, where
   bare translucent text used to dissolve into the card. */
body.login-screen .foot {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 5;
  justify-content: center; max-width: none;
  background: rgba(15, 17, 22, 0.6);
  -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
  border-top: 1px solid rgba(255, 255, 255, 0.12);
  padding: var(--s2) var(--s4);
  padding-bottom: max(var(--s2), env(safe-area-inset-bottom));
}
body.login-screen .foot .linklike { color: rgba(255, 255, 255, 0.82); min-height: 40px; }
body.login-screen .foot .linklike:hover { color: var(--white); }

/* The company links exist only on the login bar; signed-in screens keep their
   minimal footer. Typography is the site's closing-band voice verbatim:
   uppercase, 0.08em tracking, weight 600, with the consultation as the one
   outline-white pill (1.5px half-white border, 4px radius) the way
   ascendspeech.org draws its Book a Consultation button. */
.foot-cta, .foot-links { display: none; }
body.login-screen .foot-actions {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: center;
  gap: var(--s2) var(--s4);
}
body.login-screen .foot-cta {
  display: inline-flex; align-items: center; gap: 8px;
  border: 1.5px solid rgba(255, 255, 255, 0.5); border-radius: 4px;
  padding: 9px 18px; min-height: 38px; box-sizing: border-box;
  color: var(--white); text-decoration: none;
  font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
  transition: border-color var(--t), background var(--t);
}
body.login-screen .foot-cta:hover { border-color: var(--white); background: rgba(255, 255, 255, 0.08); }
body.login-screen .foot-links { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; }
body.login-screen .foot-links a {
  display: inline-flex; align-items: center; min-height: 40px; padding: 0 var(--s2);
  color: var(--on-dark); text-decoration: none;
  font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
}
body.login-screen .foot-links a:hover { color: var(--white); }
body.login-screen .foot-links a + a::before {
  content: '·'; margin-right: var(--s2); color: rgba(255, 255, 255, 0.4);
}
body.login-screen #staff-login {
  font-size: 11px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase;
}
/* Phones: the pill would push the bar to three rows and 160px of chrome over
   a login screen, so it dresses down to a link and the bar holds at two. */
@media (max-width: 480px) {
  body.login-screen .foot-cta {
    border: none; border-radius: 0; padding: 0 var(--s2); min-height: 40px;
    color: var(--on-dark);
  }
  body.login-screen .foot-cta:hover { background: none; color: var(--white); }
  /* Dissolve the wrapper so all five items flow as one wrapping stream
     instead of the pill, links and staff door each claiming a row. */
  body.login-screen .foot-links { display: contents; }
}
/* Room under the card so the last control can never hide behind the bar;
   phones get more because the bar wraps to two rows there. */
body.login-screen .login-hero { padding-bottom: 76px; }
@media (max-width: 480px) {
  body.login-screen .login-hero { padding-bottom: 132px; }
}
/* Sub-360 screens wrap the bar to a third row; keep clearing it. */
@media (max-width: 359px) {
  body.login-screen .login-hero { padding-bottom: 184px; }
}

/* The ground belongs to the body, not to the hero.
   It used to live on .login-hero, which sits inside #view, and #view is
   replaced wholesale on every render. Switching sign in to create an account
   destroyed the hero and rebuilt it, and in the gap before the new one painted
   its photo the body showed through — a beige flash on every mode switch.
   body.login-screen is already on before the switch and still on after, so
   nothing about it changes and there is nothing to repaint.

   The scrim is layered into the background so there is no overlay element to
   forget a z-index on. Falls back to near-black while the photo loads. */
body.login-screen {
  background:
    linear-gradient(180deg, rgba(15, 17, 22, 0.55), rgba(15, 17, 22, 0.62)),
    url('/images/login-wall.webp') center / cover no-repeat,
    var(--ink-fixed);
  background-attachment: fixed;
}

.login-hero {
  min-height: 100vh; min-height: 100dvh;
  display: flex; align-items: center; justify-content: center;
  padding: var(--s6) var(--s4);
  box-sizing: border-box;
  background: none;
}

/* Already on screen: the form is changing, the card is not arriving. */
.login-card.settled { animation: none; }

.login-card {
  background: var(--surface);
  width: 100%; max-width: 440px;
  padding: 40px 36px 32px;
  border-radius: var(--r-lg);
  box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
  animation: login-card-in 320ms cubic-bezier(0.2, 0.8, 0.3, 1);
}
@keyframes login-card-in {
  from { opacity: 0; transform: translateY(10px) scale(0.985); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) { .login-card { animation: none; } }
@media (max-width: 480px) { .login-card { padding: 32px 22px 26px; } }

.login-logo { display: block; height: 72px; width: auto; margin: 0 auto var(--s3); }
.login-card .eyebrow { text-align: center; }

/* "Welcome back." written by hand on load, left to right: each letter is its
   own tspan whose pen stroke traces in crimson, then its ink (the brand's
   ink-to-crimson gradient) settles as the pen moves on to the next letter.
   The stagger lives in per-letter inline animation-delays; safe to split a
   connected script this way because Great Vibes joins letters with entry and
   exit strokes baked into each glyph (measured: whole vs split width differs
   by 0.3px). The .draw class arrives from JS once the font is loaded, so the
   trace never plays over a fallback face. */
.login-script { margin: 0 0 var(--s2); }
.login-script svg { display: block; width: 100%; height: auto; overflow: visible; }
.login-script text {
  font-family: 'Great Vibes', 'Brush Script MT', cursive;
  font-size: 58px;
  fill: url(#login-ink);
}
.login-script tspan {
  fill-opacity: 0;
  stroke: var(--crimson); stroke-width: 0.9;
  /* Longer than any single glyph's outline, so every letter finishes inside
     its slot; smaller letters simply finish their trace early. */
  stroke-dasharray: 500; stroke-dashoffset: 500;
}
.login-script.draw tspan {
  animation:
    login-script-draw 0.8s ease-in-out forwards,
    login-script-ink 0.5s ease-out forwards;
  /* animation-delay is inline, one pair per letter */
}
@keyframes login-script-draw { to { stroke-dashoffset: 0; } }
@keyframes login-script-ink { to { fill-opacity: 1; stroke-opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  .login-script tspan, .login-script.draw tspan {
    animation: none;
    fill-opacity: 1; stroke-opacity: 0;
    stroke-dasharray: none; stroke-dashoffset: 0;
  }
}
/* The brand's big gradient serif, sized for a card rather than a page. */
.login-card .screen-title {
  font-size: clamp(26px, 6.5vw, 32px); text-align: center; margin-bottom: var(--s2);
}
.login-card .screen-sub {
  text-align: center; margin: 0 auto var(--s5); font-size: var(--t-sm);
}

/* Google first, styled to Google's own spec like both references: white
   ground, #dadce0 hairline, the G in its own colors. */
.login-google {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  width: 100%; min-height: 48px; box-sizing: border-box;
  background: var(--g-bg); border: 1px solid var(--g-line); border-radius: var(--r-sm);
  font-family: var(--sans); font-size: var(--t-base); font-weight: 500; color: var(--g-ink);
  cursor: pointer; transition: background var(--t), border-color var(--t);
}
.login-google:hover { background: var(--surface-2); border-color: var(--border-strong); }
.login-google svg { flex: none; }

.login-divider {
  display: flex; align-items: center; gap: var(--s3);
  margin: var(--s4) 0;
  color: var(--muted); font-size: 11px;
  text-transform: uppercase; letter-spacing: 0.08em;
}
.login-divider::before, .login-divider::after {
  content: ''; flex: 1; height: 1px; background: var(--border);
}

/* Uppercase hairline labels, the camp platform's field voice. */
.login-label {
  display: block; margin-bottom: 6px;
  font-size: 11px; font-weight: 600; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--muted);
}

.login-form { display: flex; flex-direction: column; gap: var(--s3); }
.login-form input {
  width: 100%; box-sizing: border-box; min-height: 48px;
  font-family: var(--sans); font-size: var(--t-md); padding: var(--s2) var(--s4);
  border: 1.5px solid var(--border-strong); border-radius: var(--r-sm);
  background: var(--surface); color: var(--ink);
  transition: border-color var(--t);
}
.login-form input:focus-visible { outline: none; border-color: var(--ink); box-shadow: var(--focus); }
/* A textarea in one of these forms was inheriting nothing, so it rendered at the
   browser default width (about twenty characters) next to full-width inputs.
   Same box as the input, minus the fixed height, and only vertically resizable
   so dragging it cannot push the card wider than the phone. */
.login-form textarea {
  width: 100%; box-sizing: border-box; resize: vertical;
  font-family: var(--sans); font-size: var(--t-md); padding: var(--s2) var(--s4);
  border: 1.5px solid var(--border-strong); border-radius: var(--r-sm);
  background: var(--surface); color: var(--ink);
  transition: border-color var(--t);
}
.login-form textarea:focus-visible { outline: none; border-color: var(--ink); box-shadow: var(--focus); }
.login-form .btn { min-height: 48px; margin-top: var(--s2); }
.login-msg { min-height: 20px; margin-top: var(--s3); text-align: center; }

.login-links {
  display: flex; flex-wrap: wrap; justify-content: center; gap: var(--s2) var(--s5);
  margin: var(--s4) 0 0; padding-top: var(--s4); border-top: 1px solid var(--border);
}
.login-links .linklike { min-height: 40px; display: inline-flex; align-items: center; }

/* First-run onboarding, on the same card. The role comes first because the
   form underneath changes shape with it. */
/* min(100%, 84px) is what lets three tiles share a 276px card column on a
   360px phone and still collapse gracefully if a fourth ever appears. */
.role-row {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 84px), 1fr));
  gap: var(--s3); margin-bottom: var(--s4);
}
.role-tile {
  display: flex; flex-direction: column; gap: 2px; align-items: center;
  padding: var(--s4) var(--s2); min-height: 64px; cursor: pointer;
  font-family: var(--sans); background: var(--surface);
  border: 1.5px solid var(--border-strong); border-radius: var(--r-sm);
  transition: border-color var(--t), background var(--t);
}
.role-tile strong { font-size: var(--t-md); color: var(--ink); }
.role-tile span { font-size: var(--t-xs); color: var(--muted); }
.role-tile:hover { border-color: var(--ink); }
.role-tile.on { border-color: var(--crimson); background: var(--crimson-wash); }
.role-tile.on strong { color: var(--crimson); }
.role-tile:focus-visible { outline: none; box-shadow: var(--focus); }

.login-form select {
  width: 100%; box-sizing: border-box; min-height: 48px;
  font-family: var(--sans); font-size: var(--t-md); padding: var(--s2) var(--s4);
  border: 1.5px solid var(--border-strong); border-radius: var(--r-sm);
  background: var(--surface); color: var(--ink);
}
.login-form select:focus-visible { outline: none; box-shadow: var(--focus); }

/* Staff home: the tier chip states which trusted list put you here. */
.tier-chip {
  display: inline-block; vertical-align: middle; margin-left: 4px;
  padding: 2px 8px; border-radius: var(--r-full);
  border: 1px solid var(--crimson); color: var(--crimson);
  font-size: 10px; letter-spacing: 0.1em;
}
.staff-name-form { display: flex; flex-wrap: wrap; gap: var(--s2); }
.staff-name-form input {
  flex: 1; min-width: min(100%, 160px); box-sizing: border-box; min-height: 44px;
  font-family: var(--sans); font-size: var(--t-base); padding: var(--s2) var(--s3);
  border: 1px solid var(--border-strong); border-radius: var(--r-sm);
  background: var(--surface); color: var(--ink);
}
.staff-name-form input:focus-visible { outline: none; box-shadow: var(--focus); }
.staff-name-form .btn { min-height: 44px; }

/* ── Free vs paid ──────────────────────────────────────────────────────── */
/*
 * A parent scanning five tabs had no way to tell that Ascend Academy costs
 * nothing while the paid programmes start in the hundreds. The only signal was one
 * clause buried in a paragraph. "Ascend Academy" is also a brand name, which
 * made it read like something you buy.
 *
 * The chip is deliberately quiet: free is reassurance, not a sale. It sits in
 * the same green family as the cash card so the app has one "this is good for
 * you" colour rather than two.
 */
.free-chip {
  display: inline-block; vertical-align: 1px;
  padding: 2px 7px; border-radius: 999px;
  font-size: 11px; font-weight: 650; letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--enrolled-ink);
  background: var(--enrolled-bg);
  box-shadow: inset 0 0 0 1px rgba(28, 107, 69, 0.16);
  white-space: nowrap;
}
.eyebrow .free-chip { vertical-align: 0; }

/* ── Onboarding, aligned with AALP ─────────────────────────────────────── */
/*
 * The form went from two fields to eleven, so it needs real structure rather
 * than a stack of inputs. Two-up rows collapse to one column below 420px:
 * first/last and the parent pair are the only pairs narrow enough to share a
 * line on a phone, and min-width:0 keeps them from pushing past the card.
 */
.ob-f { margin-bottom: 14px; }
.ob-f input, .ob-f select { width: 100%; box-sizing: border-box; }
.ob-two {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr));
  gap: 0 12px;
}
.ob-two > * { min-width: 0; }

/* The email we already know. Visibly not a question: same shape as the fields
   around it so the form reads evenly, but plainly not asking for input. */
.ob-readonly {
  background: var(--surface-2, rgba(21,23,27,0.04));
  color: var(--muted);
  cursor: default;
}
.ob-readonly:focus { outline: none; box-shadow: none; border-color: var(--border); }

.ob-section {
  margin: 22px 0 2px; padding-top: var(--s4); border-top: 1px solid var(--border);
  font-weight: 650; font-size: 14px; letter-spacing: 0.01em; color: var(--ink);
}
.ob-opt {
  font-weight: 500; font-size: 12px; color: var(--muted);
  text-transform: none; letter-spacing: 0;
}

/* ── Home: the four programs, and the notification strip ───────────────── */
/*
 * Aditya's square of four. Two columns at every width worth having: at 360px
 * that is two ~160px tiles per row, which is the smallest a tile can be and
 * still hold a name plus a state without truncating.
 *
 * A locked tile is dimmed but never disabled. It is the doorway to the page
 * that sells the thing, which is the only honest reason to show a padlock
 * instead of hiding the tile entirely.
 */
.prog-grid {
  display: grid;
  /* minmax(0, 1fr), not 1fr: a bare 1fr cannot shrink below its content, and
     a photo is content. */
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px;
  margin: 0 0 22px;
}
/* One enrolled program beside an empty column looked like something had
   failed to load. Alone, it takes the row and its photo becomes a band. */
.prog-grid.is-one { grid-template-columns: minmax(0, 1fr); }
.prog-tile {
  position: relative; overflow: hidden;
  display: flex; flex-direction: column; gap: 2px;
  min-width: 0; min-height: 96px;
  padding: 12px 14px 14px;
  text-align: left;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--r-md);
  box-shadow: var(--e1);
  transition: transform var(--t), box-shadow var(--t), opacity var(--t);
  cursor: pointer;
}
/* The program's colour as a left edge, the same device the family tiles use,
   so the four tiles are four things rather than one thing four times. */
.prog-tile::before {
  content: ''; position: absolute; inset: 0 auto 0 0; width: 3px; z-index: 1;
  background: var(--p-accent, var(--ink-fixed));
}
.prog-tile[data-prog="camp"]    { --p-accent: var(--crimson); }
.prog-tile[data-prog="ms"]      { --p-accent: var(--p-ms); }
.prog-tile[data-prog="sandel"]  { --p-accent: var(--p-sandel); }
.prog-tile[data-prog="academy"] { --p-accent: var(--p-academy); }
.prog-tile:hover { transform: translateY(-2px); box-shadow: var(--e3); border-color: var(--border-strong); }
.prog-tile:active { transform: translateY(0) scale(0.99); box-shadow: var(--e1); }
/* Enrolled reads as the main thing: it sits a little higher than the rest. */
.prog-tile.is-mine { box-shadow: var(--e2); }
/* Locked is no longer a dimmed tile, which with a photograph on it read as
   broken rather than as shut. The photo loses most of its colour, the edge
   fades, and the padlock plus "Not enrolled" say the rest. */
.prog-tile.is-locked { background: var(--surface-2); }
.prog-tile.is-locked::before { opacity: 0.45; }
.prog-tile.is-locked .prog-photo img { filter: saturate(0.45); opacity: 0.8; }
.prog-tile.is-locked:hover .prog-photo img { filter: none; opacity: 1; }

/* The photograph, bled to the tile's edges above the label. */
.prog-photo {
  display: block; overflow: hidden;
  margin: -12px -14px 10px;
}
.prog-photo img {
  display: block; width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover;
  transition: transform 500ms ease, filter var(--t), opacity var(--t);
}
.prog-tile:hover .prog-photo img { transform: scale(1.04); }
/* Shown whole, at the photo's own shape; see the note on .card-photo. The
   chamber shot is the one 4:3 in the set, so its tile runs a little taller
   than its neighbour and the grid stretches the pair to match. */
.prog-photo img[src*="prog-sandel"] { aspect-ratio: 900 / 675; }
/* The free chip in Academy's own colour rather than the enrolment green,
   which on this tile made it look like a Montara offer. */
.prog-tile[data-prog="academy"] .free-chip { color: var(--p-academy); background: var(--p-academy-bg); }

.prog-top {
  display: flex; align-items: center; justify-content: space-between;
  min-height: 20px; margin-bottom: 6px;
}
.prog-lock, .prog-note { font-size: 13px; line-height: 1; opacity: 0.55; }
/* min-width:0 so a long programme name truncates instead of widening the
   track and pushing the grid past the viewport. */
.prog-name {
  font-weight: 650; font-size: var(--t-row); color: var(--ink);
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.prog-sub {
  font-size: 12px; color: var(--muted);
  min-width: 0; overflow: hidden; text-overflow: ellipsis;
}

/* Notifications: moved off the bottom of the chat screen, where it was close
   to invisible, to directly under the greeting. A strip rather than a card,
   so it can sit above the balance without displacing it as the hero. */
.push-strip {
  display: flex; align-items: center; gap: 12px; width: 100%;
  margin: 0 0 16px; padding: 11px 14px;
  text-align: left; cursor: pointer;
  background: var(--crimson-wash);
  border: 1px solid rgba(188,30,45,0.18);
  border-radius: var(--r-sm);
  transition: background var(--t);
}
.push-strip:hover { background: rgba(188,30,45,0.09); }
.push-ico { font-size: 18px; line-height: 1; flex: none; }
.push-body { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.push-title { font-weight: 650; font-size: 14px; color: var(--ink); }
.push-sub { font-size: 12px; color: var(--muted); }
/* The push strip is a call to action; this one sits directly under it, so it
   drops the strip's own bottom margin and carries the steps instead. */
.push-strip[aria-expanded="true"] { margin-bottom: 0; border-radius: var(--r-sm) var(--r-sm) 0 0; }
.install-help {
  margin: 0 0 16px; padding: 4px 14px 12px;
  background: var(--crimson-wash);
  border-radius: 0 0 var(--r-sm) var(--r-sm);
}
.install-steps {
  margin: 8px 0 0; padding-left: 20px;
  font-size: 13px; line-height: 1.65; color: var(--ink);
}
.install-steps li { margin-bottom: 3px; }
.install-steps li::marker { color: var(--muted); font-variant-numeric: tabular-nums; }
/* Drawn rather than named. "Tap the Share button" is not findable by someone
   who does not already know what it looks like, and the glyph Apple uses has
   no dependable character in any font we ship. Masked so it takes the
   surrounding text colour rather than carrying its own. */
.ios-share {
  display: inline-block; width: 0.95em; height: 0.95em;
  vertical-align: -0.14em; margin: 0 1px;
  background: var(--chat-accent);
  -webkit-mask: var(--ios-share-svg) center / contain no-repeat;
  mask: var(--ios-share-svg) center / contain no-repeat;
}
:root {
  --ios-share-svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v13'/%3E%3Cpath d='M8 7l4-4 4 4'/%3E%3Cpath d='M5 12v8a1.8 1.8 0 001.8 1.8h10.4A1.8 1.8 0 0019 20v-8'/%3E%3C/svg%3E");
}

/* ── The notifications banner ────────────────────────────────────────────
   Under the topbar, above the screen, in the crimson wash the install card
   already uses so it reads as Ascend asking rather than the browser. It is a
   request, not an alert: no red fill, no icon, the button is the emphasis. */
.pushbanner {
  background: var(--crimson-wash);
  border-bottom: 1px solid var(--crimson-line);
}
.pushbanner-inner {
  max-width: var(--app-max, 720px); margin: 0 auto;
  padding: var(--s3) var(--s4);
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: var(--s3) var(--s4);
}
.pushbanner-text {
  display: flex; flex-direction: column; gap: 2px;
  flex: 1 1 260px; min-width: 0;
  font-size: var(--t-sm); line-height: 1.45; color: var(--ink);
}
.pushbanner-text strong { font-size: var(--t-base); }
.pushbanner-text > span { color: var(--muted); }
.pushbanner-text .install-steps { margin-top: 6px; color: var(--ink); }
.pushbanner-actions {
  display: flex; align-items: center; gap: var(--s3); flex: none;
}
.pushbanner-actions .btn { min-height: 40px; }
.pushbanner-dismiss { font-size: var(--t-sm); color: var(--muted); min-height: 40px; }
/* The desktop chat panel floats over the screen; the banner stays with the
   screen underneath it, exactly like the tab bar does. */
body.chat-panel-open .pushbanner { position: relative; z-index: 1; }

/* ── Resources: photographs ────────────────────────────────────────────── */
/*
 * A 2:1 band, not the wider ratio the layout would otherwise want. Each of
 * these four photographs has faces between roughly 24% and 55% of its height,
 * and 2:1 is the widest crop that keeps all of them whole on the shallowest
 * source image. We have shipped beheaded crops before; the ratio is doing real
 * work here, not decoration.
 *
 * The card owns the corner radius, so the photo is inset by the card's padding
 * and rounded on its own rather than bleeding to the card edge.
 */
.res-card { display: block; }
.res-photo {
  display: block; position: relative;
  margin: -2px 0 14px;
  border-radius: var(--r-sm);
  overflow: hidden;
  background: rgba(21,23,27,0.05);
}
.res-photo img {
  width: 100%; height: auto; aspect-ratio: 3 / 2;
  object-fit: cover;
  display: block;
}
.res-photo img[src*="learning-practice"] { aspect-ratio: 1000 / 750; }
.res-photo img[src*="learning-hub"] { aspect-ratio: 1000 / 800; }
.res-photo img[src*="staff-team"] { aspect-ratio: 1200 / 674; }
/* The same hairline and scrim the store photos carry, so a photograph reads as
   an object on the card rather than a rectangle pasted onto it. */
.res-photo::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px rgba(21,23,27,0.07),
              inset 0 -24px 30px -24px rgba(21,23,27,0.28);
}
.res-card:hover .res-photo img { transform: scale(1.015); }
.res-photo img { transition: transform var(--t); }

@media (prefers-reduced-motion: reduce) {
  .res-card:hover .res-photo img { transform: none; }
}

/* ── Boot: show nothing rather than the wrong thing ────────────────────── */
/*
 * body.login-screen is what turns the page into the full-viewport login
 * treatment, and render() applies it only after Session.me() returns. Until
 * then the markup painted as the signed-in app: topbar with the crest, padded
 * view, light footer. Then it all flipped. That was the flash people kept
 * reporting, and it survived two narrower fixes because neither touched the
 * shell itself.
 *
 * So the shell starts hidden and the first render reveals it. visibility
 * rather than display, so nothing reflows when the class comes off.
 */
body.booting .topbar,
body.booting .foot,
body.booting .tabbar {
  visibility: hidden;
  /* Safety net. If the scripts never run, nothing would ever remove .booting
     and the page would be permanently blank chrome. This reveals it anyway
     after four seconds, so a broken deploy degrades to the old flash rather
     than to nothing at all. */
  animation: boot-reveal 0s linear 4s forwards;
}
@keyframes boot-reveal { to { visibility: visible; } }

/* ── Onboarding, corrected ─────────────────────────────────────────────── */
/*
 * The readonly email never looked readonly. `.login-form input` sets
 * background:var(--white) at higher specificity than a bare `.ob-readonly`,
 * so the grey silently lost; and --surface-2 is #FAF9F7, a cream that would
 * have been invisible on a white card even if it had won. Both fixed here:
 * beat the specificity, and use a grey that actually reads as one.
 */
.login-form input.ob-readonly,
.login-form input.ob-readonly:disabled {
  background: var(--surface-2);
  border-color: var(--border);
  color: var(--muted);
  cursor: default;
  -webkit-text-fill-color: var(--muted);   /* Safari greys disabled text its own way */
  opacity: 1;
}
/* A password field and its reveal control. The button sits inside the input's
   box rather than beside it, so the field keeps the full width every other
   input has and the form's rhythm is unchanged. */
.pw-wrap { position: relative; display: block; }
.pw-wrap input { padding-right: 48px; }
.pw-eye {
  position: absolute; top: 0; right: 0; bottom: 0;
  width: 46px;
  display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 0; padding: 0;
  color: var(--muted); cursor: pointer;
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  transition: color var(--t);
}
.pw-eye svg { width: 20px; height: 20px; display: block; }
.pw-eye:hover { color: var(--ink); }
/* tabindex is -1 so the control never lands between the two password fields,
   but a pointer user still gets a visible focus ring. */
.pw-eye:focus-visible { outline: none; box-shadow: var(--focus); color: var(--ink); }

.login-form input.ob-readonly:focus-visible {
  outline: none; box-shadow: none; border-color: var(--border);
}

/*
 * Eleven fields in a 440px column is a ribbon on a laptop, which is what this
 * looked like. The card widens for onboarding only, and the paired fields
 * become genuinely two-up once there is room. Below ~560px each pair collapses
 * to one column on its own, so the phone layout is untouched.
 */
.login-card.ob-card { max-width: 720px; }
@media (max-width: 700px) {
  .login-card.ob-card { max-width: 440px; }
}

/* A required select should look as answerable as a required input. */
.ob-f select {
  min-height: 48px;
  font-family: var(--sans); font-size: var(--t-md);
  padding: var(--s2) var(--s4);
  border: 1.5px solid var(--border-strong); border-radius: var(--r-sm);
  background: var(--surface); color: var(--ink);
}
.ob-f select:focus-visible { outline: none; border-color: var(--ink); box-shadow: var(--focus); }

/* ── Onboarding: what is required, said on screen ──────────────────────── */
/*
 * The required attribute marks nothing visually; it only blocks on submit.
 * The star is the notation. Crimson so it reads as an instruction rather than
 * punctuation, and aria-hidden on the markup because the required attribute
 * already tells a screen reader the same thing — announcing "star" after every
 * label would be noise.
 */
.req-star {
  color: var(--crimson);
  font-weight: 700;
  margin-left: 1px;
}
.ob-legend {
  margin: 0 0 4px;
  font-size: 12px;
  color: var(--muted);
  text-align: right;
}

/* â”€â”€ Chat bubble â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€ */
/*
 * One tap to every conversation, from any screen. Matches AscendChat's bubble
 * in AscendOS: a crimson disc, a filled white speech bubble, a soft halo.
 *
 * Sits ABOVE the tab bar rather than beside it, and the offset is computed from
 * the tab bar's own height plus the safe-area inset, so it lands correctly on a
 * notched iPhone and does not float over the tabs on any screen size. On the
 * screens with no tab bar it drops to the shorter offset.
 *
 * 56px, comfortably past the 44px tap-target minimum, and it is the only thing
 * occupying that corner â€” nothing in the app draws a control there.
 */
.chatbubble {
  position: fixed;
  right: max(16px, env(safe-area-inset-right, 0px));
  bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  /* Above page content, beneath every overlay: popovers and the admin
     panel sit at 30, the tab bar at 40, modal sheets at 50. A floating
     control that outranks a modal is how you get a button nobody can
     dismiss on top of a dialog. */
  z-index: 25;
  width: 56px; height: 56px;
  display: flex; align-items: center; justify-content: center;
  padding: 0; border: 0; border-radius: 50%;
  background: var(--fill-crimson);
  color: var(--white);
  cursor: pointer;
  box-shadow: 0 4px 14px rgba(188,30,45,0.38), 0 2px 5px rgba(21,23,27,0.18);
  transition: transform var(--t), box-shadow var(--t), background var(--t);
}
/* When the tab bar is showing, clear it. body.has-tabs is set by drawTabs, so
   the two can never disagree about whether the bar is there. */
body.has-tabs .chatbubble {
  bottom: calc(76px + env(safe-area-inset-bottom, 0px));
}
.chatbubble svg { width: 26px; height: 26px; display: block; }
.chatbubble:hover {
  background: var(--fill-crimson-hover);
  transform: translateY(-2px);
  box-shadow: 0 8px 22px rgba(188,30,45,0.45), 0 3px 8px rgba(21,23,27,0.2);
}
.chatbubble:active { transform: translateY(0) scale(0.96); }

/* ── The desktop chat panel ───────────────────────────────────────────────
   A conversation wants the whole viewport on a phone and nothing like it on a
   desktop, where taking the window for one column of bubbles also takes away
   the screen you were working on. Above the breakpoint chat is a panel in the
   corner its bubble already occupies; below it, none of this applies and chat
   is the full screen it has always been. */
.chatpanel { display: none; }

@media (min-width: 900px) {
  .chatpanel {
    display: flex; flex-direction: column;
    position: fixed;
    right: max(16px, env(safe-area-inset-right, 0px));
    /* The bubble this used to clear is hidden while the panel is up, so this is
       now just the panel's own inset off the bottom edge. */
    bottom: calc(20px + env(safe-area-inset-bottom, 0px));
    /* 400 was too mean for a desktop: a channel list showed three rooms before
       it needed scrolling. Capped against the viewport so a narrow window at
       the breakpoint still gets a panel with a margin rather than one hanging
       off the edge. */
    width: min(460px, calc(100vw - 32px));
    /* Never taller than the room above the bubble, so it cannot run off the top
       of a short window or under the topbar. The 680 ceiling was doing the
       limiting on any ordinary desktop, where the viewport arithmetic allows
       well over 800; it now only applies where it is genuinely the smaller.
       170 = the 84px the panel sits off the bottom, plus the 71px topbar, plus
       a gap. It was 148, which is 19px short and would have slid the panel
       under the topbar on any window tall enough for the old ceiling not to
       catch it first. Raising the ceiling is what made that reachable. */
    max-height: min(880px, calc(100vh - 106px));
    /* Under the bubble at 25, so the control that opens and closes the panel is
       never behind it. Sheets at 50 still cover the panel, which is what a
       modal is for. */
    z-index: 24;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--r-lg);
    box-shadow: 0 18px 48px rgba(21, 23, 27, 0.22), 0 4px 12px rgba(21, 23, 27, 0.12);
    overflow: hidden;
  }
  /* The tab bar is fixed to the same corner. body.has-tabs moves the bubble
     already, and the panel rides the same offset so the two never disagree. */
  body.has-tabs .chatpanel {
    bottom: calc(76px + env(safe-area-inset-bottom, 0px));
    /* Same arithmetic against the tab bar's offset: 76 + the 71px topbar + a gap. */
    max-height: min(880px, calc(100vh - 162px));
  }

  .chatpanel-bar {
    flex: none;
    display: flex; align-items: center; gap: 2px;
    padding: 8px 8px 8px 14px;
    border-bottom: 1px solid var(--chat-line);
  }
  .chatpanel-title {
    flex: 1; min-width: 0; margin: 0;
    font-family: var(--serif); font-size: 18px; font-weight: 700; color: var(--ink);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .chatpanel-icon {
    flex: none; width: 32px; height: 32px;
    border: 0; border-radius: 50%; background: none;
    color: var(--muted); font-size: 19px; line-height: 1;
    display: inline-flex; align-items: center; justify-content: center;
    cursor: pointer; transition: background var(--t), color var(--t);
  }
  .chatpanel-icon:hover { background: var(--chat-hover); color: var(--ink); }
  .chatpanel-icon:focus-visible { outline: none; box-shadow: var(--focus); }

  /* The panel's own scroll port. min-height:0 is what lets a flex child scroll
     instead of growing to fit its content and pushing the composer out of the
     panel entirely. The composer's sticky bottom then rides this box rather
     than the document. */
  .chatpanel-body {
    flex: 1; min-height: 0; overflow-y: auto;
    display: flex; flex-direction: column;
    padding: 12px 14px 0;
    /* background-color, not the shorthand: see .view.chat-screen. This is the
       other watermark container, and the shorthand was resetting the image. */
    background-color: var(--surface);
  }

  /* ── Split view ───────────────────────────────────────────────────────────
     Above 1180px the panel is wide enough to hold the conversation list beside
     the thread, which is how AscendChat is laid out: moving between rooms is
     one click instead of back-then-in, and you can see who else is waiting
     while you are typing. Below it the panel keeps its one-pane shape, because
     two columns inside 460px would be two cramped ones. */
  .chatpanel.is-split { width: min(880px, calc(100vw - 32px)); }
  .chatpanel-split {
    flex: 1; min-height: 0;
    display: flex; align-items: stretch;
  }
  .chatpanel-list {
    flex: none; width: 300px;
    min-height: 0; overflow-y: auto;
    padding: 12px 12px 12px;
    border-right: 1px solid var(--chat-line);
    background: var(--surface-2);
  }
  /* The list is the panel's secondary column, so its rows sit on the quieter
     ground and the thread keeps the white. */
  .chatpanel-split .chatpanel-body { flex: 1; min-width: 0; }
  /* Which room you are reading, marked in the list beside it. Without this the
     list and the thread look unrelated, which is the whole point of showing
     them together. */
  .chatpanel-list .chatrow.is-current {
    background: var(--crimson-tint);
    border-radius: var(--r-sm);
  }
  .chatpanel-empty {
    margin: auto; padding: 0 24px;
    text-align: center; color: var(--muted); font-size: var(--t-sm);
  }
}

/* Unread count. Ink rather than another red, so it reads against the crimson
   disc instead of disappearing into it. */
.bubble-dot {
  position: absolute; top: -2px; right: -2px;
  min-width: 22px; height: 22px; padding: 0 6px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 999px;
  background: var(--ink-fixed); color: var(--white);
  font-size: 11px; font-weight: 700; line-height: 1;
  border: 2px solid var(--white);
}

@media (prefers-reduced-motion: reduce) {
  .chatbubble, .chatbubble:hover, .chatbubble:active { transition: none; transform: none; }
}


/* â”€â”€ The channel list, as a chat tool â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€ */
/*
 * This screen used to open with an eyebrow, a title, a two-line lede and a
 * full-width photograph, then list channels as padded cards carrying member
 * and online counts. That reads as a directory page about chat rather than a
 * place to talk.
 *
 * A chat list is dense and scannable: avatar, name and time on one line, the
 * last message and the unread count on the next, separated by a hairline
 * rather than floated as cards. Everything below serves that.
 */
.chat-search { margin: 4px 0 8px; }
.chat-search input {
  width: 100%; box-sizing: border-box; min-height: 40px;
  padding: 8px 14px;
  font-family: var(--sans); font-size: var(--t-row);
  border: 1px solid var(--border); border-radius: 999px;
  background: var(--surface-2); color: var(--ink);
}
.chat-search input:focus-visible {
  outline: none; border-color: var(--ink); box-shadow: var(--focus);
  background: var(--surface);
}

/* A quiet group label, not a section heading competing with the rows. */
.chat-group {
  margin: 18px 0 2px;
  font-size: 11px; font-weight: 700; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--muted);
}
.chat-group:first-of-type { margin-top: 6px; }
/* The Messages heading carries the way in: a small text button on the same
   line, so starting a conversation costs one tap and no scrolling. */
.chat-group-row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.chat-new {
  font-family: var(--sans); font-size: 12px; font-weight: 700; letter-spacing: 0.02em;
  text-transform: none; color: var(--crimson); background: none; border: 0;
  padding: 2px 0; cursor: pointer;
}
.chat-new:hover { text-decoration: underline; }
.chat-new:focus-visible { outline: none; box-shadow: var(--focus); border-radius: 4px; }
.dm-empty { margin: 6px 4px 0; }
.contact-tag {
  display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 999px;
  font-size: 10px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--crimson); background: var(--surface-2); border: 1px solid var(--border);
  vertical-align: 2px;
}
/* The New message picker: a person per row, with the rooms you share so a
   name that is common in a big program still tells you who it is. */
.contact-list { display: flex; flex-direction: column; gap: var(--s2); max-height: 52vh; overflow-y: auto; }
.contact-row { display: flex; align-items: center; gap: 12px; }
.contact-av {
  flex: none; width: 36px; height: 36px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--ink-fixed); color: var(--white); font-size: 13px; font-weight: 700;
}
.contact-av.has-photo { background: var(--surface-2); overflow: hidden; }
.contact-av img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: 50%; }
.contact-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.contact-name { font-weight: 650; color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.contact-sub { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.chatrow {
  display: flex; align-items: center; gap: 12px;
  width: 100%; padding: 11px 4px;
  background: none; border: 0; border-bottom: 1px solid var(--border);
  text-align: left; cursor: pointer;
  transition: background var(--t);
}
.chatrow:last-of-type { border-bottom: 0; }
.chatrow:hover { background: rgba(21,23,27,0.03); }
.chatrow:active { background: rgba(21,23,27,0.06); }

.chatrow-av {
  flex: none; width: 44px; height: 44px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: var(--ink-fixed); color: var(--white);
  font-size: var(--t-row); font-weight: 650; letter-spacing: 0.02em;
}
.chatrow-av.has-unread { background: var(--fill-crimson); }

/* min-width:0 on both the body and the text spans, or a long last message
   pushes the row past the viewport instead of truncating. This exact bug has
   bitten the channel list before. */
.chatrow-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
.chatrow-top, .chatrow-bot {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 8px; min-width: 0;
}
.chatrow-name {
  font-size: var(--t-row); font-weight: 650; color: var(--ink);
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chatrow-time { flex: none; font-size: 12px; color: var(--muted); }
.chatrow-last {
  font-size: 13px; color: var(--muted);
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.chatrow-who { color: var(--ink); font-weight: 600; }
/* The row is one 13px line, nowrap, ellipsised. The thread's 1.35em custom
   emoji would set the line box taller than the channel name above it. */
.chatrow-last .cemoji { height: 1.1em; vertical-align: -0.18em; margin: 0; }
.chatrow.is-unread .chatrow-name { font-weight: 750; }
.chatrow.is-unread .chatrow-last { color: var(--ink); }
.chatrow .unread-pill { flex: none; }


/* â”€â”€ Profile picture â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€ */
/*
 * The picker sits at the top of Your info, where a face belongs, and shows the
 * current picture rather than describing it. Initials stand in until there is
 * one, in the same two-letter shape the channel list uses, so a list of people
 * looks consistent whether or not they have uploaded anything.
 */
.avatar-edit {
  display: flex; align-items: center; gap: 16px;
  margin: 0 0 8px; padding: 0 0 16px;
  border-bottom: 1px solid var(--border);
}
.avatar-preview {
  flex: none; width: 72px; height: 72px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
  background: var(--ink-fixed); color: var(--white);
  font-size: 24px; font-weight: 650; letter-spacing: 0.02em;
}
.avatar-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; }
/* A label styled as a button, because the real file input is visually hidden.
   It keeps the native picker and the keyboard behaviour for free. */
.avatar-pick { cursor: pointer; min-height: 40px; display: inline-flex; align-items: center; }
#ep-avatar-msg { min-height: 18px; }

/* The channel list's avatar, once it carries a photograph rather than a
   monogram: the initials' padding and colour would show through otherwise. */
.chatrow-av.has-photo { background: var(--surface-2); padding: 0; overflow: hidden; }
.chatrow-av img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: 50%; }
/* The crest where the monogram used to be. Contained with a little air rather
   than cover-cropped, because a crest cropped to a circle loses its shield, and
   on a pale ground because the mark is crimson and the disc used to be ink.
   After .has-unread deliberately: a crimson mark on a crimson disc is a hole,
   and equal specificity means the last rule wins. */
.chatrow-av.has-mark { background: var(--surface-2); padding: 7px; overflow: hidden; }
.chatrow-av.has-mark img { object-fit: contain; border-radius: 0; }

/* The programme's own lockup at the head of its channel, which is the one place
   it is big enough to read. Above the thread rather than in the topbar, so it
   reads as the beginning of the room and scrolls away, instead of taking a
   strip off every screen for something you only need to see once. */
.chan-intro { display: flex; justify-content: center; padding: 2px 0 14px; }
.chan-intro img { height: 62px; width: auto; max-width: 100%; display: block; }


/* A message avatar carrying a photograph rather than a monogram. The colour and
   letter-spacing below it are for initials, so they have to be cleared; the
   presence dot sits above the image either way. */
.msg-avatar.has-photo { background: var(--surface-2); overflow: hidden; }
.msg-avatar.has-photo img {
  width: 100%; height: 100%; object-fit: cover; display: block; border-radius: 50%;
}
.msg-avatar.has-photo .presence-dot { z-index: 1; }

/* ── Dark mode ───────────────────────────────────────────────────────────────
   The app was already token-driven, so this is a remap rather than a second
   stylesheet. What made it possible was splitting one token in two: --white
   used to mean both "the white of text sitting on crimson" and "the ground of
   a card", and in dark those go opposite ways. Backgrounds moved to --surface;
   --white now means literal white, and the four places still asking for it sit
   on the crimson bar, the crimson cash card, or Google's own button.

   The dark values are written once, as --d-* on :root, and then aliased twice:
   once for the system setting and once for an explicit choice. Aliasing rather
   than repeating the palette means the two can never drift apart, which is the
   failure this arrangement is guarding against; only the list of names is
   duplicated, and a missing name shows up immediately as a light patch.

   A colour must never have its ONLY definition inside the media query, or it
   vanishes the moment someone picks light on a dark system. Every light value
   stays on bare :root above. */
:root {
  color-scheme: light;

  /* Light values for the tokens introduced with dark mode. */
  --topbar-bg: rgba(246, 245, 242, 0.82);
  --hover-wash: rgba(21, 23, 27, 0.06);
  /* A dark ground that carries white text and does NOT flip with the scheme.
     --ink could not do this job: it is the text colour, so in dark it becomes
     near-white, and the sixteen chips, pills and filled buttons built on it
     would have been white text on a white ground. In dark this lifts rather
     than staying near-black, so a filled control still reads as raised against
     the page instead of dissolving into it; white clears AA on both values. */
  --ink-fixed: #15171B;
  --ink-fixed-soft: #2A2E36;
  /* The wash that fades the programme watermark to almost nothing. Near-opaque
     on purpose: the mark is meant to be sensed rather than read, and anything
     lighter competes with the messages sitting on top of it. */
  --wm-veil: rgba(255, 255, 255, 0.93);
  /* The brand red as a FILL, and deliberately the same in both schemes, so it
     is defined here and never aliased below. --crimson lightens in dark so that
     crimson TEXT clears AA on a near-black page, which is right for text and
     wrong underneath it: white on the lightened accent measures 3.16:1 and
     fails. This value carries white at 5.9:1 on either ground, and it is the
     brand red, so a filled button looks like Ascend in both. Decorative bars,
     dots and rules keep --crimson, because they carry nothing and are meant to
     lift in dark. */
  --fill-crimson: #BC1E2D;
  --fill-crimson-hover: #d42a3a;
  /* Google's button is specified by Google, in both schemes. */
  --g-bg: #ffffff;
  --g-line: #dadce0;
  --g-ink: #3c4043;

  /* One colour per program on the home tiles, so four boxes stop reading as
     one box four times. Camp is the brand itself and uses --crimson directly.
     Academy is amber rather than green: green is Montara's, and the two sit
     on the same screens. */
  --p-ms: #1F3A5F;
  /* Deep emerald, not the earlier leather brown: Sandel is the premium
     programme and the brown read muddy beside Academy's gold and MS's blue
     (Aditya, 2026-09-16). Crimson is the app's own accent, so a Sandel label
     in crimson would read as a button. */
  --p-sandel: #1B6B4A;
  --p-academy: #A8760F;
  --p-academy-bg: rgba(168, 118, 15, 0.12);

  /* The paper a partner's logo sits on. White in light, where it is the card
     itself; a warm off-white in dark, because a partner's mark is drawn for a
     light ground and putting it straight on charcoal loses its wordmark, while
     pure white there glares. It is the one light surface dark mode keeps. */
  --logo-tile: #ffffff;

  /* The AscendCash card's mid-tone. In light it is the ink the card has always
     been. In dark it is a brushed silver: the card first got that by accident,
     when --ink flipped to near-white inside its gradient, and Varun liked the
     metal (2026-09-03), so it is now a value of its own rather than a side
     effect of the text colour. */
  --cash-sheen: #15171B;

  /* The dark palette.

     Not black, and not near-black either. The first cut sat at #101013 and
     Aditya's read of the parent view was "a bit dark overall" (2026-09-03),
     so every ground moved up one step: the page to a charcoal, cards a step
     above it, borders a step above that. Text tokens did not move; the accent
     was re-cut because the lighter card ground had pulled it under AA. */
  --d-page: #1A1B20;
  --d-surface: #22242A;
  --d-surface-2: #2B2E35;
  --d-cream: #2B2E35;

  --d-ink: #F2F2F4;
  --d-ink-soft: #D6D7DB;
  --d-muted: #9A9DA5;
  --d-text-muted: #B8BBC2;
  --d-border: #383B44;
  --d-border-strong: #4A4E58;

  /* Brand crimson measures about 3.5:1 on this ground, which fails AA for
     text, so the accent lightens: 4.9:1 on --d-surface-2, the lightest ground
     it sits on. The deep tone stays as it is for fills that carry white text
     on top. The tab bar and the cash card keep the brand red regardless:
     those are large areas of colour, not text on a page. */
  --d-crimson: #F4737F;
  --d-crimson-deep: #BC1E2D;
  --d-crimson-light: #F8949D;
  --d-crimson-wash: rgba(244, 115, 127, 0.09);
  --d-crimson-tint: rgba(244, 115, 127, 0.13);
  --d-crimson-line: rgba(244, 115, 127, 0.32);
  --d-focus: 0 0 0 3px rgba(244, 115, 127, 0.34);

  --d-ok: #4ED07E;
  --d-ok-bg: rgba(78, 208, 126, 0.12);
  --d-ok-line: rgba(78, 208, 126, 0.30);
  --d-ok-ink: #7BDDA0;
  --d-warn-bg: rgba(226, 176, 74, 0.12);
  --d-warn-tint: rgba(226, 176, 74, 0.18);
  --d-warn-line: rgba(226, 176, 74, 0.34);
  --d-warn-ink: #E2B04A;
  --d-warn-strong: #E2B04A;
  --d-enrolled-ink: #6FD79D;
  --d-enrolled-bg: rgba(111, 215, 157, 0.12);

  /* iMessage's own dark treatment: the blue does not move, because it is the
     one thing that reads as "you" in either scheme, and the grey side goes to
     a lifted charcoal rather than to black. */
  --d-chat-them-bg: #30323A;
  --d-chat-them-ink: #F2F2F4;
  --d-chat-meta: #9A9DA5;
  --d-chat-line: #363840;
  --d-chat-chip: #34363E;
  --d-chat-hover: #3E4048;
  --d-chat-focus-line: #7A7A84;

  /* A shadow does nothing on a dark ground unless it is darker than it. */
  --d-e1: 0 1px 2px rgba(0, 0, 0, 0.40), 0 1px 3px rgba(0, 0, 0, 0.30);
  --d-e2: 0 2px 4px rgba(0, 0, 0, 0.42), 0 6px 16px rgba(0, 0, 0, 0.38);
  --d-e3: 0 4px 8px rgba(0, 0, 0, 0.46), 0 16px 32px rgba(0, 0, 0, 0.46);

  --d-wm-veil: rgba(34, 36, 42, 0.93);
  --d-ink-fixed: #3A3F49;
  --d-ink-fixed-soft: #4A505B;
  --d-topbar-bg: rgba(26, 27, 32, 0.82);
  --d-hover-wash: rgba(255, 255, 255, 0.07);
  --d-g-bg: #1F2024;
  --d-g-line: #8E918F;
  --d-g-ink: #E3E3E3;
  /* The program accents lifted a step, so they read against charcoal. */
  --d-p-ms: #6E97D0;
  --d-p-sandel: #5CCB9A;
  --d-p-academy: #E2AC3C;
  --d-p-academy-bg: rgba(226, 172, 60, 0.16);
  --d-logo-tile: #ECEBE7;
  --d-cash-sheen: #DCDDE2;
}

/* The system setting, unless the person has explicitly asked for light. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --page: var(--d-page);
    --surface: var(--d-surface);
    --surface-2: var(--d-surface-2);
    --cream: var(--d-cream);
    --ink: var(--d-ink);
    --ink-soft: var(--d-ink-soft);
    --muted: var(--d-muted);
    --text-muted: var(--d-text-muted);
    --border: var(--d-border);
    --border-strong: var(--d-border-strong);
    --crimson: var(--d-crimson);
    --crimson-deep: var(--d-crimson-deep);
    --crimson-light: var(--d-crimson-light);
    --crimson-wash: var(--d-crimson-wash);
    --crimson-tint: var(--d-crimson-tint);
    --crimson-line: var(--d-crimson-line);
    --focus: var(--d-focus);
    --ok: var(--d-ok);
    --ok-bg: var(--d-ok-bg);
    --ok-line: var(--d-ok-line);
    --ok-ink: var(--d-ok-ink);
    --warn-bg: var(--d-warn-bg);
    --warn-tint: var(--d-warn-tint);
    --warn-line: var(--d-warn-line);
    --warn-ink: var(--d-warn-ink);
    --warn-strong: var(--d-warn-strong);
    --enrolled-ink: var(--d-enrolled-ink);
    --enrolled-bg: var(--d-enrolled-bg);
    --chat-them-bg: var(--d-chat-them-bg);
    --chat-them-ink: var(--d-chat-them-ink);
    --chat-meta: var(--d-chat-meta);
    --chat-line: var(--d-chat-line);
    --chat-chip: var(--d-chat-chip);
    --chat-hover: var(--d-chat-hover);
    --chat-focus-line: var(--d-chat-focus-line);
    --e1: var(--d-e1);
    --e2: var(--d-e2);
    --e3: var(--d-e3);
    --wm-veil: var(--d-wm-veil);
    --ink-fixed: var(--d-ink-fixed);
    --ink-fixed-soft: var(--d-ink-fixed-soft);
  --topbar-bg: var(--d-topbar-bg);
    --hover-wash: var(--d-hover-wash);
    --g-bg: var(--d-g-bg);
    --g-line: var(--d-g-line);
    --g-ink: var(--d-g-ink);
    --p-ms: var(--d-p-ms);
    --p-sandel: var(--d-p-sandel);
    --p-academy: var(--d-p-academy);
    --p-academy-bg: var(--d-p-academy-bg);
    --logo-tile: var(--d-logo-tile);
    --cash-sheen: var(--d-cash-sheen);
  }
}

/* An explicit choice, which has to win on a light system too. */
:root[data-theme="dark"] {
  color-scheme: dark;
  --page: var(--d-page);
  --surface: var(--d-surface);
  --surface-2: var(--d-surface-2);
  --cream: var(--d-cream);
  --ink: var(--d-ink);
  --ink-soft: var(--d-ink-soft);
  --muted: var(--d-muted);
  --text-muted: var(--d-text-muted);
  --border: var(--d-border);
  --border-strong: var(--d-border-strong);
  --crimson: var(--d-crimson);
  --crimson-deep: var(--d-crimson-deep);
  --crimson-light: var(--d-crimson-light);
  --crimson-wash: var(--d-crimson-wash);
  --crimson-tint: var(--d-crimson-tint);
  --crimson-line: var(--d-crimson-line);
  --focus: var(--d-focus);
  --ok: var(--d-ok);
  --ok-bg: var(--d-ok-bg);
  --ok-line: var(--d-ok-line);
  --ok-ink: var(--d-ok-ink);
  --warn-bg: var(--d-warn-bg);
  --warn-tint: var(--d-warn-tint);
  --warn-line: var(--d-warn-line);
  --warn-ink: var(--d-warn-ink);
  --warn-strong: var(--d-warn-strong);
  --enrolled-ink: var(--d-enrolled-ink);
  --enrolled-bg: var(--d-enrolled-bg);
  --chat-them-bg: var(--d-chat-them-bg);
  --chat-them-ink: var(--d-chat-them-ink);
  --chat-meta: var(--d-chat-meta);
  --chat-line: var(--d-chat-line);
  --chat-chip: var(--d-chat-chip);
  --chat-hover: var(--d-chat-hover);
  --chat-focus-line: var(--d-chat-focus-line);
  --e1: var(--d-e1);
  --e2: var(--d-e2);
  --e3: var(--d-e3);
  --wm-veil: var(--d-wm-veil);
  --ink-fixed: var(--d-ink-fixed);
  --ink-fixed-soft: var(--d-ink-fixed-soft);
  --topbar-bg: var(--d-topbar-bg);
  --hover-wash: var(--d-hover-wash);
  --g-bg: var(--d-g-bg);
  --g-line: var(--d-g-line);
  --g-ink: var(--d-g-ink);
  --p-ms: var(--d-p-ms);
  --p-sandel: var(--d-p-sandel);
  --p-academy: var(--d-p-academy);
  --p-academy-bg: var(--d-p-academy-bg);
  --logo-tile: var(--d-logo-tile);
  --cash-sheen: var(--d-cash-sheen);
}

/* The appearance control: three segments, one chosen. */
.seg {
  display: flex; width: 100%;
  border: 1px solid var(--border-strong); border-radius: var(--r-sm);
  overflow: hidden;
}
.seg-opt {
  flex: 1; min-height: 44px; padding: 10px 8px;
  border: 0; border-right: 1px solid var(--border-strong);
  background: var(--surface); color: var(--muted);
  font-family: var(--sans); font-size: var(--t-sm); font-weight: 600;
  cursor: pointer; transition: background var(--t), color var(--t);
}
.seg-opt:last-child { border-right: 0; }
.seg-opt:hover { background: var(--surface-2); color: var(--ink); }
/* --crimson-deep rather than --crimson: in dark the accent lightens to clear AA
   for text, and white on that lighter pink is about 2.3:1. The deep tone is the
   brand red in both schemes and carries white properly. */
.seg-opt.on { background: var(--fill-crimson); color: var(--white); }
.seg-opt.on:hover { background: var(--fill-crimson); color: var(--white); }
.seg-opt:focus-visible { outline: none; box-shadow: var(--focus); position: relative; z-index: 1; }

/* ── Dark-mode corrections ───────────────────────────────────────────────────
   The handful of places a remapped token is not enough. */

/* The Ascend crest is dark ink on transparency and disappears against a
   near-black bar. brightness(0) flattens every pixel to black and invert(1)
   takes it to white, which leaves the alpha channel alone, so the crest keeps
   its shape and loses its colour. Plain white rather than a tinted white: the
   gold this used to produce read as a different logo. */
:root[data-theme="dark"] .topbar-brand img,
:root[data-theme="dark"] .login-logo {
  filter: brightness(0) invert(1);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .topbar-brand img,
  :root:not([data-theme="light"]) .login-logo {
    filter: brightness(0) invert(1);
  }
}

/* A photograph at full brightness on a dark page is a lamp. Barely dimmed, so
   faces stay readable; the point is to stop it glaring, not to hide it. */
:root[data-theme="dark"] .card-photo img,
:root[data-theme="dark"] .prog-tile:not(.is-locked) .prog-photo img {
  filter: brightness(0.88);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .card-photo img,
  :root:not([data-theme="light"]) .prog-tile:not(.is-locked) .prog-photo img {
    filter: brightness(0.88);
  }
}

/* ── Per-channel notifications ──────────────────────────────────────────
   The bell pill at the top of a thread, the three-option sheet it opens
   (built on the message context-menu overlay), and the list on the chat
   home. Colours are tokens so dark mode gets them for free. */
.chan-tools { display: flex; justify-content: flex-end; margin: 0 0 6px; }
.notify-pill { min-height: 36px; padding: 6px 12px; gap: 6px; display: inline-flex; align-items: center; }
.notify-menu { min-width: min(100%, 340px); }
.notify-head { padding: 12px 16px 6px; font-size: 13px; color: var(--muted); }
.notify-opt { min-height: 52px; align-items: center; }
.notify-opt > span:first-child { display: flex; flex-direction: column; gap: 2px; text-align: left; min-width: 0; }
.notify-opt-label { font-weight: 600; }
.notify-opt-sub { font-size: 12px; color: var(--muted); line-height: 1.35; white-space: normal; }
.notify-opt.is-on .notify-opt-label { color: var(--crimson); }
.notify-list { display: flex; flex-direction: column; gap: 4px; }
.notify-row { display: flex; align-items: center; justify-content: space-between; gap: 10px;
  width: 100%; min-height: 44px; padding: 8px 12px; border: 1px solid var(--border);
  border-radius: 10px; background: var(--surface); color: inherit; font: inherit; text-align: left; cursor: pointer; }
.notify-row-name { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.notify-row-level { flex: none; font-size: 13px; color: var(--muted); white-space: nowrap; }
.notify-row-level.is-off { color: var(--crimson); }
/* ── Invite a friend ──────────────────────────────────────────────────── */
.ref-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.ref-code { font-family: "DM Mono", ui-monospace, Menlo, Consolas, monospace; font-size: 22px; font-weight: 700;
  letter-spacing: .06em; padding: 8px 12px; border: 1px dashed var(--border-strong); border-radius: 8px;
  background: var(--surface); user-select: all; }
.ref-link { overflow-wrap: anywhere; }
.ref-list { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 8px; }
.ref-item { display: flex; justify-content: space-between; gap: 10px; padding: 6px 0; min-width: 0; }
.ref-item > span:first-child { font-weight: 600; }
.ref-fine { margin: 10px 0 0; font-size: 11px; line-height: 1.45; }

/* ── Calendar ─────────────────────────────────────────────────────────────── */
/* One colour per program, the same three the tiles already own, carried on a
   single custom property so a row, a dot and a chip all agree. */
.cal-k-aa { --cal-c: var(--p-academy); }
.cal-k-sandel { --cal-c: var(--p-sandel); }
.cal-k-ms { --cal-c: var(--p-ms); }

.cal-chips { display: flex; flex-wrap: wrap; gap: var(--s2); margin: 0 0 var(--s4); }
.cal-chip {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 7px 12px; min-height: 34px; border-radius: var(--r-full);
  background: var(--surface); border: 1px solid var(--border); color: var(--ink);
  font-family: var(--sans); font-size: var(--t-sm); font-weight: 600; cursor: pointer;
  transition: background var(--t), border-color var(--t);
}
.cal-chip.on { background: var(--ink-fixed); border-color: var(--ink-fixed); color: var(--white); }
.cal-chip .cal-dot { width: 8px; height: 8px; }
.cal-chip.on .cal-dot { box-shadow: 0 0 0 1.5px rgba(255,255,255,0.75); }

.cal-card { padding: var(--s4) var(--s4) var(--s3); }
.cal-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); margin-bottom: var(--s3); }
.cal-month { font-family: var(--serif); font-size: 22px; font-weight: 700; letter-spacing: -0.01em; margin: 0; }
.cal-nav {
  width: 40px; height: 40px; border-radius: var(--r-full); border: 1px solid var(--border);
  background: var(--surface); color: var(--ink); font-size: 22px; line-height: 1; cursor: pointer;
  display: grid; place-items: center; transition: background var(--t);
}
.cal-nav:hover { background: var(--surface-2); }
.cal-dow, .cal-grid {
  display: grid;
  /* minmax(0, 1fr), not 1fr: seven tracks must shrink to fit 360px. */
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 2px;
}
.cal-dow span {
  text-align: center; font-size: var(--t-label); font-weight: 600; letter-spacing: 0.08em;
  color: var(--muted); padding: 2px 0 6px;
}
.cal-cell {
  position: relative; min-width: 0; min-height: 44px; padding: 5px 0 6px;
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  border: 1px solid transparent; border-radius: var(--r-sm); background: none;
  font-family: var(--sans); color: var(--ink); cursor: pointer; transition: background var(--t);
}
.cal-cell.is-blank { cursor: default; }
.cal-cell:not(.is-blank):hover { background: var(--hover-wash, var(--surface-2)); }
.cal-cell.is-past .cal-num { color: var(--muted); }
.cal-num {
  width: 28px; height: 28px; display: grid; place-items: center; border-radius: var(--r-full);
  font-size: var(--t-base); font-variant-numeric: tabular-nums;
}
.cal-cell.is-today .cal-num { box-shadow: inset 0 0 0 1.5px var(--crimson); color: var(--crimson); font-weight: 600; }
.cal-cell.is-selected .cal-num { background: var(--ink-fixed); color: var(--white); box-shadow: none; font-weight: 600; }
.cal-dots { display: flex; gap: 3px; height: 6px; }
.cal-dot { display: inline-block; width: 6px; height: 6px; border-radius: var(--r-full); background: var(--cal-c, var(--crimson)); }
.cal-loading { margin: var(--s2) 0 0; text-align: center; }

.cal-list-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s3); margin: var(--s5) 0 var(--s3); }
.cal-list-title { font-family: var(--serif); font-size: 21px; font-weight: 700; letter-spacing: -0.01em; margin: 0; }
.cal-list { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-md); box-shadow: var(--e1); padding: 0 var(--s4); margin-bottom: var(--s3); }

/* An event is a <details>: the summary is the row, the body the detail. Native
   disclosure, no JavaScript to keep open state across re-renders. */
.cal-ev { border-bottom: 1px solid var(--border); }
.cal-ev:last-child { border-bottom: none; }
.cal-ev > summary {
  list-style: none; display: flex; align-items: center; gap: var(--s3);
  padding: var(--s3) 0; min-height: 60px; cursor: pointer;
}
.cal-ev > summary::-webkit-details-marker { display: none; }
.cal-ev-when {
  flex: none; width: 86px; display: flex; flex-direction: column; gap: 2px;
  padding-left: 10px; border-left: 3px solid var(--cal-c, var(--crimson));
  font-variant-numeric: tabular-nums;
}
.cal-ev-day { font-size: var(--t-xs); color: var(--muted); white-space: nowrap; }
.cal-ev-time { font-size: var(--t-sm); font-weight: 600; white-space: nowrap; }
.cal-ev-body { flex: 1; min-width: 0; }
.cal-ev-title { display: block; font-size: var(--t-row); font-weight: 600; letter-spacing: -0.005em; }
/* Which program a row belongs to, in that program's colour, above the title.
   The dot and the border already carried the colour; the word makes it
   legible to someone who has not learned the colours. */
.cal-ev-prog {
  display: block; font-size: var(--t-micro); font-weight: 600; letter-spacing: 0.1em;
  text-transform: uppercase; color: var(--cal-c, var(--crimson)); margin-bottom: 2px;
}
.cal-ev-sub { display: block; margin-top: 2px; font-size: var(--t-sm); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cal-ev-meta { flex: none; display: flex; align-items: center; gap: var(--s2); }
.cal-ev-meta .status { margin: 0; }
.cal-ev[open] > summary .row-chev { transform: rotate(90deg); }
.row-chev { transition: transform var(--t); }
.cal-ev.is-past > summary { opacity: 0.6; }
.cal-ev.is-live .cal-ev-when { border-left-color: var(--crimson); }
.cal-ev-detail { padding: 0 0 var(--s4) calc(86px + var(--s3)); }
.cal-ev-acts { display: flex; flex-wrap: wrap; gap: var(--s2); margin-top: var(--s2); }
.cal-ev-acts:empty { display: none; }
@media (max-width: 400px) {
  .cal-ev-when { width: 74px; }
  .cal-ev-day { font-size: var(--t-label); }
  .cal-ev-detail { padding-left: 0; }
}

/* Home rows: the program colour on the icon chip; the date stacked in the meta. */
.cal-row .cal-ico { color: var(--cal-c, var(--crimson)); background: color-mix(in srgb, var(--cal-c, var(--crimson)) 9%, transparent); border-color: color-mix(in srgb, var(--cal-c, var(--crimson)) 22%, transparent); }
.cal-when { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; font-size: var(--t-xs); line-height: 1.25; text-align: right; }
.cal-when span:last-child { font-weight: 600; color: var(--ink); }
.cal-live { margin: 0; }

/* A recording plays where the session is: inside the expanded row. 16:9, on a
   dark plate so the player's letterboxing has somewhere to go. */
.cal-player {
  aspect-ratio: 16 / 9; width: 100%; max-width: 100%;
  margin: var(--s2) 0 var(--s3); border-radius: var(--r-sm); overflow: hidden;
  background: var(--ink-fixed);
}
.cal-player iframe, .cal-player video { width: 100%; height: 100%; border: 0; display: block; background: var(--ink-fixed); }
.cal-player-private { display: grid; place-items: center; }
.cal-play-err { color: var(--white); margin: 0; padding: var(--s4); text-align: center; }
.cal-play-err a { color: var(--white); }
.cal-rec { display: grid; place-items: center; width: 22px; height: 22px; color: var(--cal-c, var(--crimson)); }
.cal-rec svg { width: 18px; height: 18px; }
.cal-group-title { margin: var(--s5) 0 var(--s3); }

/* Add to my calendar */
.cal-sync { margin: 0 0 var(--s4); }
.cal-sync-acts { display: flex; flex-wrap: wrap; gap: var(--s2); }
.cal-sync-acts .btn { flex: 1 1 auto; min-width: 0; }

/* ── Attendance (attendance.js) ─────────────────────────────────────────────
   One row per student: the name, then Present / Late / Absent on the .seg
   control. Present and Late fill with their own tint/ink pair so both themes
   keep contrast; Absent keeps the crimson fill .seg-opt.on already has. */
.att-pick-label { display: block; margin: 0 0 6px; }
.att-pick {
  display: block; width: 100%; min-height: 44px; margin: 0 0 var(--s5); padding: 10px 12px;
  border: 1px solid var(--border-strong); border-radius: var(--r-sm);
  background: var(--surface); color: var(--ink); font-family: var(--sans); font-size: var(--t-sm);
}
.att-tally { margin: 0 0 var(--s3); }
.att-t-present { color: var(--ok-ink); }
.att-t-late { color: var(--warn-ink); }
.att-t-absent { color: var(--crimson); }
.att-all { width: 100%; margin: 0 0 var(--s4); }
.att-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
.att-row {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--s3);
  padding: var(--s3) var(--s4); min-width: 0;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-sm);
}
.att-row.is-unmarked { border-left: 3px solid var(--warn-strong); }
.att-name { display: flex; flex-direction: column; gap: 2px; flex: 1 1 140px; min-width: 0; }
.att-name strong { overflow-wrap: anywhere; }
.att-seg { width: auto; flex: 0 0 auto; }
.att-seg .seg-opt { min-width: 76px; }
.att-seg .seg-opt.att-present.on,
.att-seg .seg-opt.att-present.on:hover { background: var(--ok-bg); color: var(--ok-ink); box-shadow: inset 0 0 0 2px var(--ok); }
.att-seg .seg-opt.att-late.on,
.att-seg .seg-opt.att-late.on:hover { background: var(--warn-tint); color: var(--warn-ink); box-shadow: inset 0 0 0 2px var(--warn-strong); }
@media (max-width: 520px) {
  .att-seg { width: 100%; }
  .att-seg .seg-opt { min-width: 0; }
}
