/* ---------------------------------------------------------------- tokens ---
 * Ported from the fan app's design system. The app keeps every colour in one
 * file, NewTunes/Theme/AppTheme.swift, and this is that file's web half, so
 * a value changed there has exactly one place to land here.
 *
 * The app is a native-feeling iOS product: system greys, an indigo accent, SF
 * Pro, capsules, and glass panels edged with hairlines. The accent is #6155F5,
 * the background of the logo, so the one coloured thing on the page and the
 * mark in the corner are the same colour.
 */
:root {
  /* Radii, named for the role the app gives them rather than the number, so a
     call site says what it is instead of how round it is. */
  --r-thumb: 8px;    /* small artwork thumbnails    */
  --r-art: 10px;     /* hero artwork                */
  --r-card: 12px;    /* AppTheme.cornerRadius       */
  --r-field: 14px;   /* inputs, primary buttons     */
  --r-panel: 16px;   /* cornerRadius + 4            */
  --r-sheet: 18px;   /* large panels                */
  --r-tile: 26px;    /* the auth screen's logo tile */
  --r-pill: 999px;

  /* AppTheme layout constants */
  --sp-card: 16px;   /* cardPadding      */
  --sp-sect: 24px;   /* sectionSpacing   */
  --sp-air: 48px;    /* breathingRoom    */
  --chip-h: 30px;    /* chipHeight       */
  --chip-icon-w: 34px; /* chipIconWidth  */

  --maxw: 1060px;
  --maxw-legal: 720px; /* prose column on privacy.html; the nav matches it */

  /* quickAnimation / standardAnimation / the button press */
  --ease-out: cubic-bezier(0.22, 0.8, 0.28, 1);
  --t-quick: 0.2s;
  --t-std: 0.3s;
  --t-press: 0.15s;

  /* Fixed semantics. Non-adaptive in the app, so non-adaptive here. */
  --like: #34c759;      /* AppTheme.likeGreen  */
  --error: #ff3b30;     /* AppTheme.skipRed    */
  --verified: #1d9bf0;
  --on-accent: #ffffff;
  --success: #34c759;
  /* AppTheme.bubbleIncoming / bubbleOutgoing. The outgoing bubble is dark in
     both schemes rather than accent-coloured — it inverts far less than the page
     around it — so its text is onAccent white in both, and is not adaptive. */
  --bubble-in: #e9e9eb;
  --bubble-out: #1a1a1a;
  --on-bubble-out: #ffffff;

  /* Shadows, at the app's values. */
  --sh-btn: 0 6px 12px rgba(0, 0, 0, 0.18);     /* AuthPrimaryButton */
  --sh-focus: 0 4px 10px rgba(0, 0, 0, 0.12);   /* focused AuthField */
  --sh-card: 0 6px 16px rgba(0, 0, 0, 0.15);    /* SongSwipeCard     */
  --sh-circle: 0 3px 6px rgba(0, 0, 0, 0.08);   /* circle button     */
  --sh-glow: 0 8px 20px rgba(97, 85, 245, 0.25);/* the logo tile     */

  --glass: saturate(180%) blur(20px);

  /* AuthBackground's two blobs, at .2 and .12. Scheme-independent, as in the
     app; the wash is the same wash on white and on black. */
  --glow-strong: rgba(97, 85, 245, 0.2);
  --glow-soft: rgba(97, 85, 245, 0.12);
}

/* Light is the canonical mode: it's what the app opens in for most people,
   and what its contrast reasoning is written against. Both themes are driven
   by data-theme on <html>, which landing-v1.html's inline script sets before
   first paint. index.html and privacy.html state dark on the element itself and
   never change it, so for them this is a palette rather than a switch. */
:root,
:root[data-theme="light"] {
  color-scheme: light;

  --accent: #6155f5;                    /* the logo's background     */
  --accent-wash: rgba(97, 85, 245, 0.12);
  --accent-ring: rgba(97, 85, 245, 0.22);
  --bg: #ffffff;                        /* systemBackground          */
  --bg-blur: rgba(255, 255, 255, 0.72);
  /* panelMaterial(): the pane a panel floating on a *card* gets, where the
     card's own surface is the song's artwork blurred and scrimmed. Light
     mode is .regular — AppTheme's note says .ultraThin passes a pale
     scrimmed sleeve through almost unchanged and the panel stops looking
     like an object, so it frosts harder here. */
  --panel-fill: rgba(255, 255, 255, 0.72);
  --surface: #f2f2f7;                   /* secondarySystemBackground */
  --surface-2: #e5e5ea;
  --text: #000000;                      /* label                     */
  --text-dim: rgba(60, 60, 67, 0.6);    /* secondaryLabel            */
  --text-faint: rgba(60, 60, 67, 0.4);
  --separator: rgba(60, 60, 67, 0.29);  /* separator                 */
  --chip-fill: rgba(0, 0, 0, 0.12);     /* AppTheme.chipFill         */
  --hairline: rgba(0, 0, 0, 0.18);      /* AppTheme.surfaceStroke    */
  --rating: #d68f00;
  --rating-empty: rgba(60, 60, 67, 0.32); /* AppTheme.ratingEmpty    */

  /* A feed post's surface is the song's artwork, blurred and scrimmed. The
     scrim lightens it here and darkens it in dark mode, so the card adapts to
     the page rather than to the sleeve. --bg-0 is the page colour at zero
     alpha, which is what a post's edges fade to: `transparent` is not the same
     thing, and fading white to it can grey on the way. */
  --post-scrim: rgba(255, 255, 255, 0.55);
  --bg-0: rgba(255, 255, 255, 0);

  /* The app's primary button is not accent-coloured; it's near-black on
     white and near-white on black, inverting between the two modes. */
  --btn: #1a1a1a;
  --btn-fg: #ffffff;

  /* panelShadow(): a pale panel needs lifting off a pale page. Dark mode has
     nothing to lift, so it gets none. */
  --lift: 0 6px 16px rgba(0, 0, 0, 0.1);
  --spot: rgba(0, 0, 0, 0.05);
}

:root[data-theme="dark"] {
  color-scheme: dark;

  --accent: #6155f5;
  --accent-wash: rgba(97, 85, 245, 0.22);
  --accent-ring: rgba(97, 85, 245, 0.35);
  --bg: #000000;
  --bg-blur: rgba(0, 0, 0, 0.72);
  /* .ultraThin on a dark card, which lightens rather than darkens: the panel
     is a pane laid over the artwork. --bg-blur is the *page's* material and
     goes the other way — 72% black on a dark card is a hole, not a pane,
     and it is what made the rating pill read as a slab. */
  --panel-fill: rgba(255, 255, 255, 0.14);
  --surface: #1c1c1e;
  --surface-2: #2c2c2e;
  --text: #ffffff;
  --text-dim: rgba(235, 235, 245, 0.6);
  --text-faint: rgba(235, 235, 245, 0.4);
  --separator: rgba(84, 84, 88, 0.6);
  --chip-fill: rgba(255, 255, 255, 0.12);
  --hairline: rgba(255, 255, 255, 0.12);
  --rating: #ffcc00;
  --rating-empty: rgba(255, 255, 255, 0.24);
  --post-scrim: rgba(0, 0, 0, 0.4);
  --bg-0: rgba(0, 0, 0, 0);
  --bubble-in: #3a3a3c;
  --bubble-out: #2c2c2e;

  --btn: #f5f5f5;
  --btn-fg: #1a1a1a;

  --lift: none;
  --spot: rgba(255, 255, 255, 0.06);
}

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

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

body {
  /* The app uses no custom font: everything is .system(), i.e. SF Pro. On the
     iPhones this page is selling, this stack *is* the app's typeface; Inter
     only catches everyone else. The deck below has always opted into it. */
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: 1.5;
  min-height: 100vh;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
  transition: background-color 0.25s, color 0.25s;
}

/* ------------------------------------------------------------------ icons --
 * One drawing per SF Symbol, defined once in the page's sprite and pulled in
 * wherever it's needed with <use>. Size comes from the component the icon sits
 * in — every rule that sizes an icon below targets `svg` — and weight comes from
 * these classes, because stroke-width is inherited and so reaches through <use>
 * into the symbol, whose paths deliberately don't set it. That's what lets one
 * definition serve a 10px chevron at semibold and a 27px xmark at bold.
 */
/* Horizontal shelves: the deck's genre row, Browse's carousels, the chart
   picker. They scroll by thumb on their own, and app.js gives a desktop pointer
   the drag it otherwise has no gesture for — see attachShelfScroll(). The cursor
   is the only thing that says so, since the app draws no scrollbar and neither
   do we; the row running off the trailing edge is the other half of saying it.
   overscroll-behavior keeps a flick inside the shelf instead of chaining out to
   the page once it hits its end. */
.deck-chips,
.carousel,
.chart-picker { cursor: grab; overscroll-behavior-x: contain; }
.deck-chips.is-grabbing,
.carousel.is-grabbing,
.chart-picker.is-grabbing { cursor: grabbing; }
/* Nothing inside a shelf should look draggable in its own right. */
.deck-chips > *,
.carousel > *,
.chart-picker > * { cursor: default; }

.sf-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.ic { stroke-width: 1.7; }              /* .regular  */
.ic.w-medium { stroke-width: 1.95; }    /* .medium   */
.ic.w-semibold { stroke-width: 2.3; }   /* .semibold */
.ic.w-bold { stroke-width: 2.7; }       /* .bold     */

/* AuthBackground: two accent blobs over the page colour, sized ~1.1x the
   viewport and pushed mostly off-canvas. Kept faint on purpose; it should
   read as depth, not as a coloured screen.
 *
 * No filter: blur(). The app blurs because it is compositing two hard-edged
 * Circles; a radial-gradient already *is* the falloff, and blurring a fixed
 * inset:0 layer smears its own boundary into a visible seam down the edge of
 * the viewport. The transparent stop is written as a zero-alpha indigo rather
 * than `transparent`, which some engines interpolate through black and grey
 * the fade out. */
.bg-glow {
  position: fixed;
  inset: 0;
  z-index: -1;
  background:
    radial-gradient(55vw 55vw at 96vw 16vw, var(--glow-strong), rgba(97, 85, 245, 0) 70%),
    radial-gradient(55vw 55vw at 4vw 100%, var(--glow-soft), rgba(97, 85, 245, 0) 70%);
  pointer-events: none;
}

.wrap {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 40px 22px 48px;
}

/* -------------------------------------------------------------- sticky nav */
#nav-sentinel { position: absolute; top: 0; height: 1px; width: 100%; }

.nav-bar {
  position: sticky;
  top: 0;
  z-index: 50;
  border-bottom: 1px solid transparent;
  transition: background 0.25s, border-color 0.25s, backdrop-filter 0.25s;
}
.nav-bar.is-stuck {
  background: var(--bg-blur);
  -webkit-backdrop-filter: var(--glass);
  backdrop-filter: var(--glass);
  border-bottom-color: var(--separator);
}
.nav {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 14px 22px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; }
/* The page's ask, in the bar. Same accent capsule as the two on the page, one
   size down — it is a reminder of the CTA, not a third one competing with them.
   [hidden] needs saying out loud: an author `display` beats the UA's. */
.nav-cta[hidden] { display: none; }
.nav-cta {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 34px;
  padding: 0 14px 0 16px;
  border: none;
  border-radius: var(--r-pill);
  background: var(--accent);
  color: var(--on-accent);
  font-family: inherit;
  font-size: 14px;
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  box-shadow: var(--sh-glow);
  animation: nav-cta-in 0.28s var(--ease-out);
  transition: opacity var(--t-press), transform var(--t-press);
}
.nav-cta .ic { width: 13px; height: 13px; transition: transform var(--t-press) var(--ease-out); }
.nav-cta:hover { opacity: 0.92; }
.nav-cta:hover .ic { transform: translateX(3px); }
.nav-cta:active { transform: scale(0.97); }
.nav-cta:focus-visible {
  outline: none;
  box-shadow: var(--sh-glow), 0 0 0 3px var(--bg), 0 0 0 5px var(--accent);
}
@keyframes nav-cta-in {
  from { opacity: 0; transform: translateY(-6px) scale(0.96); }
  to   { opacity: 1; transform: none; }
}

/* AuthBrandHeader's logo tile, at nav scale. The logo now ships as a finished
   full-colour icon that draws its own squircle, so the tile no longer supplies
   a surface, a tint or an accent ring: layering any of those on top would put a
   second border around a mark that already has one. The glyph is the artwork
   itself, filling the tile edge to edge, with only a soft shadow underneath so
   it sits on the page the way the app icon sits on a home screen. */
.brand-icon {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  position: relative;
  filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.22));
}
.brand-glyph {
  width: 100%;
  height: 100%;
  /* One 192px asset rather than a 1x/2x pair: at a 30px tile that covers 6x, so
     it stays sharp on any display and under zoom for 23KB. */
  background: url(logo-192.png) center / contain no-repeat;
}
/* -- The face the name is drawn in ----------------------------------------- */
/* Nutunes Sans, from `Fonts/Nutunes Sans/`. Six glyphs — N u t n e s — and nothing else:
   no other letters, no digits, no punctuation. It sets the wordmark and must
   never be asked to set anything longer.

   It lives here rather than in landing-v2.css because all three pages carry the
   same bar, and the privacy page and v1 were left setting the name in the UI
   font while the page they link back to set it in its own.

   `block` rather than `swap` because the two cuts are ~1.6 KB each off our own
   origin, so nothing waits on them for long, and a flash of the system font in
   their place is the only outcome worth avoiding. Every page that loads this
   sheet needs `font-src 'self'` in its policy — default-src 'none' refuses them
   otherwise, and the failure looks like nothing more than the old font. */
@font-face {
  font-family: "Nutunes Sans";
  src: url(fonts/NutunesSans-Regular.woff2) format("woff2");
  font-weight: 400;
  font-display: block;
}
@font-face {
  font-family: "Nutunes Sans";
  src: url(fonts/NutunesSans-Bold.woff2) format("woff2");
  font-weight: 700;
  font-display: block;
}

/* The wordmark, set in the name's own face — capital N and all.

   Worth knowing before touching this: that N is not a drawn letter. It is the
   logo artwork itself, the same figure .brand-icon holds beside it, so the bar
   carries the mark twice by design — once as the app's rounded icon and once as
   white line art inside the word, which is how the wordmark is drawn.

   Bigger than the 17px it replaced. Bold is the cut to use — its lowercase is
   drawn to the mark's own stem width, so the word reads as one piece rather
   than as artwork sitting next to lighter letters. Tracked out because Bold is
   fitted tight and two pairs pay for it at this size: the N hangs its sparkle
   over what follows, and the t is drawn to sit closer than the rest.

   No vertical nudge: with the N present the word's ink runs cap-to-note-head,
   and that spread happens to centre on the line box on its own. */
.brand-name {
  font-family: "Nutunes Sans", -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
  font-weight: 700;
  font-size: 23px;
  letter-spacing: 0.08em;
  line-height: 1;
}
.nav-right { display: flex; align-items: center; gap: 10px; }

/* The app's circular icon buttons: the compose and bell in the feed header. */
.theme-toggle {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: var(--r-pill);
  border: 0.5px solid var(--hairline);
  background: var(--surface);
  color: var(--text);
  cursor: pointer;
  box-shadow: var(--sh-circle);
  transition: color var(--t-press), border-color var(--t-press), background var(--t-press), transform var(--t-press);
}
.theme-toggle:hover { border-color: var(--separator); }
.theme-toggle:active { transform: scale(0.97); }
.theme-toggle:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
/* Show the icon for the mode you'd switch *to*. */
.theme-toggle .icon-sun { display: none; }
:root[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
:root[data-theme="dark"] .theme-toggle .icon-moon { display: none; }

/* ------------------------------------------------------------ scroll reveal */
.reveal {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out);
}
.reveal.is-visible { opacity: 1; transform: none; }

/* -------------------------------------------------------------------- hero */
.hero {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
  gap: 48px;
  align-items: start;
  /* Tight to the genre marquee below. The hero already ends in whitespace: the
     deck's caption line reserves its height whether or not it has text, so a
     large margin on top of that read as a hole in the page. */
  margin-bottom: 24px;
}
.hero-copy { min-width: 0; }
/* SectionHeader: uppercased, 12 semibold, tracked, secondary. */
.eyebrow {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: 18px;
}
.headline {
  font-size: clamp(32px, 5.4vw, 44px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.08;
  margin-bottom: 20px;
}
/* Solid, not a gradient clip: the app has no gradient type anywhere, and the
   accent is the one thing on the page allowed to be coloured. */
.headline .accent { color: var(--accent); }
.sub {
  font-size: clamp(16px, 2.4vw, 17px);
  color: var(--text-dim);
  max-width: 560px;
  margin-bottom: 32px;
}

/* ------------------------------------------------------- iOS Discover deck --
 * The swipe deck from the fan app, rebuilt for the browser at 1px = 1pt so the
 * gesture and geometry match the device exactly. Measurements come from
 *   SongSwipeCard.swift  - card layout, stamps, transforms, drag thresholds
 *   DiscoveryView.swift  - scrims, genre chips, action bar
 *   AppTheme.swift       - type scale, radii, spacing, semantic colours
 * Rendered at natural size, deliberately: scaling the component would put the
 * pointer deltas and the card's own coordinate space out of step, and the card
 * would outrun the cursor mid-drag.
 *
 * The card is the screen. It was an inset 343pt panel with the action bar in a
 * row underneath it, which is what Discover used to be; the app now runs the
 * artwork to all four edges and under the navigation bar, and lays the chips and
 * the action bar over it. So the deck fills its screen, the card has no corners
 * and no margin, and every piece of chrome below is a layer on the cover.
 */
.hero-demo { display: flex; flex-direction: column; align-items: center; }

/* These were scoped to the component back when the rest of the page ran on a
   different palette; the deck needed the app's real colours while everything
   around it was purple. The page now uses those colours throughout, so these
   are aliases onto the global tokens rather than a second set of values. Kept
   as names so the deck CSS below still reads in iOS terms.

   They sit on the screen rather than on the deck because the two verdicts are
   worn by both — the stamps inside the card, and the action bar that now floats
   over it as a sibling of the deck rather than a row underneath it. */
.screen.is-immersive {
  --ios-like: var(--like);
  --ios-skip: var(--error);
}

.deck-demo { position: absolute; inset: 0; }

/* Card stack, edge to edge. */
.ios-deck { position: absolute; inset: 0; outline: none; }
/* The cards paint over the deck itself, so the focus ring has to be drawn on
   top of them rather than on the element underneath. */
.ios-deck:focus-visible::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 20;
  pointer-events: none;
  box-shadow: inset 0 0 0 3px var(--accent-ring);
}

/* No radius and no inset: `.frame(maxWidth: .infinity, maxHeight: .infinity)`
   over a background that ignores the safe area. The only rounding the card gets
   is the phone panel's own, which clips it. */
.ios-card {
  position: absolute;
  inset: 0;
  overflow: hidden;
  z-index: calc(10 - var(--depth));
  /* interactiveSpring(response: 0.3, dampingFraction: 0.7) */
  transition: transform 0.3s cubic-bezier(0.24, 1.2, 0.42, 1);
  user-select: none;
  -webkit-user-select: none;
  touch-action: pan-y;
}
/* Full-bleed cards are all the same size, so the deck no longer fans out behind
   the top one — the next card simply sits under it, fully covered, and is
   revealed as this one is dragged away. */
.ios-card.is-front { cursor: grab; }
.ios-card.is-dragging { cursor: grabbing; transition: none; }
/* flingOff: easeOut 0.25s */
.ios-card.is-flying { transition: transform 0.25s ease-out; }

/* The app paints the artwork behind the card blurred 40 under a 35% black
   scrim. Here that is a background on the card element itself, NOT a filtered
   child, and that is load-bearing: `filter: blur()` promotes a child to its own
   GPU compositing layer, and Safari does not clip a composited layer to an
   ancestor's border-radius; the card's corners rendered square on the device
   even though headless WebKit (software rendering) clipped them correctly.
   With no filtered child there is no separate layer, so the clip the phone panel
   puts on the card holds everywhere. Blurring the stand-in gradient was a visual
   no-op regardless. The scrim is the first background layer, over the track's
   artwork; both are set together in buildCard(). */

/* padding(.horizontal, cardPadding + 4), with topChromeInset 52 and
   bottomChromeInset 84: the room the chips and the action bar need, kept clear
   by the content rather than taken out of the card.

   52 on top of --screen-top, and not on top of a navigation bar as well: there
   is no bar here for the card to run under, so the chip row starts at the safe
   area and 52 is exactly what SongSwipeCard leaves above it. 59 + 52 = 111,
   which is where the card's content starts on device. The bottom still carries
   the floating tab bar's inset, which has not gone anywhere.

   The card's *background* is not inset by any of this — it is full-bleed and runs
   under the status area, as fullBleedBackground's .ignoresSafeArea() says. Only
   the content moves. Same for .deck-scrims, which stay at inset: 0. */
.ios-card-body {
  position: relative;
  height: 100%;
  padding: calc(var(--screen-top) + 52px) 20px calc(var(--safe-bottom) + 84px);
  display: flex;
  flex-direction: column;
  color: #fff;
}

/* Spacer(minLength: 0) above and below: the artwork floats in whatever the song
   block leaves, centred. scaledToFit() capped at 420 — a square on the smaller
   of the two dimensions it's offered, which is what the container units say. The
   percentage line before it is the fallback for engines without them. */
.ios-art-wrap {
  flex: 1;
  min-height: 0;
  container-type: size;
  display: flex;
  align-items: center;
  justify-content: center;
}
.ios-art {
  width: min(100%, 420px);
  width: min(100cqw, 100cqh, 420px);
  aspect-ratio: 1;
  border-radius: var(--r-card);
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
  position: relative;
  overflow: hidden;
}

.ios-art-sheen {
  position: absolute;
  inset: 0;
  background: radial-gradient(70% 55% at 24% 14%, rgba(255, 255, 255, 0.32), transparent 66%);
}
.ios-art-quote {
  position: absolute;
  right: 8px;
  bottom: 8px;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.35);
  color: rgba(255, 255, 255, 0.7);
}

/* VStack(spacing: 14): the song, the pills, the scrubber and the hint, as one
   block under the artwork. */
.ios-card-meta { flex-shrink: 0; display: flex; flex-direction: column; gap: 14px; }

/* VStack(spacing: 6), centre-aligned. The tight leading is SwiftUI's: a Text is
   about 1.2x its point size, and the page's 1.5 spread this block far enough to
   take the room the artwork should have. */
.ios-song {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  text-align: center;
}
.ios-song h3 {
  font-size: calc(20px * var(--tf, 1));
  font-weight: 600;
  max-width: 100%;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ios-song p { font-size: calc(16px * var(--tf, 1)); font-weight: 400; color: rgba(255, 255, 255, 0.7); }
.ios-song span { font-size: calc(13px * var(--tf, 1)); font-weight: 400; color: rgba(255, 255, 255, 0.7); }

.ios-actions { display: flex; align-items: center; justify-content: center; gap: 8px; }
.ios-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-family: inherit;
  font-size: calc(13px * var(--tf, 1));
  font-weight: 400;
  border: none;
  border-radius: 999px;
  padding: 8px 10px;
  background: rgba(255, 255, 255, 0.2);
  color: #fff;
  white-space: nowrap;
}
.ios-pill.is-white { background: #fff; color: #000; padding: 8px 14px; }

/* The one pill that does something. The rest of the card is a picture of an
   app, so the cursor is what tells you this part isn't. Width is pinned so the
   card doesn't reflow when the label swaps between Preview and Pause. */
.ios-pill.is-preview {
  cursor: pointer;
  justify-content: center;
  min-width: 92px;
}
.ios-pill.is-preview:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.85);
}

/* 3pt capsule scrubber, shown only while this card's preview is loaded. */
.ios-progress {
  height: 3px;
  margin: 0 8px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.2);
  overflow: hidden;
}
/* timeupdate fires about four times a second, so the fill is eased between
   readings; without this the bar advances in visible steps. */
.ios-progress i {
  display: block;
  height: 100%;
  background: #fff;
  border-radius: 999px;
  transition: width 0.26s linear;
}

.ios-likedby { display: flex; align-items: center; gap: 8px; font-size: calc(13px * var(--tf, 1)); color: rgba(255, 255, 255, 0.7); }
.ios-avatars { display: flex; }
.ios-avatars i {
  width: 24px;
  height: 24px;
  border-radius: 999px;
  border: 1.5px solid rgba(0, 0, 0, 0.3);
  display: grid;
  place-items: center;
  font-size: calc(9px * var(--tf, 1));
  font-weight: 700;
  font-style: normal;
  color: #fff;
}
.ios-avatars i + i { margin-left: -8px; }
.ios-likedby .more { font-size: calc(11px * var(--tf, 1)); font-weight: 600; }
.ios-likedby .sp { flex: 1; }
.ios-likedby .chev { color: rgba(255, 255, 255, 0.5); display: grid; place-items: center; }

.ios-comment { display: flex; align-items: center; gap: 8px; }
.ios-comment .bar { width: 2px; height: 28px; border-radius: 1px; background: rgba(255, 255, 255, 0.5); flex-shrink: 0; }
.ios-comment .txt {
  font-family: ui-serif, Georgia, "Times New Roman", serif;
  font-style: italic;
  font-size: calc(12px * var(--tf, 1));
  color: rgba(255, 255, 255, 0.7);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* AppTheme.accent.opacity(0.7) — the token, not a copy of it. It was once the hex
   written out in full, which meant it sat out every change to the panel's accent
   and had to be chased by hand each time. */
.ios-comment .hrt { color: color-mix(in srgb, var(--accent) 70%, transparent); display: grid; place-items: center; flex-shrink: 0; }
.ios-comment .n { font-size: calc(10px * var(--tf, 1)); font-weight: 500; color: rgba(255, 255, 255, 0.5); flex-shrink: 0; }

.ios-hint {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  font-size: calc(11px * var(--tf, 1));
  font-weight: 500;
  color: rgba(255, 255, 255, 0.4);
}

/* SKIP / LIKE / COMMENT overlays: plain 34pt bold text, no chrome, pushed clear
   of the chips that float over the card. */
.ios-stamp {
  position: absolute;
  top: calc(var(--screen-top) + 80px);            /* topChromeInset 52 + 28 */
  font-size: calc(34px * var(--tf, 1));
  font-weight: 700;
  opacity: 0;
  z-index: 5;
}
.ios-stamp.skip { left: 28px; color: var(--ios-skip); }
.ios-stamp.like { right: 28px; color: var(--ios-like); }
.ios-stamp.comment {
  top: auto;
  bottom: calc(var(--safe-bottom) + 24px);
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  font-size: calc(20px * var(--tf, 1));
  color: #fff;
}

/* ---------------------------------------------------- chrome over the card --
 * Everything from here to the end of the deck sits on the artwork: the two
 * scrims, the genre chips and the action bar. DiscoveryView layers them in a
 * ZStack over a card that has already taken the whole screen.
 */

/* mediaScrims: 240 of black at 0.55 fading down, 260 of black at 0.6 fading up.
   Over the deck, under the chrome, and untouchable so a drag still starts on
   the card. */
.deck-scrims { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
.deck-scrims::before,
.deck-scrims::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
}
.deck-scrims::before { top: 0; height: 240px; background: linear-gradient(rgba(0, 0, 0, 0.55), transparent); }
.deck-scrims::after { bottom: 0; height: 260px; background: linear-gradient(transparent, rgba(0, 0, 0, 0.6)); }

/* The genre row, at the top of the card and on the cover. A horizontal
   ScrollView in the app, so it scrolls here too and runs off the edge. */
.deck-chips {
  position: absolute;
  top: var(--screen-top);
  left: 0;
  right: 0;
  z-index: 3;
  display: flex;
  gap: 8px;
  padding: 8px var(--sp-card);
  overflow-x: auto;
  scrollbar-width: none;
}
.deck-chips::-webkit-scrollbar { display: none; }
/* filterChipSurface(isSelected:), resolved in the dark scheme the card forces on
   its chrome: glass with a hairline, or the primary button's near-white. */
.deck-chip {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  white-space: nowrap;
  font-size: calc(14px * var(--tf, 1));
  font-weight: 400;
  color: #fff;
  padding: 8px 16px;
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, 0.14);
  -webkit-backdrop-filter: var(--glass);
  backdrop-filter: var(--glass);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.22);
}
.deck-chip svg { width: 12px; height: 12px; }
.deck-chip.is-on {
  font-weight: 600;
  color: #1a1a1a;
  background: #f5f5f5;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  box-shadow: none;
}

/* HStack(spacing: 22), floating at the bottom of the card: Undo in glass at 48,
   Skip and Like tinted at 66. */
.ios-actionbar {
  position: absolute;
  left: 0;
  right: 0;
  /* Above the floating tab bar, where the safe area ends. The artwork carries on
     under both — only the background ignores the safe area. */
  bottom: var(--safe-bottom);
  z-index: 3;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 22px;
  padding: 8px 0 12px;
}
.ios-circle {
  display: grid;
  place-items: center;
  border: 1px solid transparent;
  border-radius: 999px;
  color: #fff;
  cursor: pointer;
  /* spring(response: 0.28, dampingFraction: 0.55) */
  transition: transform 0.28s cubic-bezier(0.2, 1.5, 0.4, 1);
}
/* A lit rim along the top — .white at 0.45 fading to 0.08. The buttons sit on a
   photograph, and a flat disc on a photograph reads as a sticker. */
.ios-circle.skip,
.ios-circle.like {
  width: 66px;
  height: 66px;
  background-image:
    linear-gradient(var(--c-fill), var(--c-fill-2)),
    linear-gradient(rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0.08));
  background-origin: border-box;
  background-clip: padding-box, border-box;
}
.ios-circle.skip {
  --c-fill: var(--ios-skip);
  --c-fill-2: rgba(255, 59, 48, 0.72);
  box-shadow: 0 6px 14px rgba(255, 59, 48, 0.5);
}
.ios-circle.like {
  --c-fill: var(--ios-like);
  --c-fill-2: rgba(52, 199, 89, 0.72);
  box-shadow: 0 6px 14px rgba(52, 199, 89, 0.5);
}
/* .ultraThinMaterial: present, but not one of the two verdicts. */
.ios-circle.undo {
  width: 48px;
  height: 48px;
  background: rgba(255, 255, 255, 0.16);
  -webkit-backdrop-filter: var(--glass);
  backdrop-filter: var(--glass);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35), 0 6px 14px rgba(0, 0, 0, 0.5);
}
.ios-circle:active { transform: scale(0.86); }
.ios-circle:focus-visible { outline: none; box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.85); }
.ios-circle:disabled { opacity: 0.32; cursor: default; }
.ios-circle:disabled:active { transform: none; }

.demo-caption {
  font-size: 12px;
  color: var(--text-dim);
  text-align: center;
  margin-top: 12px;
  max-width: 320px;
}
/* The deck's live region: what a swipe did, what is playing, how many you kept.
   Not drawn any more — a running tally under the phone was the page talking
   about itself — but kept in the document and still announced, because it is the
   only thing that tells a screen reader the card changed. */
#deck-saved {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
/* Apple asks for the source to be credited wherever its previews and artwork are
   used, and the deck's six cards are all Apple previews now. */
.credit { display: block; margin-top: 6px; color: var(--text-faint); }
.credit a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
.credit a:hover { color: var(--accent); }

/* -------------------------------------------------------------------- form */
.form { margin-bottom: 14px; }
/* AuthField. Focus is deliberately neutral rather than accented: a saturated
   ring round a text field reads as "this field is wrong", and errors here are
   red; the app makes the same point in a comment. The accent stays on the
   caret and on links. */
/* A capsule holding a capsule, which is what this product looks like: the app is
   capsules end to end — filter chips, Follow, Edit Profile, the deck's pills,
   the profile's tab pill — and the page's one conversion point was the rounded
   rectangle among them. The envelope leads it the way the magnifier leads the
   app's search fields, and it fills the dead air the placeholder used to sit
   alone in. */
.field {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--surface);
  border: 1px solid var(--separator);
  border-radius: var(--r-pill);
  padding: 6px 6px 6px 18px;
  max-width: 460px;
  transition: border-color var(--t-quick), box-shadow var(--t-quick);
}
.field-row {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 10px;
}
.field-glyph {
  flex-shrink: 0;
  width: 19px;
  height: 19px;
  color: var(--text-faint);
  transition: color var(--t-quick);
}
.field:hover { box-shadow: var(--sh-focus); }
/* Still no accent ring — a saturated ring round a text field reads as "this
   field is wrong", and errors here are red. What focus gets instead is a soft
   accent *glow*, the same wash AuthBackground puts behind the page: light
   around the field rather than a verdict on it. */
.field:focus-within {
  border-color: var(--text);
  box-shadow: var(--sh-focus), 0 6px 28px var(--glow-soft);
}
.field:focus-within .field-glyph { color: var(--text-dim); }
.field input {
  flex: 1;
  min-width: 0;
  background: transparent;
  border: none;
  outline: none;
  color: var(--text);
  caret-color: var(--accent);
  font-size: 16px;
  padding: 12px 0;
  font-family: inherit;
}
.field input::placeholder { color: var(--text-faint); }

/* The accent, on the one control that is the point of the page.

   This was near-black for a long time, on the argument that the accent means
   "the single coloured thing here" and an indigo CTA spends it on furniture.
   The counter-argument won: a signup button is not furniture. It is the only
   thing on the page a visitor is asked to do, and the accent is the brand's own
   colour — the logo's background. Everywhere else the accent marks state, which
   is why it can afford to mark intent here.

   White on #6155F5 measures 5.1:1, past AA for body text and comfortably past
   it for a 17 semibold label, in both appearances — the accent does not adapt,
   so this holds in dark mode unchanged. */
.field button {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  background: var(--accent);
  color: var(--on-accent);
  border: none;
  border-radius: var(--r-pill);
  font-family: inherit;
  font-size: 17px;
  font-weight: 600;
  padding: 13px 20px 13px 24px;
  cursor: pointer;
  /* No lift: this button is nested *inside* the field's surface rather than
     standing on the page, and the auth button's shadow at this size reads as
     a control floating out of its own container. The closing CTA keeps it. */
  transition: opacity var(--t-press), transform var(--t-press);
}
/* The one moving part. Colour would have been the easy way to make this less
   dull and the wrong one — the accent already means "the single coloured thing
   here", and an indigo CTA spends it on furniture. Motion costs nothing it
   owns. */
.field button .ic {
  width: 15px;
  height: 15px;
  transition: transform var(--t-press) var(--ease-out);
}
.field button:hover .ic { transform: translateX(3px); }
.field button:hover { opacity: 0.9; }
.field button:active { transform: scale(0.97); }
/* Mid-submit the label is swapped for "Joining…", and an arrow that still
   invites a press would be lying about what the button is doing. */
.field button:disabled .ic { opacity: 0; transform: translateX(-4px); }
/* Dimmed rather than re-surfaced: next to a card-coloured field, a
   card-coloured button reads as a second field. */
.field button:disabled { opacity: 0.4; cursor: default; box-shadow: none; }

.form-msg {
  font-size: 14px;
  margin-top: 14px;
  min-height: 20px;
  font-weight: 500;
}
/* The app keeps errors off the accent; an accent that also means "wrong"
   stops meaning anything, and the accent is user-changeable in Settings. */
.form-msg.error { color: var(--error); }
.form-msg.success { color: var(--success); }

.reassure { font-size: 13px; color: var(--text-faint); margin-top: 2px; }

/* Links out to the privacy notice, under the form and inside the FAQ. Tinted
   rather than underlined, so they read as part of the sentence. */
.reassure a,
.faq details p a {
  color: var(--accent);
  text-decoration: none;
  border-bottom: 1px solid var(--accent-ring);
}
.reassure a:hover,
.faq details p a:hover { border-bottom-color: var(--accent); }
.reassure a:focus-visible,
.faq details p a:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); border-radius: 3px; }

/* Post-signup state, swapped in for the form. The [hidden] rule has to be
   explicit: an author `display` declaration beats the UA's hidden styling. */
.success-card[hidden] { display: none; }
.success-card {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  max-width: 460px;
  background: var(--surface);
  border: 0.5px solid var(--hairline);
  border-radius: var(--r-field);
  padding: 20px;
  box-shadow: var(--lift);
  animation: pop 0.4s var(--ease-out);
}
@keyframes pop {
  from { opacity: 0; transform: scale(0.96) translateY(6px); }
  to   { opacity: 1; transform: none; }
}
.success-check {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: var(--r-pill);
  background: rgba(52, 199, 89, 0.14);
  color: var(--like);
}
.success-card h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
.success-card p { font-size: 14px; color: var(--text-dim); }
/* The queue position, and the one place on the page besides the headline where
   the accent carries information rather than decorating something. Tabular
   figures so a five-digit number doesn't wobble the line. */
.success-card .success-position {
  font-size: 20px;
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  margin-bottom: 2px;
}
.success-card .success-position[hidden] { display: none; }
.link-btn {
  background: none;
  border: none;
  padding: 0;
  margin-top: 10px;
  font-family: inherit;
  font-size: 13px;
  font-weight: 600;
  color: var(--accent);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.link-btn:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); border-radius: 4px; }

/* ----------------------------------------------------------------- marquee */
.marquee {
  display: flex;
  gap: 12px;
  overflow: hidden;
  margin-bottom: 64px;
  padding: 4px 0;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
.marquee-track {
  display: flex;
  gap: 12px;
  flex-shrink: 0;
  animation: scroll-x 42s linear infinite;
}
.marquee:hover .marquee-track { animation-play-state: paused; }
/* The app's genre pills: chipSurface() re-based for the page. AppTheme.chipFill
   is the fill for a chip resting on *tinted* artwork; on a plain background it
   lands muddy, so this is the card grey plus a hairline instead — which is what
   the app does for chips that sit on a background rather than on a card. */
.marquee span {
  white-space: nowrap;
  font-size: 13px;
  font-weight: 500;
  color: var(--text);
  border: 0.5px solid var(--separator);
  background: var(--surface);
  padding: 7px 15px;
  border-radius: var(--r-pill);
}
@keyframes scroll-x {
  from { transform: translateX(0); }
  to   { transform: translateX(calc(-100% - 12px)); }
}

/* ------------------------------------------------------- positioning quote */
.quote {
  border-left: 3px solid var(--accent);
  padding: 4px 0 4px 18px;
  margin-bottom: 64px;
}
.quote p {
  font-size: clamp(17px, 2.6vw, 20px);
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--text-dim);
  max-width: 640px;
}
.quote strong { color: var(--text); font-weight: 600; }

/* ---------------------------------------------------------------- sections */
.section { margin-bottom: 64px; }
/* SectionHeader.swift: 12 semibold, uppercased, tracking 1, secondary. */
.section-title {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--text-dim);
  margin-bottom: var(--sp-sect);
}

/* Cursor-following spotlight, driven by --mx/--my set in app.js */
[data-spotlight] { position: relative; isolation: isolate; }
[data-spotlight]::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: radial-gradient(240px circle at var(--mx, 50%) var(--my, 0%), var(--spot), transparent 70%);
  opacity: 0;
  transition: opacity 0.3s;
  pointer-events: none;
  z-index: -1;
}
[data-spotlight]:hover::after { opacity: 1; }

/* --------------------------------------------------------------------- FAQ */
/* Two columns: what the section is on the left, the questions on the right.

   The list used to span the container on the argument that a narrow card left
   the right half of the section empty — but the answers wrapped at a readable
   measure regardless, so the emptiness moved inside the card instead of going
   away. A column that says what the section is fills it with something, and the
   list gets a line length a paragraph actually wants. */
.section.is-faq {
  display: grid;
  grid-template-columns: minmax(0, 260px) minmax(0, 1fr);
  gap: 48px;
  align-items: start;
}
/* Follows the list past the fold, which matters once a long answer is open
   and the lede would otherwise have scrolled away from what it introduces. */
.faq-lede { position: sticky; top: 96px; }
.faq-lede .section-title { margin-bottom: 14px; }
.faq-head {
  font-size: 22px;
  font-weight: 700;
  letter-spacing: -0.4px;
  color: var(--text);
  margin-bottom: 8px;
}
.faq-lede p { font-size: 15px; line-height: 1.5; color: var(--text-dim); }
.faq-link {
  display: inline-block;
  margin-top: 14px;
  font-size: 14px;
  color: var(--accent);
  text-decoration: none;
  border-bottom: 1px solid var(--accent-ring);
}
.faq-link:hover { border-bottom-color: var(--accent); }
.faq-link:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); border-radius: 3px; }

/* An iOS inset grouped list rather than five separate cards: one surface, rows
   divided by inset hairlines. It's the shape the app gives every settings and
   detail screen, and five stacked outlines is the shape nothing in it has. */
.faq {
  background: var(--surface);
  border: 0.5px solid var(--hairline);
  border-radius: var(--r-card);
  box-shadow: var(--lift);
  overflow: hidden;
}
.faq details + details { border-top: 0.5px solid var(--separator); }
.faq summary {
  list-style: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 15px 18px;
  transition: background var(--t-press), box-shadow var(--t-press);
}
/* Label over question. The label is the part somebody scanning for one answer
   actually reads, so it leads — and it gives every row two levels instead of a
   single line of grey. */
.q-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.q-text em {
  font-style: normal;
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--text-faint);
  transition: color var(--t-press);
}
.q-text { font-size: 16px; font-weight: 500; }
.faq summary::-webkit-details-marker { display: none; }
.faq details:not([open]) summary:hover { background: var(--surface-2); }
.faq summary:focus-visible { outline: none; background: var(--surface-2); }
/* chevron.right in the app; down here, because these rows open in place rather
   than pushing a screen. It was a rotated CSS border box, from before the page
   had a symbol sheet to draw from. */
.faq summary .ic {
  flex-shrink: 0;
  margin-left: auto;
  width: 13px;
  height: 13px;
  color: var(--text-faint);
  transition: transform var(--t-quick) var(--ease-out), color var(--t-quick);
}
/* The one open row wears the accent along its leading edge and in its label —
   the accent marks state everywhere else in this product, and "this is the row
   you opened" is state.

   The tint goes on the <details>, not on the summary and the paragraph
   separately: the answer's line length is capped, so a background on it stopped
   where the sentence did and left a notch of untinted surface down the right. */
.faq details[open] { background: var(--accent-wash); box-shadow: inset 3px 0 0 var(--accent); }
.faq details[open] summary .q-text { font-weight: 600; }
.faq details[open] summary .q-text em { color: var(--accent); }
.faq details[open] summary .ic { transform: rotate(180deg); color: var(--accent); }
/* A closed row answers the cursor by nodding its chevron. */
.faq summary:hover .ic { transform: translateY(2px); }
.faq details[open] summary:hover .ic { transform: rotate(180deg) translateY(2px); }
.faq details p {
  font-size: 15px;
  line-height: 1.55;
  color: var(--text-dim);
  padding: 0 18px 18px;
  /* The row spans the column; the sentence doesn't have to. At this width an
     uncapped line runs past ninety characters, which is well past comfortable. */
  max-width: 66ch;
}

/* Opening is a height change, so it can be animated — but only where the
   browser can interpolate to `auto`. Everywhere else the row snaps open, which
   is what it did before and is no worse for it. */
@supports (interpolate-size: allow-keywords) {
  html { interpolate-size: allow-keywords; }
  .faq details::details-content {
    block-size: 0;
    overflow: hidden;
    transition: block-size var(--t-std) var(--ease-out), content-visibility var(--t-std) allow-discrete;
  }
  .faq details[open]::details-content { block-size: auto; }
}

/* ------------------------------------------------------------- closing CTA */
.cta {
  text-align: center;
  padding: 52px 20px;
  margin-bottom: 40px;
  background: var(--surface);
  border: 0.5px solid var(--hairline);
  border-radius: var(--r-sheet);
  box-shadow: var(--lift);
}
.cta h2 {
  font-size: clamp(21px, 4vw, 28px);
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.25;
  margin-bottom: 22px;
}
/* AuthPrimaryButton proper, standing on the page, so it keeps the lift. */
/* The same ask at the bottom of the page, so the same colour: two buttons that
   do one thing shouldn't disagree about what they are. */
.cta-btn {
  background: var(--accent);
  color: var(--on-accent);
  border: none;
  border-radius: var(--r-field);
  font-family: inherit;
  font-size: 17px;
  font-weight: 600;
  padding: 0 28px;
  height: 54px;
  cursor: pointer;
  /* A coloured button casts a coloured shadow; the neutral drop it had reads as
     grime under the accent. --sh-glow is the same wash the logo tile sits on. */
  box-shadow: var(--sh-glow);
  transition: opacity var(--t-press), transform var(--t-press) var(--ease-out);
}
.cta-btn:hover { opacity: 0.9; }
.cta-btn:active { transform: scale(0.97); }
/* An accent ring round an accent button is a ring you can't see. Both of the
   signup buttons put the surface they stand on between the two, so the focus
   state reads as a ring rather than as a slightly larger button. */
.cta-btn:focus-visible {
  outline: none;
  box-shadow: var(--sh-glow), 0 0 0 3px var(--surface), 0 0 0 5px var(--accent);
}
.field button:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px var(--surface), 0 0 0 5px var(--accent);
}

/* ------------------------------------------------------------------ footer */
.footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  font-size: 13px;
  color: var(--text-faint);
  padding-top: var(--sp-sect);
  border-top: 0.5px solid var(--separator);
}
.footer a { color: var(--text-faint); text-decoration: none; }
.footer a:hover { color: var(--accent); }
/* The way back and the social mark travel together at the right-hand end, so
   the row stays two things across rather than three spread thin. Same shape as
   .v2-footer-links in landing-v2.css, restated because the pages that use this
   footer don't load that file. Hover is already covered by .footer a above. */
.footer-links { display: inline-flex; align-items: center; gap: 18px; }
.footer-social { display: inline-flex; color: var(--text-faint); transition: color var(--t-quick); }
.footer-social .ic { width: 17px; height: 17px; }

/* ----------------------------------------------------------- app tab preview */
/* One phone, five screens, the app's own tab bar along the bottom.
 *
 * Drawn at 1px = 1pt, the same convention the deck uses, so every number here is
 * the number in the Swift file rather than a guess that looks close. The screen
 * is 393 wide because that is an iPhone's point width, and the type comes from
 * AppTheme.swift:
 *
 *   titleFont    28 bold        the profile's display name
 *   headlineFont 20 semibold    every navigation title
 *   bodyFont     16 regular     bios, conversation titles at 16 semibold
 *   captionFont  13 regular     previews, artists, @handles
 *   smallFont    11 medium      timestamps, album lines
 *
 * The screen is a whole 393x852 phone, not a window onto one, so its proportions
 * are a device's. Screens still end in a fade rather than at a hard edge, because
 * they scroll here exactly as they do on the phone and Browse alone stacks seven
 * carousels — see .screen-body.
 *
 * Line height and tracking are the panel's own, not the page's: SF Pro sets both
 * and the app overrides neither, so .phone resets line-height to 1.2 and nothing
 * in here tightens letter-spacing. The only positive tracking below is on the
 * uppercase labels the app spaces itself, with .tracking(1) and .tracking(1.2).
 */
/* In the hero: the device centred in its column, the caption under it. */
.appview { display: flex; justify-content: center; width: 100%; }
/* No device frame: the screens sit on the page as a panel, edged with the same
   hairline as every other surface here. 395 rather than 393 because the two 0.5px
   hairlines are inside the border box and each rounds up to a device pixel, so
   the screen comes out at 393 — an iPhone's point width, and so the width of the
   Discover card, which is the whole screen. */
.phone {
  /* The tab bar floats over the screen rather than sitting under it — see
     .tabbar — so the panel is a positioning context and these are the numbers
     both of them are written against. --safe-bottom is the bottom safe-area
     inset the floating bar creates: the line every screen's content, and the
     deck's action bar, stops at. */
  --tabbar-h: 56px;
  /* 16, not the 12 it was, so the bar nests exactly inside the panel's corner:
     the bar is a capsule, so its own corner is an arc of radius --tabbar-h / 2
     centred 16 + 28 in from each edge — which is the centre of the panel's own
     44px corner arc too. Concentric, and 28 < 44, so the bar is provably inside
     the clip whatever it does. Pull either number and they part company. */
  --tabbar-x: 16px;
  --tabbar-y: 16px;
  --safe-bottom: calc(var(--tabbar-h) + var(--tabbar-y));
  /* A device publishes 59 here — a 54pt status bar plus 5 — and the navigation
     bar starts at the inset. This panel reserved all 59 and then drew nothing in
     it: no clock, no signal bars, no island. On a real phone that band is the
     busiest strip on the screen; drawn empty it is just a dead margin above
     every title, and it read as a mistake rather than as a status bar nobody
     had filled in.

     So the panel keeps the *height* — 852 is still an iPhone's, and the
     silhouette is what the room above the title was bought for — and gives the
     room to the screens instead. 22 is what the navigation bar needs to clear
     the panel's own 44px corner: the glyphs sit in 36pt circles 12 in from each
     edge, and below about 20 those circles start crossing the corner arc. */
  --safe-top: 22px;
  /* UINavigationBar, .inline. Every screen has one; see .navbar. */
  --navbar-h: 44px;
  /* Where a screen's content starts: under the bar, which is transparent and
     which the content scrolls beneath. One knob, and everything written against
     it — the card's chrome inset, the stamps, the genre row — follows. */
  --screen-top: calc(var(--safe-top) + var(--navbar-h));

  /* The page's indigo — the logo's background — rather than the app's crimson
     (AppTheme.defaultAccentHex, #CE1126). The panel is the one large coloured
     object on the page, and running it on a second hue read as two brands on one
     screen; one accent, and the phone belongs to the site it sits on. Still
     redefined here rather than inherited, because every rule inside the panel is
     written against these three and the wash and ring below are the panel's own
     alphas. */
  --accent: #6155f5;
  --accent-wash: rgba(97, 85, 245, 0.12);
  --accent-ring: rgba(97, 85, 245, 0.22);

  /* Type factor. Every font size inside this panel is written as
     calc(Npx * var(--tf)), so this one number moves all 98 of them together.
     1 everywhere the panel is drawn at a size where the app's own scale reads.

     It is a second reduction on top of the panel's zoom, not instead of it —
     --phone-fit already shrinks the whole phone to the room, and this takes the
     type down further within it, so the layout keeps its proportions and the
     words sit lighter in it. Worth knowing what that costs, because it
     compounds: on a 375-wide handset the panel is already at 0.575, so a 10pt
     tab label is 5.8px before this touches it and 4.7px after. Much below the
     0.82 here and the smallest labels stop resolving into letters at all —
     that floor, not a chosen size, is what the steps are working against. */
  --tf: 1;

  /* Liquid Glass. The app is built against the iOS 26 SDK — glassEffect and
     GlassEffectContainer are all over FeedView and ProfileView — so its bars,
     chips and toolbar buttons are lenses rather than frosted panes, and the
     difference is almost entirely at the edge. See .lg. Light values here,
     because light is this file's canonical mode; dark overrides below. */
  --lg-lens: blur(14px) saturate(185%) brightness(1.04);
  --lg-fill: rgba(255, 255, 255, 0.55);
  --lg-sheen: 0.5;
  /* The rim inverts between the modes, and it has to. On black a lit top edge
     is the whole signal. On white a lit top edge is invisible, and what says
     "object" is the *bottom* of the ring going dark — light falls on the top of
     a glass bead and the far side of it shades. So this runs white to a real
     black at 0.16 rather than to a whisper, and the bar keeps an edge against a
     white page instead of dissolving into it. */
  --lg-rim-top: rgba(255, 255, 255, 0.95);
  --lg-rim-bot: rgba(0, 0, 0, 0.16);
  --lg-lift: 0 6px 18px rgba(0, 0, 0, 0.14);
  /* The fill behind the selected tab. Lighter than the bar in both schemes
     — it is a layer resting on the glass, not a hole cut in it. */
  --tab-sel: rgba(0, 0, 0, 0.07);

  position: relative;
  width: 100%;
  max-width: 395px;
  /* SF Pro sets its own line height and the app almost never touches it — one
     .lineSpacing() in 236 files. The page's 1.5 is a *web* body measure, and
     inherited into here it stretched every row: a name over a handle, a title
     over an artist, a tab label. 1.2 is what a single line of SF Pro actually
     occupies, which puts the gap back where the app keeps it — in the VStack
     spacing these rules already carry as `gap`. Multi-line copy takes some of it
     back below; see .post-caption and friends. */
  line-height: 1.2;
  /* iOS renders text with subpixel antialiasing; the page asks for grayscale
     (body, -webkit-font-smoothing), which thins every stem. At the panel's own
     type sizes — 10 and 11 for tab labels and timestamps — thinned strokes are a
     large part of what reads as broken here. Back to the platform default inside
     the phone only. */
  -webkit-font-smoothing: subpixel-antialiased;
  /* A display's corner, not a card's. 18 was right for a panel that was really a
     cropped screen; on a 393x852 body it read as a tall rounded rectangle. 44 is
     what --tabbar-x/y are picked against — see above. */
  border-radius: 44px;
  background: var(--bg);
  border: 0.5px solid var(--hairline);
  box-shadow: var(--lift), 0 24px 60px rgba(0, 0, 0, 0.1);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  /* Where the page's typography stops. Nothing inside this panel is the page's,
     and text-align is the one property that walks in on its own — the reel
     centres its caption under 900px (.v2-reel-stage in landing-v2.css) and the
     phone is a descendant of the thing being centred, so the composer's
     placeholder and every other plain run of text inside the app quietly moved
     to the middle on a handset. Everything in here that is meant to be centred
     says so itself, which is why the panel has always been right above 900 and
     only wrong below it.

     Stated on the panel rather than patched inside the media query so it holds
     for whatever the page centres next. */
  text-align: left;
  /* The finished panel is drawn a shade smaller than the phone it copies. This
     is the ONLY place that departs from 1px = 1pt: everything inside is still
     written at the app's own numbers — the deck's geometry, the type scale, the
     gesture thresholds — and only the assembled result is reduced. One token to
     dial. `zoom` rather than `transform: scale()` because it takes the layout
     with it; a transform would leave the hero holding a full-height gap under a
     panel that no longer fills it. See deckScale() in app.js, which reads this
     back so a drag still tracks the cursor. */
  zoom: var(--phone-scale, 1);
}
/* Only where the hero runs in two columns. Stacked under the copy the panel has
   the width to itself and is already sized by the column, and shrinking it there
   would just make a phone-shaped thing smaller than a phone.

   landing-v1.html only: index.html's reel overrides --phone-scale with the
   measured --phone-fit. 0.76 rather than the 0.85 it was, because the panel is
   now 852 tall rather than 760 — 0.85 of it is 725px, taller than v1's hero had
   room for. 0.76 lands at 649, which is what 0.85 of the old panel came to. */
@media (min-width: 901px) {
  .appview { --phone-scale: 0.76; }
}
:root[data-theme="dark"] .phone {
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
  /* The wash and the ring carry more alpha on black, the same way the page's own
     accent tokens do — same hue as the page now, see .phone. */
  --accent-wash: rgba(97, 85, 245, 0.22);
  --accent-ring: rgba(97, 85, 245, 0.35);
  /* On black the glass is almost all rim. Fill and sheen are deliberately low:
     they add, and over a black screen 0.09 of fill under 0.13 of sheen came to a
     fifth of white along the top edge, which is a grey capsule rather than a
     lens. Half that reads as glass and still separates from the page, because
     what says "there is an object here" is the lit edge, not the fill. Over
     artwork the same tokens pick the colour up on their own — that is what the
     saturate in the lens is for — so these are tuned against the worst case,
     which is Chat and Profile sitting on flat #000. */
  --lg-lens: blur(14px) saturate(190%) brightness(1.06);
  --lg-fill: rgba(255, 255, 255, 0.055);
  --lg-sheen: 0.085;
  --lg-rim-top: rgba(255, 255, 255, 0.5);
  --lg-rim-bot: rgba(255, 255, 255, 0.06);
  --lg-lift: 0 6px 18px rgba(0, 0, 0, 0.34);
  --tab-sel: rgba(255, 255, 255, 0.14);
}

/* The whole device screen now, tab bar included: the bar floats on top of this
   box instead of taking a strip below it. 852 is an iPhone 15/16's point height,
   so the panel is a whole phone at 1px = 1pt rather than a window onto one: the
   760 it was is the app's *content* height (852 less the status bar and the home
   indicator), and drawn on its own, with no inset above or below it, it read as
   a rectangle rather than as a device. --screen-top holds the top inset now. */
.phone-screen { position: relative; height: 852px; overflow: hidden; }
.screen { position: absolute; inset: 0; display: flex; flex-direction: column; }
.screen[hidden] { display: none; }
/* Screens cross-fade rather than cut. The outgoing one keeps its box for the
   length of its fade — they are both `inset: 0`, so they sit on top of each
   other and the swap reads as one screen becoming another rather than as two
   separate events. showScreen() makes the leaving one inert while it lingers,
   so tab order and assistive tech still see exactly one screen.

   Reduce Motion drops both animations at the bottom of this file, which leaves
   an instant swap — which is the point. */
.screen.is-active { animation: screen-in 0.34s var(--ease-out); }
.screen.is-leaving { animation: screen-out 0.26s var(--ease-out) forwards; }
@keyframes screen-in {
  from { opacity: 0; transform: translateY(8px) scale(0.994); }
  to   { opacity: 1; transform: none; }
}
@keyframes screen-out {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(-6px) scale(0.994); }
}

/* No unseen-activity dots on the tab bar. ContentView badges Feed and Chat from
   activityService.unseenCount and messageNotifications.totalUnread — real
   counts, for a real account. This panel has neither, so the dots were saying
   something about a person who doesn't exist, and a tour of the app is a poor
   place to be pointing at notifications nobody can open. The deck's filter glyph
   keeps its dot: that one marks state you can see on the same screen. */
/* These scroll, like the screens they copy: 852 less the top inset and the tab
   bar's leaves a 673pt window, and Browse alone stacks seven carousels.
   overscroll-behavior keeps a flick inside the device instead of chaining on to
   the page once a screen hits its end. */
.screen-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  overscroll-behavior: contain;
  scrollbar-width: none;
  /* Clears the floating tab bar, so the last row can be scrolled out from under
     it. The rows still pass beneath the glass on the way — that is what the
     material is for, and stopping them short of it would leave a dead strip. */
  padding: var(--screen-top) 16px calc(var(--safe-bottom) + 12px);
}
.screen-body::-webkit-scrollbar { display: none; }
/* The deck doesn't scroll: it's one card, and the card is the screen. */
.screen-body.is-deck { overflow: hidden; }
/* Content that reaches the bottom of the screen fades instead of being sliced:
   these screens scroll on device — 852 less the top inset and the bar's is a
   673pt window, and Browse alone stacks seven carousels. The deck is exempt — it
   ends at the screen's edge because that is where the card ends, not because it
   was cropped. */
.screen-body:not(.is-deck) {
  -webkit-mask-image: linear-gradient(#000 92%, transparent);
  mask-image: linear-gradient(#000 92%, transparent);
}
/* Discover holds the live deck, and the deck holds the whole screen: the card is
   the page, the navigation bar has no surface of its own, and the chips and the
   action bar are layers on the artwork. The screen is a plain positioning box
   rather than a column, so every child can say where it goes.

   :not([hidden]) is load-bearing. `.screen[hidden]` and `.screen.is-immersive`
   weigh the same, and this rule is the later of the two, so without it Discover
   wins its own hiding rule and stays on screen behind every other tab. */
.screen.is-immersive:not([hidden]) { display: block; }
.screen.is-immersive .screen-body.is-deck { position: absolute; inset: 0; padding: 0; z-index: 1; }

/* ------------------------------------------------------------ liquid glass --
 * The material iOS 26 puts under a tab bar, a toolbar button and a filter chip.
 * It is not the frosted pane the old materials were, and copying it as "blur
 * plus a grey fill" is what makes a web mock of an Apple UI look like a web mock
 * of an Apple UI. Three things do the work, in this order of importance:
 *
 *   The rim. Glass is a solid with thickness, so its edge catches light along
 *   the top and loses it along the bottom. DiscoveryView already says this about
 *   its own deck buttons — it strokes them white 0.4 falling to 0.08 and its
 *   comment is "a flat disc on a photograph reads as a sticker" — so this is the
 *   app's own language, applied to every glass surface rather than to three
 *   circles. Drawn as a masked ring so it follows a capsule's curve; an inset
 *   box-shadow gives a straight band that a capsule then clips square.
 *
 *   The lens. saturate above 100 and a touch of brightness: real glass
 *   concentrates what is behind it rather than fogging it. Blur is deliberately
 *   lower than the 20 the page's other surfaces use, because Liquid Glass is
 *   clearer than the material it replaced — you are meant to see what is under
 *   it well enough to know it moved.
 *
 *   The fill, last and least. Almost nothing on black. A heavy fill is the
 *   single thing that turns glass into plastic.
 *
 * Applied here rather than baked into each rule so the four surfaces that carry
 * it — bar, chips, toolbar buttons, segmented control — cannot drift apart.
 */
.lg {
  position: relative;
  /* Sheen first, fill under it: the highlight is on the glass, not behind it. */
  background:
    linear-gradient(rgba(255, 255, 255, var(--lg-sheen)), rgba(255, 255, 255, 0) 62%),
    var(--lg-fill);
  -webkit-backdrop-filter: var(--lg-lens);
  backdrop-filter: var(--lg-lens);
  box-shadow: var(--lg-lift);
}
.lg::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 0.5px;
  background: linear-gradient(var(--lg-rim-top), var(--lg-rim-bot));
  /* Ring, not a filled box: paint the gradient, then knock the middle out of it.
     `exclude` composites the padding-box away from the border-box, which leaves
     exactly the 0.5px band and follows whatever radius the element has. */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  pointer-events: none;
}
/* Over artwork the glass is always light-on-image, whatever the page's mode:
   the deck is a photograph in both. Matches the on-media scale in AppTheme. */
.is-immersive .lg,
.lg.on-media {
  --lg-fill: rgba(255, 255, 255, 0.16);
  --lg-sheen: 0.16;
  --lg-rim-top: rgba(255, 255, 255, 0.4);
  --lg-rim-bot: rgba(255, 255, 255, 0.08);
}

/* ------------------------------------------------------------ navigation bar --
 * Every screen has one, inline, and every one of them is transparent: Feed and
 * Discover say so outright with .toolbarBackground(.hidden), and the other three
 * are transparent at rest and only take a background once content is under them.
 * So the bar is drawn as its items and its title, with nothing behind them.
 *
 * One title size across all five, at Browse's 17.
 *
 * The app has two. Four screens build their own
 * ToolbarItem(placement: .principal) with AppTheme.headlineFont, which is 20
 * semibold; Browse uses .navigationTitle and gets the system's inline title,
 * which is 17. That difference is real on a device and this panel used to carry
 * it, on the argument that a replica copies what is there rather than what is
 * tidy.
 *
 * It doesn't survive the reel. On a phone you meet those screens minutes apart
 * and each title is simply the size it is; here they are five frames of one
 * animation in the same box, and the title jumping three points between frames
 * read as the panel resizing rather than as two toolbar APIs. Whatever is
 * constant across the sequence has to look constant, and the title is the one
 * thing on screen at every step.
 *
 * 17 rather than 20 because it is the smaller of the two: the bar has to clear
 * the panel's 44px corner, and the four glyph screens have the least room.
 */
.navbar {
  position: absolute;
  top: var(--safe-top);
  left: 0;
  right: 0;
  height: var(--navbar-h);
  z-index: 4;
  display: grid;
  /* Leading items, centred title, trailing items. The title is centred on the
     *bar*, not between its neighbours, which is what a principal item does —
     Discover has one leading and two trailing glyphs and its title still sits
     on the screen's centre line. */
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  padding: 0 12px;
  pointer-events: none;
}
.navbar-l, .navbar-r { display: flex; align-items: center; gap: 8px; }
.navbar-r { justify-self: end; }
.navbar-t {
  font-size: calc(17px * var(--tf, 1));
  font-weight: 600;
  white-space: nowrap;
}
/* iOS 26 gives a bare toolbar button its own glass. Round, 36, and the glyph
   inside keeps the size the Swift asks for — 16 semibold on Feed, Chat, Profile
   and Browse, 15 regular on Discover, which is what .is-deck-size carries. */
.navbtn {
  width: 36px;
  height: 36px;
  border-radius: 999px;
  display: grid;
  place-items: center;
  flex-shrink: 0;
  color: var(--text);
  position: relative;
}
.navbtn svg { width: 16px; height: 16px; }
.navbtn.is-deck-size svg { width: 15px; height: 15px; }
/* The unseen state: bell.badge.fill in the accent, which is what FeedView
   swaps to rather than adding a dot beside the plain bell. */
.navbtn.is-on { color: var(--accent); }
/* hasTuning puts a 7pt accent dot on the deck's filter glyph. */
.navbtn .tunedot {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 7px;
  height: 7px;
  border-radius: 999px;
  background: var(--accent);
}
/* Over the card, the glyphs are chromeTint — AppTheme.onMedia, plain white and
   non-adaptive, because a photograph is a photograph in both modes. */
.is-immersive .navbar { color: #fff; z-index: 3; }
.is-immersive .navbtn { color: #fff; }

/* -- Feed ------------------------------------------------------------------ */
/* FeedView floats two glass capsules over the feed instead of a filled
   segmented control: selection reads in the label's weight, not in a slab of
   tint, and the pair hugs its titles rather than splitting the width.

   BOTH capsules carry the glass. FeedView gives every chip
   .glassEffect(.regular.interactive(), in: Capsule()) and separates them by
   weight and foreground alone; only its pre-iOS-26 fallback frosts the selected
   one on its own, which is the branch this used to draw. */
/* Above the feed's own scroller, so this row carries the top inset and the body
   below it starts at nothing. Vertical 8 is FeedView's own on the inset. */
.feed-switch { flex-shrink: 0; display: flex; gap: 8px; justify-content: center; padding: calc(var(--screen-top) + 8px) 16px 8px; }
.screen-body.is-feed { padding-top: 0; }
/* .system(size: 14, weight: .medium) at .padding(.horizontal, 14).padding(.vertical, 8) */
.chip-glass {
  font-size: calc(14px * var(--tf, 1));
  font-weight: 500;
  color: var(--text-dim);
  padding: 8px 14px;
  border-radius: var(--r-pill);
  /* Surface from .lg. These float on the feed's own artwork, so they sit a
     little lower than the tab bar does. */
  --lg-lift: 0 2px 10px rgba(0, 0, 0, 0.18);
}
/* Selection is the weight and the ink, nothing else. */
.chip-glass.is-on {
  font-weight: 600;
  color: var(--text);
}
/* The posts run edge to edge: a post's 16 is inside it, and its background is
   the sleeve, so a screen inset would leave two strips of page colour down the
   sides of a surface that is supposed to be continuous. */
.screen-body.is-feed { padding-left: 0; padding-right: 0; }

/* -- The two feeds --------------------------------------------------------- */
/* Tunes and Tune of the Day, side by side and paged sideways. The outer box
   scrolls horizontally and snaps; each pane scrolls vertically on its own. That
   split is what keeps the two gestures apart — the one that moves *between*
   feeds and the one that moves *through* one never arrive at the same scroller. */
.feed-pager {
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  /* The panes carry the bottom inset, so the pager itself claims none. */
  padding-bottom: 0;
}
/* While the page's scroll is driving the pan. `scroll-behavior: smooth` is
   deliberately absent from the rule above for the same reason: a scrollLeft
   written every frame would be animating towards a target that had already
   moved. The chips ask for smooth explicitly when *they* scroll it. */
.feed-pager.is-driven { scroll-snap-type: none; }
.feed-pager::-webkit-scrollbar { display: none; }
.feed-pane {
  flex: 0 0 100%;
  scroll-snap-align: start;
  overflow-y: auto;
  overscroll-behavior-y: contain;
  scrollbar-width: none;
  padding-bottom: calc(var(--safe-bottom) + 12px);
}
.feed-pane::-webkit-scrollbar { display: none; }
/* A press on a chip is a real press now, so they need a button's reset — and
   `background: none` is the load-bearing line: .chip-glass only paints a surface
   when it is selected, so without this the *unselected* one wore the browser's
   default button grey and read as the chosen one. */
.feed-switch .chip-glass {
  border: none;
  background: none;
  font-family: inherit;
  cursor: pointer;
  transition: color var(--t-press), background var(--t-press);
}
.feed-switch .chip-glass:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }

/* -- Tune of the Day ------------------------------------------------------- */
/* TuneOfTheDayPicker. While the day's slot is unspent the tab's own answer to
   "what now" sits at the top of it, because picking is a decision the
   recommender can help with rather than a blank field everyone starts from.

   A grid, not a shelf. Every other row of songs in this app scrolls sideways and
   this one deliberately doesn't: a shelf says "here is a selection, keep
   looking", and this is a page of candidates you are meant to read at once and
   choose from. It was an uppercase label over a carousel — the wrong furniture
   for both halves of that. */
.totd-pick { padding: 16px 0; }
.totd-head { display: flex; align-items: flex-start; gap: 12px; padding: 0 var(--sp-card); margin-bottom: 14px; }
.totd-title { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.totd-title b { font-size: calc(20px * var(--tf, 1)); font-weight: 600; }
.totd-title > span { font-size: calc(13px * var(--tf, 1)); line-height: 1.35; color: var(--text-dim); }
/* Start again with different songs — a glass disc, the way the app draws it. */
.totd-reload {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  border-radius: var(--r-pill);
  background: var(--bg-blur);
  -webkit-backdrop-filter: var(--glass);
  backdrop-filter: var(--glass);
  color: var(--text-dim);
}

/* GridItem(.adaptive(minimum: 104, maximum: 180), spacing: 14), rows at 18:
   three columns on a phone, two if the screen is narrow enough that a third
   would truncate every title to one word. */
.totd-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  gap: 18px 14px;
  align-items: start;
  padding: 0 var(--sp-card);
}
.totd-tile { display: flex; flex-direction: column; gap: 7px; min-width: 0; }
.totd-art {
  position: relative;
  aspect-ratio: 1;
  border-radius: 10px;
  background-size: cover;
  background-position: center;
}
/* palette(onMedia, mediaScrimStrong): white triangle knocked out of a dark disc,
   which is legible over any cover at all rather than over most of them. */
.totd-art .ic {
  position: absolute;
  right: 6px;
  bottom: 6px;
  color: rgba(0, 0, 0, 0.5);
  filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.35));
}
.totd-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
/* Two lines, because one turns every title with a bracket in it into
   "Song Title (Fea…". */
.totd-meta b {
  font-size: calc(13px * var(--tf, 1));
  font-weight: 500;
  line-height: 1.25;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.totd-meta > span {
  font-size: calc(12px * var(--tf, 1));
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The calendar mark beside a timestamp: a glyph and nothing else. The spelled
   out title was the widest thing in the header for a distinction only this tab
   needs. */
.totd-badge {
  display: inline-grid;
  place-items: center;
  padding: 3px 5px;
  border-radius: var(--r-pill);
  background: var(--accent-wash);
  color: var(--accent);
}
.totd-badge svg { width: 10px; height: 10px; display: block; }
.post-when { display: inline-flex; align-items: center; gap: 6px; }

/* The lock. Tune of the Day is a trade — you show yours to see everyone else's —
   so until you have, the song is withheld and the author is not: the header
   stays sharp and only what is below it blurs. */
.post-locked { position: relative; }
.post-locked .post-locked-body { filter: blur(16px); pointer-events: none; }
.post-lock-hint {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 0 24px;
  text-align: center;
  font-size: calc(13px * var(--tf, 1));
  font-weight: 600;
  color: var(--text);
  /* Twice, tight and wide: the words sit on whatever the blur happens to leave
     under them, and one shadow isn't enough to hold them off it. */
  text-shadow: 0 0 6px var(--bg), 0 0 2px var(--bg);
}
.post-lock-hint svg { width: 24px; height: 24px; }

/* FeedPostCard is not a card, which is the whole shape of it. It has no border,
   no corner radius and no surface of its own: the background is the song's own
   artwork blurred 40 under a scrim, and the top and bottom edges fade to the
   page colour so consecutive posts melt into one continuous run. The list is a
   LazyVStack(spacing: 0) — there is nothing between two posts to see, and the
   only thing separating them is where one sleeve's colour gives way to the
   next. It was a grey panel with a hairline and a 12pt gap under it.

   VStack(spacing: 10) at cardPadding 16. */
.post {
  position: relative;
  padding: var(--sp-card);
  background-position: center;
  background-size: cover;
  overflow: hidden;
}
/* Scrim and both edge fades in one layer over the artwork. */
.post::before {
  content: "";
  position: absolute;
  inset: 0;
  background:
    linear-gradient(var(--bg), var(--bg-0) 32%, var(--bg-0) 68%, var(--bg)),
    var(--post-scrim);
  pointer-events: none;
}
/* Positioned, so they paint over the layer above rather than under it. */
.post > * { position: relative; }

/* HStack(spacing: 10) with a 40pt avatar, then the controls that ride on the
   right: Follow, Open With, Share, and the ••• menu. */
.post-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.post-who { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.post-who b { font-size: calc(15px * var(--tf, 1)); font-weight: 600; }
/* The timestamp alone. The app puts no handle on this line. */
.post-who span { font-size: calc(12px * var(--tf, 1)); font-weight: 500; color: var(--text-dim); }
/* The trailing controls are one group, not two more items spaced like the
   avatar and the name. Tighter inside, with the row's slack all on their left:
   the cluster reads as a cluster, and the header stops looking like several
   things strung across it.

   All four of the app's are drawn. Open With used to be dropped here — at 12pt
   it and Share are both an arrow leaving a box, and side by side at this size
   they read as one smudge twice — but the panel's job is to be a picture of the
   header the app actually has, and a header short a control is a worse lie than
   two glyphs that rhyme. If the pair ever needs separating again, the answer is
   to draw them apart rather than to delete one. */
.post-acts { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }

/* chipSurface(): a tint and a hairline rather than a system grey, which is
   defined against the page and would vanish into scrimmed artwork in light
   mode. chipIconWidth 34 x chipHeight 30 for the icon pair. */
.post-icon,
.post-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  flex-shrink: 0;
  border-radius: var(--r-pill);
  background: var(--chip-fill);
  box-shadow: inset 0 0 0 0.5px var(--hairline);
  color: var(--text);
  white-space: nowrap;
}
.post-icon { width: var(--chip-icon-w); height: var(--chip-h); }
.post-chip { height: var(--chip-h); padding: 0 12px; font-size: calc(12px * var(--tf, 1)); font-weight: 500; }
/* An unanswered Follow is the one filled thing in the header; Following and
   Requested would take the chip surface beside it. */
.post-follow {
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
  height: var(--chip-h);
  padding: 0 12px;
  border-radius: var(--r-pill);
  background: var(--accent);
  color: var(--on-accent);
  font-size: calc(11px * var(--tf, 1));
  font-weight: 600;
}

/* Real prose, and the only text on a post that wraps. The panel's 1.2 is a
   single line's height — right for the rows it was set for, too tight for a
   paragraph — so this takes the leading back. */
.post-caption { font-size: calc(15px * var(--tf, 1)); line-height: 1.35; color: var(--text-dim); margin-bottom: 10px; }

/* scaledToFit() capped at 380: the sleeve stays square and centred rather than
   stretching into a letterbox across the card. */
.post-art {
  width: min(100%, 380px);
  aspect-ratio: 1;
  margin: 0 auto 10px;
  border-radius: var(--r-art);
  box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
  background-position: center;
  background-size: cover;
}

/* VStack(spacing: 3), centred: 17 semibold over captionFont over smallFont. */
.post-song {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  text-align: center;
  margin-bottom: 10px;
}
.post-song b { font-size: calc(17px * var(--tf, 1)); font-weight: 600; }
.post-song span { font-size: calc(13px * var(--tf, 1)); color: var(--text-dim); }
.post-song em { font-size: calc(11px * var(--tf, 1)); font-weight: 500; font-style: normal; color: var(--text-dim); }

/* engagementRow: HStack(spacing: 8), centred like everything else in the stack.
   The preview button wears the primary fill; comments take the chip surface.
   The label reads "Comment" rather than a number, which is what the app shows
   at zero and the only honest thing to put under a real recording here. */
.post-actions { display: flex; justify-content: center; align-items: center; gap: 8px; margin-bottom: 10px; }
.post-pill {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: var(--chip-h);
  padding: 0 12px;
  border-radius: var(--r-pill);
  background: var(--btn);
  color: var(--btn-fg);
  font-size: calc(12px * var(--tf, 1));
  font-weight: 500;
  /* A real button now — see the preview block in app.js — so it needs a
     button's reset and the cursor that says so. */
  border: none;
  font-family: inherit;
  cursor: pointer;
}
.post-pill [data-glyph] { display: inline-flex; }
.post-pill svg { display: block; }
.post-pill:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-ring); }
/* Playing: the fill goes to the accent, which is what the app tints a control
   that is doing something rather than offering to. */
.post-pill.is-playing { background: var(--accent); color: var(--on-accent); }

/* StarRatingView: five 24pt stars in a glass capsule, edged and lifted. Empty,
   because this is *your* score and there is none — the one number the app would
   draw here that this page will not invent. */
.post-stars {
  display: flex;
  align-items: center;
  /* An SF symbol's ink is not its point size: a star at 24 measures 26.8
     across, and SwiftUI lays the images out on a 24 box that the ink overflows.
     The sprite's star fills its own box instead, so the box is sized to the ink
     — 27 — and the gap and padding are the app's, less the 3 the ink has
     already spent. The pill comes out 187 x 40 either way, which is what the
     phone draws. */
  gap: 5px;
  width: fit-content;
  margin: 0 auto;
  padding: 6.5px 16px;
  border-radius: var(--r-pill);
  background: var(--panel-fill);
  -webkit-backdrop-filter: var(--glass);
  backdrop-filter: var(--glass);
  box-shadow: inset 0 0 0 1px var(--hairline), var(--lift);
}
/* A real control — see the rating block in app.js. */
.star {
  position: relative;
  display: block;
  width: 27px;
  height: 27px;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  color: var(--rating-empty);
  /* The half-star is a second tap, and a browser's default reading of two quick
     taps is zoom — which also costs the first tap its 300ms. */
  touch-action: manipulation;
  /* interactiveSpring, near enough: it overshoots coming back. */
  transition: color var(--t-press), transform 0.3s cubic-bezier(0.24, 1.4, 0.42, 1);
}
.star svg { display: block; width: 27px; height: 27px; }
/* The two glyphs are stacked and cross-faded rather than swapped, so nothing
   reflows and the outline stays under the fill as its edge. */
.star svg + svg { position: absolute; inset: 0; opacity: 0; }
.star.is-lit {
  color: var(--rating);
  /* shadow(color: rating.opacity(0.45), radius: 5) */
  filter: drop-shadow(0 0 5px color-mix(in srgb, var(--rating) 45%, transparent));
}
.star.is-lit svg + svg { opacity: 1; }
/* star.leadinghalf.filled, without a third drawing. The fill is clipped to its
   leading half and the outline underneath keeps drawing the whole star's edge,
   which is exactly what that symbol is. Clipped rather than half-hidden so the
   cut lands on the star's centre line and not on the glyph's ink. */
.star.is-half svg + svg { clip-path: inset(0 50% 0 0); }
.star:focus-visible { outline: none; border-radius: 6px; box-shadow: 0 0 0 3px var(--accent-ring); }
/* poppedStar: scaleEffect 1.3 on a spring. */
.star.is-popping { animation: star-pop 0.34s cubic-bezier(0.24, 1.4, 0.42, 1); }
@keyframes star-pop {
  0%   { transform: scale(1); }
  38%  { transform: scale(1.3); }
  100% { transform: scale(1); }
}

/* -- Search and Chat ------------------------------------------------------- */
/* Browse and Messages both open with a .searchable field under the title in the
   app, and neither is drawn here. It was a picture of a field: it took no text,
   searched nothing, and sat at the top of two screens as the first thing on
   them — a control-shaped object promising the one interaction this panel can't
   perform. The screens start on their content instead. */
/* SectionHeader.swift, the same 12 semibold uppercase tracking-1 the page uses
   for its own section titles. */
.browse-head {
  font-size: calc(12px * var(--tf, 1));
  font-weight: 600;
  letter-spacing: 1px;
  text-transform: uppercase;
  color: var(--text-dim);
}

/* -- Browse ---------------------------------------------------------------- */
/* BrowseView is a stack of carousels at sectionSpacing 24, each under a header
   with a See All capsule, over a 170pt banner. The rows run off the *trailing*
   edge because that is what tells you they scroll — the bottom still has to
   clear the floating tab bar, or the last section's Follow buttons sit under the
   glass with nowhere left to scroll. This used to zero the padding outright,
   back when the bar was a strip below the screen rather than a layer on it. */
/* Plus the mini player's own 56 and the 6 under it, because it is a
   safeAreaInset here and the rows have to be able to scroll clear of it too. */
.screen-body.is-browse { padding-bottom: calc(var(--safe-bottom) + 62px + 12px); }

/* -- NowPlayingBar --------------------------------------------------------- */
/* "A card rather than a full-width strip, so the list underneath still reads as
   the page and the bar reads as an overlay on top of it" — the comment on the
   Swift view, and the reason for the 10 down each side rather than none.
   Sits above the screen but below the tab bar, which floats over everything. */
.nowplaying {
  position: absolute;
  left: 10px;
  right: 10px;
  bottom: calc(var(--safe-bottom) + 6px);
  z-index: 5;
  display: flex;
  align-items: center;
  gap: 12px;
  /* .padding(.leading, 8).padding(.trailing, 4).padding(.vertical, 8) */
  padding: 8px 4px 8px 8px;
  border-radius: 14px;
  background: var(--bg-blur);
  -webkit-backdrop-filter: var(--glass);
  backdrop-filter: var(--glass);
  box-shadow: inset 0 0 0 0.5px var(--separator), 0 10px 20px rgba(0, 0, 0, 0.18);
  overflow: hidden;
}
.np-art {
  width: 40px;
  height: 40px;
  border-radius: 6px;
  flex-shrink: 0;
  background-size: cover;
  background-position: center;
  background-color: var(--surface);
}
/* VStack(alignment: .leading, spacing: 2) */
.np-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.np-text b {
  font-size: calc(14px * var(--tf, 1));
  font-weight: 500;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* smallFont, 11 medium */
.np-text span {
  font-size: calc(11px * var(--tf, 1));
  font-weight: 500;
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* 32x32 hit boxes, so the two glyphs sit level however wide they draw. */
.np-btn { flex-shrink: 0; width: 32px; height: 32px; display: grid; place-items: center; }
.np-btn svg { width: 18px; height: 18px; }
.np-btn.is-next svg { width: 16px; height: 16px; }
/* hasNext is false at the end of a queue; the app dims it rather than hiding it. */
.np-btn.is-next { color: var(--text-dim); }
/* 2pt of accent along the bottom edge, inside the clip. */
.np-progress {
  position: absolute;
  left: 0;
  bottom: 0;
  height: 2px;
  width: 38%;
  background: var(--accent);
}
.is-browse .sec-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 22px 0 10px;
}
/* SeeAllButton: 13 semibold in the accent, on a 12% accent capsule. */
.see-all {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  font-size: calc(13px * var(--tf, 1));
  font-weight: 600;
  color: var(--accent);
  background: var(--accent-wash);
  padding: 7px 12px;
  border-radius: var(--r-pill);
  flex-shrink: 0;
}
.see-all svg { width: 10px; height: 10px; }
/* "Near <city>", a statement rather than a control, in smallFont. */
.sec-sub { font-size: calc(11px * var(--tf, 1)); font-weight: 500; color: var(--text-dim); margin: -6px 0 10px; }

/* A carousel: HStack(spacing: 12) inside a horizontal ScrollView, bleeding past
   the screen's padding so the next card is half-visible. */
.carousel {
  display: flex;
  gap: 12px;
  overflow-x: auto;
  scrollbar-width: none;
  margin: 0 -16px;
  padding: 0 16px;
}
.carousel::-webkit-scrollbar { display: none; }

/* The 170pt banner: an 11 bold eyebrow over a 21 bold title, radius 14. */
.banner {
  position: relative;
  height: 170px;
  border-radius: 14px;
  overflow: hidden;
  display: flex;
  align-items: flex-end;
  padding: 14px;
  color: #fff;
  /* Artwork in img/, drawn rather than photographed — see the note at the top
     of the file itself. The scrim over it is the same one that was over the
     gradient: the title under it is 21 bold white, and the picture's own dark
     corner is not by itself enough to guarantee that at every size. */
  background:
    linear-gradient(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.62)),
    url(img/venue-night.svg) center / cover no-repeat,
    linear-gradient(140deg, #1f2937, #4f46e5 60%, #db2777);
}
.banner-copy { display: flex; flex-direction: column; gap: 3px; }
.banner-eyebrow { font-size: calc(11px * var(--tf, 1)); font-weight: 700; letter-spacing: 0.6px; text-transform: uppercase; opacity: 0.85; }
.banner-copy b { font-size: calc(21px * var(--tf, 1)); font-weight: 700; letter-spacing: -0.3px; }
.banner-copy em { font-size: calc(13px * var(--tf, 1)); font-style: normal; opacity: 0.8; }
/* The carousel's page dots, 7pt, under the banner. */
.banner-dots { display: flex; gap: 6px; justify-content: center; margin-top: 10px; }
.banner-dots i { width: 7px; height: 7px; border-radius: 999px; background: var(--separator); }
.banner-dots i.is-on { background: var(--accent); }

/* ConcertCard: 200x120 artwork at radius 10 over three lines, the date on the
   artwork's top-left corner. */
.ev-card { width: 200px; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.ev-art {
  position: relative;
  height: 120px;
  border-radius: 10px;
  margin-bottom: 4px;
}
/* CalendarBlock — SearchResultRows.swift, the same tile the event *row* carries,
   which is the point of it: one date reads one way everywhere in the app. This
   corner used to hold a pill saying TONIGHT, or SAT 5 in a quieter grey when the
   show was further off. Two things wrong with that. The app has no such pill —
   how soon a show is (whenLabel) is a phrase the *rows* carry, not something the
   card says — and the tile is a fixed object: same size, same colours, on every
   card, so a shelf of them scans as a column of dates rather than as a set of
   differently-weighted labels.

   48pt square at radius 8, on the card surface, hairlined; the month in a
   full-width accent strip over the day. The VStack has spacing 0 and comes to 44
   inside a fixed 48 frame, so it centres with about 2 above and below and the
   strip does not run to the tile's top edge. That gap is what makes it a
   calendar page rather than a coloured header — worth keeping deliberately,
   because it looks like a rounding error and isn't. */
.ev-cal {
  position: absolute;
  left: 8px; top: 8px;
  width: 48px;
  height: 48px;
  display: flex;
  flex-direction: column;
  justify-content: center;
  border-radius: 8px;
  overflow: hidden;
  background: var(--surface);
  /* strokeBorder, so it is drawn inside the 48 rather than around it. */
  box-shadow: inset 0 0 0 0.5px var(--separator);
}
.ev-cal i { display: block; font-style: normal; text-align: center; padding: 2px 0; }
.ev-cal .ev-cal-m {
  font-size: calc(10px * var(--tf, 1));
  font-weight: 700;
  color: var(--on-accent);
  background: var(--accent);
}
.ev-cal .ev-cal-d {
  font-size: calc(20px * var(--tf, 1));
  font-weight: 600;
  color: var(--text);
}
.ev-card b { font-size: calc(15px * var(--tf, 1)); font-weight: 600; }
.ev-card span { font-size: calc(13px * var(--tf, 1)); color: var(--text-dim); }
.ev-card em { font-size: calc(11px * var(--tf, 1)); font-weight: 500; font-style: normal; color: var(--text-faint); }

/* songCard: 130pt square, radius 8, 13 medium over smallFont. */
.song-card { width: 130px; flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
.song-art { width: 130px; height: 130px; border-radius: 8px; }
.song-card b { font-size: calc(13px * var(--tf, 1)); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.song-card span { font-size: calc(11px * var(--tf, 1)); font-weight: 500; color: var(--text-dim); }

/* The artist row: a 110pt circle over one centred line. */
.artist-card { width: 110px; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.artist-ring {
  width: 110px;
  height: 110px;
  border-radius: 999px;
  display: grid;
  place-items: center;
  color: #fff;
  font-size: calc(30px * var(--tf, 1));
  font-weight: 700;
}
.artist-card b { font-size: calc(13px * var(--tf, 1)); font-weight: 500; }

/* albumCard: the same 130 square as a tune, titled with the record over the
   artist, and left-aligned rather than centred. */
.album-card { width: 130px; flex-shrink: 0; display: flex; flex-direction: column; gap: 6px; }
.album-card .song-art { margin-bottom: 0; }
.album-card b { font-size: calc(13px * var(--tf, 1)); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.album-card span { font-size: calc(11px * var(--tf, 1)); font-weight: 500; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* personCard: an 84pt avatar over a name, a handle and a Follow, 130 wide. The
   card is not one button in the app — the name pushes a profile and Follow acts
   in place — which is why the button is drawn as its own surface here. */
.person-card {
  width: 130px;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  text-align: center;
}
.person-card b { font-size: calc(13px * var(--tf, 1)); font-weight: 500; }
.person-card span { font-size: calc(11px * var(--tf, 1)); font-weight: 500; color: var(--text-dim); margin-top: -6px; }
.person-follow {
  font-style: normal;
  font-size: calc(11px * var(--tf, 1));
  font-weight: 600;
  padding: 5px 14px;
  border-radius: var(--r-pill);
  background: var(--accent);
  color: var(--on-accent);
}

/* -- Charts ---------------------------------------------------------------- */
/* ChartsSection: a chip row over columns of four rows. The chips are the same
   filterChipSurface the deck's genre row wears, at 13 semibold. */
.chart-picker { display: flex; gap: 8px; margin-bottom: 4px; overflow-x: auto; scrollbar-width: none; }
.chart-picker::-webkit-scrollbar { display: none; }
.chart-chip {
  flex-shrink: 0;
  font-size: calc(13px * var(--tf, 1));
  font-weight: 600;
  color: var(--text);
  padding: 7px 14px;
  border-radius: var(--r-pill);
  background: var(--bg-blur);
  -webkit-backdrop-filter: var(--glass);
  backdrop-filter: var(--glass);
  box-shadow: inset 0 0 0 1px var(--separator);
}
.chart-chip.is-on { background: var(--btn); color: var(--btn-fg); box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; }

/* ChartRowView: the rank right-aligned in 26, then 44 of artwork, then the
   record. Nothing on the trailing edge — Apple's chart carries no metric. */
.chart-rows { display: flex; flex-direction: column; }
.chart-row { display: flex; align-items: center; gap: 12px; padding: 7px 0; }
.chart-rank {
  width: 26px;
  flex-shrink: 0;
  text-align: right;
  font-size: calc(15px * var(--tf, 1));
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--text-dim);
}
/* The podium in accent. Nothing else on the page separates the top of a chart
   from the middle of one. */
.chart-row.is-top .chart-rank { font-weight: 800; color: var(--accent); }
.chart-art { width: 44px; height: 44px; border-radius: 6px; flex-shrink: 0; background-size: cover; background-position: center; }
.chart-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.chart-text b { font-size: calc(15px * var(--tf, 1)); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chart-text span { font-size: calc(13px * var(--tf, 1)); color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.row-list { display: flex; flex-direction: column; }
/* HStack(spacing: 14), and the separator inset to clear the avatar the way the
   chat lists inset theirs. */
.row { position: relative; display: flex; align-items: center; gap: 14px; padding: 9px 0; }
/* Inset past the avatar so it starts at the title, which is what
   .alignmentGuide(.listRowSeparatorLeading) does in ChatListView — 44pt of
   avatar plus the row's own 14pt gap. It was full-bleed here, and a rule that
   runs under the faces cuts them off from their own rows. Drawn as a pseudo
   rather than a border because a border cannot be inset from one side. */
.row + .row::before {
  content: "";
  position: absolute;
  left: 58px;
  right: 0;
  top: 0;
  height: 0.5px;
  background: var(--separator);
}
.row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.row-text b { font-size: calc(16px * var(--tf, 1)); font-weight: 600; }
.row-text span {
  font-size: calc(13px * var(--tf, 1));
  color: var(--text-dim);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.row-tag { font-size: calc(13px * var(--tf, 1)); color: var(--text-dim); flex-shrink: 0; }

/* ConversationRow's two lines. Both are an HStack with a Spacer in the middle,
   which is the part this used to get wrong: the timestamp and the unread count
   were stacked in one right-hand column, centred against the row, where the app
   hangs the time off the *title* line and the badge off the *preview* line. One
   column reads as a mail client; two lines each with their own trailing item is
   what iOS Messages — and this app — actually does.

   Only the inbox uses these. The jam's Up Next list is built on the same .row
   and .row-text and keeps the plain b/span above. */
.row-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
/* The two things that take the slack, so the Spacer lands where the app's does. */
.row-line b { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.row-prev { flex: 1; }
/* smallFont, 11 medium — and secondaryLabel, not the tertiary this had. */
.row-line em {
  font-size: calc(11px * var(--tf, 1));
  font-weight: 500;
  font-style: normal;
  color: var(--text-dim);
  flex-shrink: 0;
}
/* The last song shared in the thread, 28pt at radius 6, between the preview and
   the unread count. Drawn only for a thread this panel can actually point at a
   record in — see [data-shared-art] in app.js. */
.row-thumb {
  width: 28px;
  height: 28px;
  border-radius: 6px;
  flex-shrink: 0;
  background: var(--chip-fill) center / cover no-repeat;
}
/* The unread capsule: 11 bold, on-accent, over the accent. */
.unread {
  font-size: calc(11px * var(--tf, 1));
  font-weight: 700;
  color: var(--on-accent);
  background: var(--accent);
  padding: 3px 7px;
  border-radius: var(--r-pill);
}
.thumb { width: 44px; height: 44px; border-radius: 8px; flex-shrink: 0; }
.album-strip { display: flex; gap: 10px; }
.album { width: 96px; height: 96px; border-radius: 10px; flex-shrink: 0; }

/* Avatars at the app's sizes: 40 in a post header, 44 in a conversation row,
   88 on the profile. */
.av {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  width: 40px; height: 40px;
  border-radius: 999px;
  color: #fff;
  font-size: calc(13px * var(--tf, 1));
  font-weight: 700;
  background-size: cover;
  background-position: center;
}
/* AvatarView's image. The accounts in this panel are invented — see the note
   above SHARERS in app.js — so their faces are drawn rather than photographed:
   a photograph is a picture of somebody who exists, and none of these people
   do. tools/gen-avatars.py draws them and says how.

   Named rather than inlined so the markup only has to say who somebody is, and
   so one person keeps one face across the twenty-odd places they appear. The
   plain .av above still stands: the jam rows put a listener count in the same
   circle, and a number is not a person. */
.av[data-who="remi"]     { background-image: url(img/avatars/remi.svg); }
.av[data-who="aya"]      { background-image: url(img/avatars/aya.svg); }
.av[data-who="mara"]     { background-image: url(img/avatars/mara.svg); }
.av[data-who="sam"]      { background-image: url(img/avatars/sam.svg); }
.av[data-who="elias"]    { background-image: url(img/avatars/elias.svg); }
.av[data-who="tobias"]   { background-image: url(img/avatars/tobias.svg); }
.av[data-who="jae"]      { background-image: url(img/avatars/jae.svg); }
.av-20 { width: 20px; height: 20px; font-size: calc(8px * var(--tf, 1)); }
.av-26 { width: 26px; height: 26px; font-size: calc(10px * var(--tf, 1)); }
.av-32 { width: 32px; height: 32px; font-size: calc(12px * var(--tf, 1)); }
.av-44 { width: 44px; height: 44px; font-size: calc(14px * var(--tf, 1)); }
.av-84 { width: 84px; height: 84px; font-size: calc(25px * var(--tf, 1)); }
.av-88 { width: 88px; height: 88px; font-size: calc(26px * var(--tf, 1)); }

/* -- Chat's three panes ---------------------------------------------------- */
/* The inbox, the thread, the jam — paged sideways on the same mechanism the feed
   uses for its two, and for the same reason: this tab's content is a sequence,
   and a screen of rows only ever shows you the first step of it.
   See .feed-pager for why the horizontal and vertical scrollers are split. */
.chat-pager {
  display: flex;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  padding: 0;
}
.chat-pager.is-driven { scroll-snap-type: none; }
.chat-pager::-webkit-scrollbar { display: none; }
.chat-pane {
  flex: 0 0 100%;
  scroll-snap-align: start;
  overflow-y: auto;
  overscroll-behavior-y: contain;
  scrollbar-width: none;
  padding: var(--screen-top) 16px calc(var(--safe-bottom) + 12px);
}
.chat-pane::-webkit-scrollbar { display: none; }
/* The rail bleeds past the trailing edge, like every other carousel here, so the
   inbox pane takes its own side padding back off the rail. */
.chat-pane .jam-rail { margin-right: -16px; }

/* One title, three states. Laid on top of each other in one grid cell so the bar
   never changes width mid-pan and the glyphs either side stay put. */
.navbar-t[data-chat-title] { display: grid; }
.navbar-t .ct {
  grid-area: 1 / 1;
  opacity: 0;
  transition: opacity 0.22s var(--ease-out);
}
.navbar-t .ct.is-on { opacity: 1; }
/* ChatDetailView's principal item, which is the one bar in the app that isn't a
   word: VStack(spacing: 2) of a 32pt avatar over the display name at 12
   semibold. The person rather than their name — you opened a conversation with
   somebody, and the bar says who.

   32 + 2 + a 12pt line comes to about 48 in a bar that is 44, so the stack sits
   a little proud of its row. That is what it does on a device too: an inline bar
   gives a principal item the room it asks for. Here the bar is a fixed height
   with the screen scrolling under it, so the overflow simply paints — nothing
   below moves, and the two neighbouring titles keep the row they were on. */
.navbar-t .ct.is-person {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  place-self: center;
}
.navbar-t .ct.is-person b {
  font-size: calc(12px * var(--tf, 1));
  font-weight: 600;
}
/* Back only exists inside a thread — the list is the root of the stack.
   Deliberately NOT .is-on: that class already means "tinted with the accent" on
   a .navbtn, which is the badged bell's state, and reusing it here tinted the
   chevron. Visibility and tint are two different things. */
.navbtn.is-back { opacity: 0; transition: opacity 0.22s var(--ease-out); }
.navbtn.is-back.is-shown { opacity: 1; }

/* -- The thread ------------------------------------------------------------ */
.chat-pane.is-thread { display: flex; flex-direction: column; padding-bottom: 0; }
.thread {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 2px;
  padding-bottom: 10px;
}
/* avatarSize 26 + railSpacing 6. The rail is held even where no face is drawn,
   so a run stays aligned instead of stepping left on its last bubble. */
.msg { display: flex; align-items: flex-end; gap: 6px; max-width: 100%; }
.msg.out { justify-content: flex-end; }
.msg.run-start { margin-top: 6px; }
.msg .av { flex-shrink: 0; visibility: hidden; }
.msg.run-end .av { visibility: visible; }
.bub {
  max-width: 76%;
  padding: 9px 14px;
  font-size: calc(16px * var(--tf, 1));
  border-radius: 18px;
  overflow-wrap: anywhere;
}
/* UnevenRoundedRectangle: 18 everywhere except the corner a run continues
   through, which is 6. That is what makes three bubbles read as one turn of
   speech rather than as three separate lozenges. */
.msg.in .bub { background: var(--bubble-in); color: var(--text); }
.msg.out .bub { background: var(--bubble-out); color: var(--on-bubble-out); }
.msg.in:not(.run-start) .bub { border-top-left-radius: 6px; }
.msg.in:not(.run-end) .bub { border-bottom-left-radius: 6px; }
.msg.out:not(.run-start) .bub { border-top-right-radius: 6px; }
.msg.out:not(.run-end) .bub { border-bottom-right-radius: 6px; }
/* A song brings its own surface, so it drops the bubble's fill and padding. */
.bub.is-song {
  padding: 0;
  background: none;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 218px;
  border-radius: var(--r-card);
  overflow: hidden;
  background: var(--surface);
  box-shadow: inset 0 0 0 0.5px var(--separator);
}
.bub.is-song .art { width: 46px; height: 46px; flex-shrink: 0; background-size: cover; background-position: center; }
.bub.is-song .t { display: flex; flex-direction: column; gap: 1px; min-width: 0; padding: 6px 10px 6px 0; }
.bub.is-song b { font-size: calc(14px * var(--tf, 1)); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bub.is-song span { font-size: calc(12px * var(--tf, 1)); color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.msg-time {
  font-size: calc(10px * var(--tf, 1));
  color: var(--text-dim);
  padding-left: 36px;              /* clears the avatar column */
  margin: 2px 0 0;
}
/* TypingIndicatorView. Three dots on their own bubble, breathing out of phase. */
.bub.is-typing { display: flex; align-items: center; gap: 4px; padding: 12px 14px; }
.bub.is-typing i {
  width: 7px;
  height: 7px;
  border-radius: 999px;
  background: var(--text-faint);
  animation: typing 1.25s var(--ease-out) infinite;
}
.bub.is-typing i:nth-child(2) { animation-delay: 0.16s; }
.bub.is-typing i:nth-child(3) { animation-delay: 0.32s; }
@keyframes typing {
  0%, 60%, 100% { opacity: 0.35; transform: translateY(0); }
  30%           { opacity: 1;    transform: translateY(-3px); }
}
/* Sticks to the bottom of the pane rather than scrolling away with the last
   message, which is where a composer lives. */
/* inputBar: HStack(spacing: 10), .padding(.vertical, 10). The horizontal
   cardPadding comes off the pane, which already carries it. */
.composer {
  position: sticky;
  bottom: 0;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 0 calc(var(--safe-bottom) + 10px);
  background: linear-gradient(var(--bg-0), var(--bg) 22%);
}
/* TextField("Message...") — .plain over cardBackground, clipped to a Capsule,
   padding 12 / 8. Not glass: the field is the one solid surface in the bar, and
   it is what the three accent glyphs are read against. The ellipsis is the app's
   own placeholder, and it is doing something — a field that says "Message" reads
   as a label on a filled row rather than as an empty one waiting for you. */
.composer-field {
  flex: 1;
  padding: 8px 12px;
  border-radius: var(--r-pill);
  background: var(--surface);
  font-size: calc(16px * var(--tf, 1));
  color: var(--text-faint);
}
/* The three glyphs either side, all accent, all bare — no disc behind any of
   them. Sized individually because the app sizes them individually: 20 for the
   note, 19 for the paperclip, 22 for the mic. */
.composer-act {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  color: var(--accent);
}
/* .frame(width: 28, height: 28) — the slot arrow.up.circle.fill takes over once
   there is text, held at that size so the trailing controls don't shuffle as you
   type. Nothing is typed here, so it stays a microphone. */
.composer-act.is-mic { width: 28px; height: 28px; }

/* -- The jam --------------------------------------------------------------- */
.chat-pane.is-jam { display: flex; flex-direction: column; align-items: center; text-align: center; }
/* The match banner, which is the screen's whole reason for existing: everyone in
   the room swiped the same song and kept it. */
.jam-match {
  display: flex;
  align-items: center;
  gap: 10px;
  align-self: stretch;
  padding: 10px 16px;
  border-radius: var(--r-panel);
  background: var(--accent-wash);
  box-shadow: inset 0 0 0 0.5px var(--accent-ring);
  text-align: left;
}
.jam-match-emoji { font-size: calc(22px * var(--tf, 1)); line-height: 1; }
.jam-match-text { display: flex; flex-direction: column; gap: 1px; }
.jam-match-text b { font-size: calc(13px * var(--tf, 1)); font-weight: 700; }
.jam-match-text span { font-size: calc(12px * var(--tf, 1)); color: var(--text-dim); }
/* Overlapped faces, the way the app stacks a room's members. */
.jam-who-row { display: flex; align-items: center; margin-top: 16px; }
.jam-who-row .av { margin-right: -6px; box-shadow: 0 0 0 2px var(--bg); }
/* Which of the faces in the room is the one holding this phone. The ring is the
   accent rather than another grey, because it is answering a question the other
   two don't raise — and it is the same answer on both handsets, each pointing at
   a different person, which is what makes the pair legible. */
.jam-who-row .av.is-me { box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent); }
.jam-who-label { font-size: calc(13px * var(--tf, 1)); color: var(--text-dim); margin-left: 16px; }
.jam-art {
  width: 160px;
  height: 160px;
  margin-top: 18px;
  border-radius: var(--r-panel);
  background-size: cover;
  background-position: center;
  background-color: var(--surface);
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28);
}
.jam-song { display: flex; flex-direction: column; gap: 3px; margin-top: 14px; max-width: 100%; }
.jam-song b { font-size: calc(17px * var(--tf, 1)); font-weight: 600; }
.jam-song span { font-size: calc(13px * var(--tf, 1)); color: var(--text-dim); }
/* The same two verdicts the deck wears, at the size JamView draws them. */
.jam-verdicts { display: flex; gap: 22px; margin-top: 18px; }
.jam-verdicts .ios-circle { width: 56px; height: 56px; }
.jam-verdicts .ios-circle svg { width: 23px; height: 23px; }
.jam-next { align-self: stretch; margin-top: 24px; text-align: left; }
.jam-next .browse-head { display: block; margin-bottom: 8px; }

/* -- Live sessions --------------------------------------------------------- */
/* The inbox's rail of live jams. JamLiveDot beside the section header, doing the
   work an accent-coloured label used to: this row is a section, not an alert. */
.av-24 { width: 24px; height: 24px; font-size: calc(9px * var(--tf, 1)); }
/* Its own display, not the one .is-browse hands its section headers: this
   header lives on Chat, where that rule doesn't reach, and without it the dot
   is an inline span ignoring its own width. */
.sec-head.is-live {
  display: flex;
  align-items: center;
  gap: 6px;
  padding-top: 10px;
  margin-bottom: 8px;
}
/* JamLiveDot: a ring that expands to twice the dot and fades, easeOut over 1.3s,
   forever. Sized to the dot it belongs to — a ring much wider than that reads as
   a blob rather than a pulse — and it takes its colour from whatever it is
   sitting on, so the white one inside the LIVE pill pulses white. */
.live-dot {
  --ring: var(--accent-ring);
  width: 5px; height: 5px;
  border-radius: 999px;
  background: var(--accent);
  animation: live-pulse 1.3s var(--ease-out) infinite;
}
@keyframes live-pulse {
  0%        { box-shadow: 0 0 0 0 var(--ring); }
  70%, 100% { box-shadow: 0 0 0 4px transparent; }
}
.jam-rail { padding-bottom: 10px; }

/* LiveSessionCard: 240x132 at radius 14, the artwork behind everything. */
.jam-card {
  position: relative;
  flex-shrink: 0;
  width: 240px;
  height: 132px;
  padding: 12px;
  border-radius: 14px;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  color: #fff;
  background-size: cover;
  background-position: center;
  box-shadow: inset 0 0 0 0.5px var(--separator);
}
/* Three stops rather than a flat scrim: the top pills need a little cover, the
   middle should stay artwork, and the caption needs real darkness. */
.jam-card::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.1) 35%, rgba(0, 0, 0, 0.8));
}
.jam-card > * { position: relative; }

.jam-top { display: flex; align-items: center; gap: 6px; }
.jam-pill {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 5px 8px;
  border-radius: var(--r-pill);
  background: rgba(255, 255, 255, 0.2);
  box-shadow: inset 0 0 0 0.5px rgba(255, 255, 255, 0.25);
  font-size: calc(11px * var(--tf, 1));
  font-weight: 600;
}
.jam-pill.is-live { font-size: calc(10px * var(--tf, 1)); font-weight: 700; letter-spacing: 1.2px; }
.jam-pill .live-dot { width: 6px; height: 6px; background: #fff; --ring: rgba(255, 255, 255, 0.6); }
.jam-pill svg { width: 9px; height: 9px; }
.jam-top .sp { flex: 1; }

.jam-caption { margin-top: auto; display: flex; flex-direction: column; gap: 6px; }
.jam-who { display: flex; align-items: center; gap: 7px; min-width: 0; }
.jam-who b { font-size: calc(14px * var(--tf, 1)); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.jam-now { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: calc(12px * var(--tf, 1)); font-weight: 600; }
.jam-now em { font-style: normal; font-weight: 400; color: rgba(255, 255, 255, 0.7); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* NowPlayingIndicator: three 2.5pt capsules in a 14pt box, breathing. */
.np { display: flex; align-items: center; gap: 2px; width: 14px; height: 14px; flex-shrink: 0; }
.np i {
  width: 2.5px;
  border-radius: 999px;
  background: #fff;
  animation: np-bar 1s var(--ease-out) infinite alternate;
}
.np i:nth-child(1) { height: 6px; animation-delay: -0.2s; }
.np i:nth-child(2) { height: 11px; animation-delay: -0.6s; }
.np i:nth-child(3) { height: 8px; animation-delay: -0.4s; }
@keyframes np-bar { from { height: 4px; } to { height: 12px; } }

/* The playhead along the bottom edge — the same hairline the now-playing bar
   draws, and the detail that makes the card look alive rather than like a
   screenshot of a jam. */
.jam-progress {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 2.5px;
  background: rgba(255, 255, 255, 0.1);
}
.jam-progress i { display: block; height: 100%; background: var(--accent); }

/* -- Profile --------------------------------------------------------------- */
/* HStack(alignment: .center, spacing: 16) with an 88pt avatar, the name in
   titleFont, and the stat row on a line of its own below. */
.prof-head { display: flex; align-items: center; gap: 16px; margin-bottom: 14px; }
.prof-who { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.prof-who b { font-size: calc(28px * var(--tf, 1)); font-weight: 700; }
.prof-who span { font-size: calc(13px * var(--tf, 1)); color: var(--text-dim); }
/* The three share the row evenly, as .frame(maxWidth: .infinity) gives them. */
.prof-stats { display: flex; margin-bottom: 12px; }
.prof-stats span {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  font-size: calc(12px * var(--tf, 1));
  color: var(--text-dim);
}
.prof-stats b {
  font-size: calc(19px * var(--tf, 1));
  font-weight: 700;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
/* Wraps, like .post-caption — leading back for the same reason. */
.prof-bio { font-size: calc(16px * var(--tf, 1)); line-height: 1.35; color: var(--text-dim); }

/* ProfileAnthemRow: the song's own sleeve, blurred under a 35% scrim, carrying
   a 40pt copy of itself. The same trick the deck card and the feed post use —
   background on the element, never a filtered child. */
.anthem {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 14px;
  padding: 12px;
  border-radius: var(--r-card);
  background-size: cover;
  background-position: center;
  color: #fff;
}
.anthem-art { width: 40px; height: 40px; border-radius: 6px; flex-shrink: 0; background-size: cover; background-position: center; }
.anthem-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.anthem-text em {
  font-style: normal;
  font-size: calc(10px * var(--tf, 1));
  font-weight: 600;
  letter-spacing: 1.2px;
  color: rgba(255, 255, 255, 0.7);
}
.anthem-text b { font-size: calc(14px * var(--tf, 1)); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.anthem-text span { font-size: calc(13px * var(--tf, 1)); color: rgba(255, 255, 255, 0.8); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.anthem-play {
  flex-shrink: 0;
  width: 26px;
  height: 26px;
  border-radius: 999px;
  display: grid;
  place-items: center;
  background: rgba(255, 255, 255, 0.22);
  -webkit-backdrop-filter: var(--glass);
  backdrop-filter: var(--glass);
  color: rgba(255, 255, 255, 0.9);
}

/* Edit Profile: full width, glass, edged. Width comes from the row rather than
   from padding, so the capsule spans the header's content. */
.prof-edit {
  display: block;
  margin-top: 14px;
  padding: 10px 0;
  border-radius: var(--r-pill);
  text-align: center;
  font-size: calc(14px * var(--tf, 1));
  font-weight: 600;
  color: var(--text);
  background: var(--bg-blur);
  -webkit-backdrop-filter: var(--glass);
  backdrop-filter: var(--glass);
  box-shadow: inset 0 0 0 1px var(--separator);
}

/* Everything below the header is one VStack(spacing: sectionSpacing) under a
   .padding(.top, sectionSpacing), so the shelf, the tab bar and the tab's
   content all stand 24 apart.

   The 12 below is the shelf's *own* internal spacing, and a different number
   from the 24 above it on purpose: FeaturedSongsShelf is a
   VStack(spacing: 12) holding the header and the row, so the title sits closer
   to the sleeves it names than the shelf does to its neighbours. It was 0 here,
   which put the word on top of the artwork and left the shelf reading as one
   undifferentiated block. */
.prof-edit + .browse-head {
  display: block;
  margin-top: var(--sp-sect);
  margin-bottom: 12px;
}

/* FeaturedSongCard: 130 square at radius 12, with the play badge sitting in the
   bottom-right corner of the sleeve. */
.feat-card { width: 130px; flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; }
.feat-art {
  position: relative;
  width: 130px;
  height: 130px;
  border-radius: var(--r-card);
  background-size: cover;
  background-position: center;
}
.feat-art .ic { position: absolute; right: 6px; bottom: 6px; color: var(--accent); filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.35)); }
.feat-card b { font-size: calc(14px * var(--tf, 1)); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.feat-card span { font-size: calc(12px * var(--tf, 1)); color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

/* The profile's segmented capsule: one glass container at padding 4 holding two
   equal halves, the selected one on its own pill. Not the feed's pair of
   free-floating chips — this one is a single control, which is exactly what
   GlassEffectContainer(spacing: 4) wrapping tabButtons.padding(4) makes it. */
.prof-tabs {
  display: flex;
  gap: 4px;
  margin-top: var(--sp-sect);
  padding: 4px;
  border-radius: var(--r-pill);
}
.prof-tab {
  position: relative;
  flex: 1;
  text-align: center;
  padding: 9px 0;
  border-radius: var(--r-pill);
  font-size: calc(14px * var(--tf, 1));
  font-weight: 600;
  color: var(--text-dim);
}
/* A solid accent capsule. The app draws this one as
   .glassEffect(.regular.tint(accent).interactive()) — tinted glass at 74%, over
   a lens, with a lit top edge and a white rim, so the surface keeps moving under
   the selected half. That is the material working as intended on a device, and
   at the size this panel is drawn on a web page it read as a washed-out purple
   with a highlight on it rather than as a selection. Solid says "this half" at
   any scale, which is the one job a segmented control has.

   Deliberately not the app's, then, and the only thing inside the phone that
   isn't. Everything the glass was doing goes with it: no lens, no gradient
   sheen, no rim. Half a treatment is what makes a flat pill look broken. */
.prof-tab.is-on {
  color: var(--on-accent);
  background: var(--accent);
}
/* SongCalendarView. Seven flexible columns, like the LazyVGrid it comes from,
   but the cells are *squares* — the app sizes each one off a clear aspect-ratio
   anchor so a day's artwork can't inflate it, and a day is a tile rather than a
   number on a row.

   This drew flat numbers with one accent dot under some of them. What the app
   actually does: a day you posted on is backed by that song's artwork, blurred
   and scrimmed, with the number in white over it and a dot per post up to three;
   a gig you're going to takes an accent ring and a ticket rather than a fill, so
   a day that is both still reads as both; today is accent and bold; and days
   ahead of today are dimmed, because there is nothing behind them to open. */
.cal { margin-top: var(--sp-sect); }
.cal-nav { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.cal-month { font-size: calc(16px * var(--tf, 1)); font-weight: 600; }
.cal-arrow { color: var(--text-dim); display: flex; }
.cal-arrow svg { width: 14px; height: 14px; }
/* The forward arrow is dead on the current month, and says so at the app's
   opacity rather than by disappearing. */
.cal-arrow.is-off { opacity: 0.3; }
.cal-wds { display: grid; grid-template-columns: repeat(7, 1fr); margin-bottom: 8px; }
.cal-wd { font-size: calc(11px * var(--tf, 1)); font-weight: 500; color: var(--text-dim); text-align: center; }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; }
.cal-day {
  aspect-ratio: 1;
  border-radius: 8px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  font-size: calc(13px * var(--tf, 1));
  color: var(--text);
  background-size: cover;
  background-position: center;
}
/* The days that answer a press. dayCell is a Button that opens the day, and
   .disabled() leaves only days with something on them live — so only those are
   drawn as buttons here, and a dead day stays a span with no pointer on it. */
button.cal-day {
  position: relative;
  border: none;
  padding: 0;
  font: inherit;
  font-size: calc(13px * var(--tf, 1));
  background-color: transparent;
  color: inherit;
  cursor: pointer;
  touch-action: manipulation;
}
/* An outline rather than a box-shadow: .has-gig spends its box-shadow on the
   ring that says there's a gig, and focus must not take that away. */
button.cal-day:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* accent.opacity(0.5) over the sleeve, which is what selection swaps the
   media scrim for. Under the number and the count, not over them. */
.cal-day.is-picked::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 8px;
  background: color-mix(in srgb, var(--accent) 50%, transparent);
}
.cal-n, .cal-mark, .cal-count { position: relative; z-index: 1; }
.cal-mark { display: flex; align-items: center; }
/* A selected day trades its dots — or its ticket — for what it is holding. */
.cal-count {
  display: none;
  font-size: calc(9px * var(--tf, 1));
  font-weight: 700;
  color: rgba(255, 255, 255, 0.85);
}
.cal-day.is-picked { color: #fff; }
.cal-day.is-picked .cal-mark { display: none; }
.cal-day.is-picked .cal-count { display: block; }
.cal-day.is-out { visibility: hidden; }
/* textSecondary at 0.3: a day with nothing behind it yet. */
.cal-day.is-future { color: var(--text-faint); opacity: 0.55; }
.cal-day.is-today { color: var(--accent); font-weight: 700; }
.cal-day.has-art { color: #fff; font-weight: 600; text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5); }
.cal-day.has-gig { box-shadow: inset 0 0 0 2px var(--accent); color: var(--accent); }
.cal-day.has-gig.has-art { color: #fff; }
.cal-dots { display: flex; gap: 2px; }
.cal-dots i { width: 4px; height: 4px; border-radius: 999px; background: #fff; }
.cal-day .ic { width: 7px; height: 7px; }

/* -- The tab bar ----------------------------------------------------------- */
/* Five tabs in ContentView's order, tinted with the accent on selection. Real
   buttons: this is the one part of the device meant to be pressed. Labels are
   10 medium and glyphs 25pt, the sizes iOS gives a tab item.

   It floats. The app is built against Liquid Glass — GlassEffectContainer and
   .glassEffect run through FeedView, ProfileView and the tour overlay — and that
   is the tab bar it gets: a capsule of glass inset from the screen's edges with
   the page running *under* it, lit by whatever it happens to be over. This was
   an opaque strip pinned to the bottom edge, squared off, with a hairline across
   the top and the screen stopping above it. The hairline is gone with it: the
   bar is an object on the page now, not the page's last row, so it is edged all
   the way round and casts a shadow instead. */
.tabbar {
  position: absolute;
  left: var(--tabbar-x);
  right: var(--tabbar-x);
  bottom: var(--tabbar-y);
  z-index: 6;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  align-items: center;
  height: var(--tabbar-h);
  padding: 0 4px;
  border-radius: var(--r-pill);
  /* Surface comes from .lg — the bar is the largest piece of Liquid Glass on the
     screen and the one whose edge is most visible against moving content, so it
     carries the same rim as every other piece rather than a flat hairline. The
     lift is its own: it floats higher than a chip does. */
  --lg-lift: 0 8px 24px rgba(0, 0, 0, 0.18);
}
:root[data-theme="dark"] .tabbar { --lg-lift: 0 8px 24px rgba(0, 0, 0, 0.5); }
.tabbtn {
  position: relative;
  display: grid;
  justify-items: center;
  gap: 4px;
  background: none;
  border: none;
  padding: 0 2px;
  font-family: inherit;
  color: var(--text-faint);
  cursor: pointer;
  transition: color var(--t-press);
}
/* iOS 26 marks the selected tab with a fill behind it, not with tint alone: a
   rounded rect covering the whole glyph-and-label block, a shade lighter than
   the bar it sits on. Proportioned off the real bar — the fill is 74% of the
   bar's height and the width of one tab, which on a 56pt bar and a 70.6pt cell
   comes out at 41 tall on a 12 corner.

   The fill is BIGGER than the button, which is the part that took two goes to
   get right. .tabbar centres its buttons rather than stretching them, so a
   .tabbtn is exactly its own glyph-and-label block and nothing more — 41 on a
   56 bar. Sized to the button the fill comes out skin-tight, gripping the glyph
   and the label with no air at all, and that is what read as wrong: on device
   there is a clear step of padding inside the fill before the icon starts.

   So the 7.5 of slack each side is split the way the real bar splits it —
   roughly a third inside the fill, two thirds left as margin to the bar's own
   edge — which puts 2.4 of padding inside and 5.1 outside, and a 46pt fill on a
   56pt bar. Against the reference: 0.82 of the bar's height where it wants
   0.76, and 0.90 of the fill filled by content where it wants 0.86. The
   remaining difference is ours to keep — this bar is 56 where the device's is
   61, and the glyph and label are the app's own sizes.

   A capsule, not a rounded rectangle. Every other surface in this bar is one —
   the bar itself, the chips, the glass buttons — and iOS 26 marks the selected
   tab the same way. A 14pt corner on a 46pt fill left flat runs down both
   sides, which is the shape of a card and reads as one.

   A pseudo rather than a background on the button so the fill can be larger
   than its box at all. It is the first child in paint order and the glyph and
   label are positioned, so they sit over it without anybody needing a
   z-index. */
.tabbtn::before {
  content: "";
  position: absolute;
  inset: -2.4px 0;
  border-radius: var(--r-pill);
  background: var(--tab-sel);
  opacity: 0;
  transition: opacity var(--t-press);
}
.tabbtn.is-on::before { opacity: 1; }
.tabbtn svg,
.tabbtn b { position: relative; }
.tabbtn svg { width: 25px; height: 25px; }
.tabbtn b { font-size: calc(10px * var(--tf, 1)); font-weight: 500; letter-spacing: 0.1px; }
.tabbtn:hover { color: var(--text-dim); }
.tabbtn.is-on { color: var(--accent); }
.tabbtn:focus-visible { outline: none; border-radius: 10px; box-shadow: 0 0 0 3px var(--accent-ring); }

/* The type factor, stepped down with the screen. Two stops rather than a smooth
   ramp: a phone panel drawn beside the copy is a different object from one
   stacked under it, and 900 is where that changes. See --tf on .phone. */
@media (max-width: 900px) { .phone { --tf: 0.90; } }
@media (max-width: 480px) { .phone { --tf: 0.82; } }

/* ------------------------------------------------------------- responsive */
@media (max-width: 900px) {
  .hero { grid-template-columns: 1fr; gap: 40px; }
  /* Stacked, the phone sits below the copy so the email field stays reachable
     without scrolling past a full-height device mock. */
  /* The FAQ's two columns stack for the same reason the hero's do — at this
     width neither the lede nor the list has room to be a column. */
  .section.is-faq { grid-template-columns: 1fr; gap: 24px; }
  /* Stacked, the lede sits above the list rather than beside it, so there is
     nothing for it to follow — sticking here would just park it over them. */
  .faq-lede { position: static; }
}

@media (max-width: 560px) {
  .wrap { padding-top: 28px; }
  .hero { margin-bottom: 20px; }
  .marquee { margin-bottom: 48px; }
  .quote { margin-bottom: 48px; }
  .section { margin-bottom: 48px; }
  /* Stacked, so the capsule would read as a lozenge: the box goes back to a
     rounded rectangle and the button — still a capsule — takes the full width
     under the input. The glyph stays beside the input, which is why the two
     share a row of their own. */
  .field { flex-direction: column; align-items: stretch; padding: 8px; border-radius: var(--r-field); gap: 8px; }
  .field-row { padding-left: 8px; }
  .field button { justify-content: center; padding: 13px 20px; }
  .cta { padding: 40px 18px; }
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { transition: none !important; animation: none !important; }
  .reveal { opacity: 1; transform: none; }
}

/* ---------------------------------------------------------- legal pages --
   privacy.html. A single measured column; a privacy notice is only useful if
   someone reads it, so it gets the same care as the rest of the page. */
/* The privacy page's way back, wearing the waitlist bar's capsule. Three things
   an anchor needs that the button above it didn't:

   the underline off, which a <button> never had; the arrow and its hover
   travel mirrored, because this one points back; and no entrance. The keyframe
   on .nav-cta is there because that button *appears* partway down the waitlist
   page — it is a response to a scroll. This one is in the bar from the first
   paint, and a thing that animates in answer to nothing reads as a glitch. */
a.nav-cta { text-decoration: none; }
.nav-cta.is-back {
  padding: 0 16px 0 14px;
  animation: none;
}
.nav-cta.is-back:hover .ic { transform: translateX(-3px); }

/* Keeps the brand mark flush with the text below it instead of sitting out at
   the 1060px edge while the prose starts 170px further in. */
.nav-narrow { max-width: var(--maxw-legal); }

.legal { max-width: var(--maxw-legal); padding-top: 48px; }

/* The notice's first line: when it was last touched, and the same notice in the
   other languages. They share a row because they are the facts about the
   document rather than about what it says, and the row wraps on a narrow screen
   rather than squeezing either of them. The ways across are one group inside it,
   so the row stays two things spread apart rather than three spread evenly.

   Quieter than .legal a, which is the accent: a link out of the document at the
   top of it should be findable, not the first thing the eye lands on. Two
   classes deep so it outranks .legal a without reaching for !important — the
   same trick .legal-lead uses a few lines up. */
.legal-topline {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 4px 18px;
}
.legal-langs { display: inline-flex; flex-wrap: wrap; gap: 4px 14px; }
.legal .lang-link {
  font-size: 13px;
  color: var(--text-dim);
  border-bottom-color: var(--hairline);
  transition: color var(--t-quick), border-color var(--t-quick);
}
.legal .lang-link:hover { color: var(--accent); border-bottom-color: var(--accent); }

.legal h1 {
  font-size: clamp(32px, 5vw, 40px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1.08;
  margin: 6px 0 22px;
}
.legal h2 {
  font-size: 20px;
  font-weight: 600;
  letter-spacing: -0.015em;
  margin: 40px 0 12px;
  scroll-margin-top: 80px; /* the sticky bar must not cover #rights */
}
.legal p, .legal li { font-size: 16px; line-height: 1.65; color: var(--text-dim); }
.legal p + p { margin-top: 12px; }
/* Two classes deep so it outranks `.legal p` without reaching for !important. */
.legal .legal-lead {
  font-size: 17px;
  line-height: 1.6;
  color: var(--text);
  padding: 18px 20px;
  background: var(--surface);
  border: 0.5px solid var(--hairline);
  border-radius: var(--r-card);
  box-shadow: var(--lift);
}
.legal ul { margin: 12px 0 12px 20px; display: grid; gap: 7px; }
.legal li::marker { color: var(--text-faint); }
.legal strong { color: var(--text); font-weight: 600; }
.legal a { color: var(--accent); text-decoration: none; border-bottom: 1px solid var(--accent-ring); }
.legal a:hover { border-bottom-color: var(--accent); }
.legal code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.88em;
  padding: 2px 6px;
  border-radius: 6px;
  background: var(--surface-2);
  color: var(--text);
}
/* The waitlist page's footer, matched. .v2-footer carries no rule over it —
   the distance from what comes before is the separation, and a hairline there
   would only draw the join — and it falls by the same clamp. Restated rather
   than shared because privacy.html doesn't load landing-v2.css, and scoped to
   .legal so landing-v1.html's footer keeps the border it was drawn with. */
.legal .footer {
  margin-top: clamp(56px, 8vw, 88px);
  padding-top: 0;
  border-top: 0;
}
/* The footer is inside .legal, so `.legal a` above was claiming its links too:
   same specificity as `.footer a`, written later, so the way back and the
   social mark came out accent-coloured with a hairline under them — a body
   link's dress on the quietest row of the page. Two classes deep to take them
   back, the same move .legal-lead makes over .legal p. The accent stays for
   hover, which is what .footer a already intended. */
.legal .footer a { color: var(--text-faint); border-bottom: 0; }
.legal .footer a:hover { color: var(--accent); }

/* Delivered / read receipts sit under the last outgoing bubble, right-aligned,
   where the incoming side's timestamp sits under the avatar column. */
.msg-time.is-out { padding-left: 0; text-align: right; }

/* The date separator a thread puts between runs from different days. Centred,
   small and dim: it is punctuation, not a heading. */
.msg-day {
  align-self: center;
  font-size: 11px;
  font-weight: 600;
  color: var(--text-faint);
  /* Room above it, none below: the separator belongs to the run it introduces,
     and hard against the bubble before it the two read as one block. */
  margin: 14px 0 8px;
}
.msg-day:first-child { margin-top: 0; }
