/* Soft, floral, deliberately not a dev tool. Light theme only and on purpose:
   this is a cosy room, not a dashboard. */

:root {
  --pink: #ff7aa8;
  --pink-soft: #ffd7e6;
  --pink-deep: #d9407a;
  --lav: #b892ff;
  --lav-soft: #ece2ff;
  --cream: #fff7fa;
  --ink: #4a3540;
  --ink-soft: #9c8592;
  --card: #ffffff;
  --line: #f6dce7;
  --ok: #57c98a;
  --radius: 18px;
  --shadow: 0 6px 22px rgba(217, 64, 122, 0.12);
  --font: "Segoe UI", -apple-system, BlinkMacSystemFont, "Nunito", system-ui, sans-serif;

  /* Every surface in the room comes from one of these three, which is what
     makes a theme a swap of six values instead of a hunt through the file. */
  --surface: #fff6fa;
  --surface-2: #fff0f6;
  --input: #fffafc;
  --glow-1: var(--lav-soft);
  --glow-2: var(--pink-soft);

  /* The pill an effect writes its caption on ("you sent a hug"). It floats over
     whatever happens to be under it — video, chat, a coloured wash — so it
     carries its own background rather than borrowing one, which is exactly why
     it needs to be listed here: it was a hardcoded white with `--ink` on it,
     and at night that is near-white text on a near-white pill. */
  --fx-pill: rgba(255, 255, 255, 0.95);

  /* The words in a speech bubble over a character's head.
     *
     * Its own name rather than `--ink`, and that is the whole point. `.cast`
     * defines `--ink` too — there it means the outline you draw a cartoon with,
     * `#3a2b33` — and the bubble lives inside `.cast`, so anything in here that
     * said `var(--ink)` picked up the crayon rather than the theme. The dark
     * override for these bubbles did exactly that and therefore did nothing at
     * all: dark drawing ink on a dark bubble, 1.27:1.
     *
     * `.cast` also shadows `--line`. Two names, one scope each; do not reach
     * into the cast with either of them. */
  --say-ink: #4a3540;

  /* What the browser paints for itself: the caret, a date picker, a range
     track, the inside of any control we forget to dress. Without it every
     native control stays in its light-mode costume at night. */
  color-scheme: light;
}

/* ------------------------------------------------------------ dark mode
 *
 * Same room at night, not a different product: the pinks and lavenders stay,
 * everything they sit on goes dark. Set on <html> by the moon button in the
 * chat header and remembered per device, so her phone can be dark while his
 * laptop is not.
 */
:root[data-theme="dark"] {
  --pink-soft: #4b2438;
  --lav-soft: #2f2743;
  --cream: #15111a;
  --ink: #f3e7ef;

  /* These two were left at their light-theme values, and between them they were
     most of what was hard to read at night.
     *
     * `--pink-deep` is the accent on twenty pieces of text — the room title,
     * medal lines, the name on a quoted reply, the reaction count, toast
     * titles. At #d9407a it managed 3.99:1 on a dark card and 2.53:1 on the
     * tinted bubble it sits on most often, both under the 4.5:1 that ordinary
     * text needs. A dark theme wants the accent lighter than the surface, not
     * darker; this is the same hue with the lightness inverted, and it takes
     * those to 8.8:1 and 5.6:1 without touching the light theme.
     *
     * `--ink-soft` is secondary text, and at #a893a4 it read 3.75:1 on a
     * message bubble and 2.74:1 in the calendar. Same fix.
     *
     * Both are used as a background in exactly two places, patched below. */
  --pink-deep: #ff9ec4;
  --ink-soft: #c2b0be;
  --fx-pill: rgba(33, 26, 40, 0.95);
  --say-ink: #f3e7ef;
  --card: #211a28;
  --line: #382c42;
  --surface: #291f31;
  --surface-2: #33243a;
  --input: #1c1622;
  --glow-1: #2a2140;
  --glow-2: #3a2135;
  --shadow: 0 6px 22px rgba(0, 0, 0, 0.4);
  color-scheme: dark;
}

* { box-sizing: border-box; }

html, body {
  height: 100%;
  margin: 0;
  /* Two taps in quick succession must never be read as a double-tap zoom.
     Safari on iPad zooms on the second tap otherwise, so hammering an emoji
     button leaves the page zoomed in and then back out. `manipulation` keeps
     panning and pinch zoom and drops only the double-tap gesture. */
  touch-action: manipulation;
}

body {
  font-family: var(--font);
  color: var(--ink);
  background:
    radial-gradient(1200px 600px at 10% -10%, var(--glow-1), transparent 60%),
    radial-gradient(900px 500px at 100% 0%, var(--glow-2), transparent 55%),
    var(--cream);
  overflow: hidden;
}

/* Colour has to be inherited, not left to the browser default. A button with no
   `color` of its own does not inherit one — it gets the browser's `ButtonText`,
   which is black — so `GIF`, the layout `◫` and the search `✕` were black
   glyphs on a dark lavender circle at night, 1.5:1. The same trap as the inputs
   below, in the place nobody thought to look, because in the light theme black
   on lavender is fine. Buttons that set their own colour are unaffected. */
button { font-family: inherit; cursor: pointer; border: none; color: inherit; }

/* A dark input with black text in it is unreadable. */
input, textarea { font-family: inherit; color: inherit; }

/* Anything you tap repeatedly. The tap highlight and the selection callout are
   what make fast repeated taps look broken on iOS: the button flashes grey and
   the third tap starts selecting the label instead of pressing it. */
button, .file-btn, .pick img {
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  -webkit-touch-callout: none;
  -webkit-user-select: none;
  user-select: none;
}

/* ------------------------------------------------------------ layout */

.app {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 380px;
  gap: 16px;
  /* --kb is the height of the on-screen keyboard, measured in wireChatLog().
     iOS does not shrink the layout viewport when the keyboard opens, so
     without this the composer and the newest messages sit behind it. */
  height: calc(100dvh - var(--kb, 0px));
  padding: 16px;
}

.stage {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-width: 0;
  min-height: 0;
}

.panel {
  display: flex;
  flex-direction: column;
  min-height: 0;
  background: var(--card);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
}

@media (max-width: 900px) {
  .app {
    grid-template-columns: 1fr;
    grid-template-rows: auto minmax(0, 1fr);
    padding: 8px;
    gap: 8px;
  }
  .stage { flex: 0 0 auto; }
}

/* iPad landscape and small laptops. A fixed 380px chat eats a third of an
   1024px iPad, so the column narrows before the layout stacks. */
@media (min-width: 901px) and (max-width: 1200px) {
  .app { grid-template-columns: minmax(0, 1fr) 320px; gap: 12px; padding: 12px; }
}

/* Touch targets. Anything you tap rather than click needs more room, and this
   catches iPad in both orientations regardless of width. */
@media (pointer: coarse) {
  .pick .fav { width: 32px; height: 32px; font-size: 16px; }
  .icon-btn { width: 46px; height: 46px; }
  .icon-mini, .fs-btn { width: 40px; height: 40px; }
  .viewers { right: 62px; }
  .quick button { padding: 8px 13px; }
  .chip-row button { width: 44px; height: 44px; }
}

/* ------------------------------------------------------------ video */

.video-wrap {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  background: #2b1d25;
  border-radius: var(--radius);
  overflow: hidden;
  box-shadow: var(--shadow);
}

@media (max-width: 900px) {
  .video-wrap {
    aspect-ratio: 16 / 10;
    flex: 0 0 auto;
    /* Cap the height, or the aspect ratio wins and pushes the chat off screen.
       A 16:10 box on an 820px iPad portrait wants 512px; on a phone in
       landscape it wants more height than the whole viewport has. The video is
       object-fit: contain, so capping letterboxes rather than crops. */
    max-height: 45dvh;
    margin: 0 auto;
  }
}

/* Landscape phones, and iPads with the keyboard up. Almost no vertical room, so
   the video shrinks further and the chat keeps the space. */
@media (max-width: 900px) and (max-height: 520px) {
  .video-wrap { max-height: 38dvh; }
  .xp { padding: 8px 10px; }
  .quick { display: none; }
}

.video-wrap video {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  background: #2b1d25;
}

.video-wrap video.mirrored { transform: scaleX(-1); }

/* The corner: whoever is not in the big picture. Only on screen when both
   cameras are running, so it never covers anything on a one-way stream.

   It is a class rather than an element, because the swap moves the corner from
   one video to the other and never moves a track — see render() in stage.js for
   why that distinction is load bearing.

   Top right, under the fullscreen button rather than beside it, because the
   other three corners are taken: LIVE sits top left, the love bar sits bottom
   centre, and the bottom is where the two cartoon characters stand. It crops to
   fill rather than letterboxing, since at this size the bars would be most of
   it. */
/* `.video-wrap video` sizes the picture to fill the box, and it is the more
   specific of the two selectors, so this one has to name the element as well as
   the class or the corner is full width and full height. */
.video-wrap video.corner {
  position: absolute;
  top: 56px;
  right: 12px;
  z-index: 2;
  width: min(26%, 132px);
  height: auto;
  aspect-ratio: 3 / 4;
  object-fit: cover;
  border-radius: 14px;
  border: 2px solid rgba(255, 255, 255, 0.55);
  box-shadow: 0 6px 18px rgba(43, 29, 37, 0.4);
  background: #2b1d25;
  cursor: pointer;
}

/* A front camera is a mirror in either position, and only your own is. */
.video-wrap video.selfie.mirrored { transform: scaleX(-1); }

/* `display: block` on the rule above beats the hidden attribute otherwise, and
   both elements are on the page from the start now: an element with no track in
   it is hidden rather than absent. */
.video-wrap video[hidden] { display: none; }

.offline {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 10px;
  text-align: center;
  color: #ffe4ef;
  padding: 24px;
  /* Purely informational, and it covers the whole video area including the
     overlay buttons. Without this it swallows every click on them, and in
     video-only mode that leaves no way back to the chat. */
  pointer-events: none;
}

.offline .big { font-size: 46px; }
.offline p { margin: 0; opacity: 0.85; max-width: 32ch; line-height: 1.5; }

/* Above .offline regardless of DOM order, so the controls stay clickable even
   if another overlay is added to the video later. */
.badge-live, .viewers, .fs-btn { z-index: 2; }

.badge-live {
  position: absolute;
  top: 12px;
  left: 12px;
  display: none;
  align-items: center;
  gap: 7px;
  padding: 6px 12px;
  border-radius: 999px;
  background: rgba(217, 64, 122, 0.92);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.08em;
}

.badge-live.on { display: inline-flex; }
.badge-live::before {
  content: "";
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--card);
  animation: pulse 1.6s ease-in-out infinite;
}

@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }

.viewers {
  position: absolute;
  top: 12px;
  right: 56px;
  padding: 6px 12px;
  border-radius: 999px;
  background: rgba(43, 29, 37, 0.6);
  color: #ffe4ef;
  font-size: 12px;
}

.fs-btn {
  position: absolute;
  top: 12px;
  right: 12px;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: rgba(43, 29, 37, 0.6);
  color: #ffe4ef;
  font-size: 15px;
  line-height: 1;
  display: grid;
  place-content: center;
  backdrop-filter: blur(2px);
}

.fs-btn:hover { background: rgba(217, 64, 122, 0.85); }

.icon-mini {
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--lav-soft);
  font-size: 15px;
  display: grid;
  place-content: center;
}

.icon-mini:hover { background: #e0d0ff; }

/* The bell, while notifications are actually on. There is no other place in
   the room that says so, and "did I turn that on?" is a question worth being
   able to answer from across the header. */
.icon-mini.bell-on {
  background: var(--pink-soft);
  box-shadow: 0 0 0 2px var(--pink) inset;
}

/* A header toggle that is currently doing something: the muted stream, and the
   pencil while the board has the screen. Flagged the same way the bell is,
   because "did I leave that on?" is the question both of them answer — and a
   muted room is otherwise indistinguishable from a quiet one. */
.icon-mini.off,
.icon-mini.on {
  background: var(--pink-soft);
  box-shadow: 0 0 0 2px var(--pink-deep) inset;
}

/* The four steps in the "add to home screen" sheet. Numbered, roomy, and read
   on a phone held in one hand. */
.push-steps {
  margin: 0 0 14px;
  padding-left: 22px;
  line-height: 1.65;
  font-size: 14px;
}

.push-steps li { margin-bottom: 8px; }
.push-steps b { color: var(--pink-deep); }
.push-close { text-align: center; margin: 18px 0 0; }

/* ------------------------------------------------------------ xp bar */

.xp {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 14px;
  background: var(--card);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
}

.xp-emoji { font-size: 26px; line-height: 1; }
.xp-body { flex: 1; min-width: 0; }

.xp-top {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 8px;
  font-size: 13px;
}

.xp-name { font-weight: 800; color: var(--pink-deep); text-transform: lowercase; }
.xp-next { color: var(--ink-soft); font-size: 12px; }

.xp-track {
  height: 9px;
  margin-top: 7px;
  border-radius: 999px;
  background: var(--lav-soft);
  overflow: hidden;
}

.xp-fill {
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--lav), var(--pink));
  transition: width 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.medals-btn {
  padding: 9px 13px;
  border-radius: 999px;
  background: var(--lav-soft);
  color: #6a4bb0;
  font-weight: 700;
  font-size: 13px;
  white-space: nowrap;
}

.medals-btn:hover { background: #e0d0ff; }

/* ------------------------------------------------------------ chat */

.chat-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 14px 16px;
  border-bottom: 1px solid var(--line);
}

/* Takes the slack, so the buttons stay grouped on the right instead of being
   spread evenly across a wide header by space-between. */
.chat-id { flex: 1; min-width: 0; }

.chat-title { font-weight: 800; color: var(--pink-deep); }
.chat-sub { font-size: 12px; color: var(--ink-soft); display: flex; gap: 7px; align-items: baseline; }

/* The ghost clock, beside the connection line. Counting only ever means one
   thing here, so it turns red and stays red until he looks at the room. */
.ghost-line { font-weight: 800; white-space: nowrap; }
.ghost-line.away { color: #c0392b; }

/* Wraps the log purely so the "new messages" pill can float over the bottom of
   it. Anchoring the pill to .panel instead would put it at a fixed offset from
   the panel's edge, and the composer below it changes height as you type. */
.chat-body {
  position: relative;
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}

.jump-latest {
  position: absolute;
  left: 50%;
  bottom: 12px;
  transform: translateX(-50%);
  display: none;
  padding: 8px 16px;
  border-radius: 999px;
  background: linear-gradient(135deg, var(--pink), var(--lav));
  color: #fff;
  font-size: 12.5px;
  font-weight: 800;
  white-space: nowrap;
  box-shadow: 0 6px 18px rgba(217, 64, 122, 0.35);
  /* Over the cast (38), under the sheets (40) and the peek (45).
     `.chat-body` is `position: relative` with no z-index, so it is not a
     stacking context and this number competes with the cast's directly. The
     characters stand on the floor, and the floor is the top of the composer —
     which is exactly where this button sits. At the old `z-index: 5` a toon
     standing in front of it swallowed the tap on its painted pixels, which
     never mattered while the pill only appeared for a new message and matters
     now that it is the way back down. */
  z-index: 39;
  animation: rise 0.35s cubic-bezier(0.2, 1.4, 0.4, 1);
}

/* Its own keyframes rather than the toast's `pop`: that one animates to
   `transform: none`, which would drop the translateX(-50%) that centres this. */
@keyframes rise {
  from { opacity: 0; transform: translate(-50%, 10px); }
  to { opacity: 1; transform: translate(-50%, 0); }
}

.jump-latest.show { display: block; }

/* Scrolled up with nothing new waiting: the button is only an arrow.
   A pink pill is an announcement, and there is nothing to announce — it is just
   the way back down. 42px so a thumb on a tablet can land on it. */
.jump-latest.bare {
  width: 42px;
  height: 42px;
  padding: 0;
  font-size: 18px;
  line-height: 42px;
  text-align: center;
  background: var(--card);
  color: var(--ink);
  border: 1px solid var(--line);
  box-shadow: 0 4px 14px rgba(74, 53, 64, 0.18);
}

.chat-log {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 14px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  scrollbar-width: thin;
}

/* A row so the avatar sits beside the bubble; the text stacks inside .msg-col. */
.msg { display: flex; align-items: flex-end; gap: 7px; max-width: 88%; }

/* A line that is two and a half screens away from the fold is not laid out,
   not painted, and any GIF in it is not decoding. room.js marks these, and
   only ever after the line has been rendered for real: the exact width and
   height it had are written into `contain-intrinsic-size` as inline style, so
   the space it takes up does not change by a pixel when it is skipped.
   The size must never be left to the contents here — that was round eighteen,
   where iOS collapsed every cell in the picker. */
.msg.far {
  content-visibility: auto;
  /* And this line is the whole thing working at all.
     The log is a column flexbox that overflows, so every line in it is a
     shrinkable flex item; what normally stops one collapsing is its automatic
     minimum size, which is the size of its contents. A skipped line has no
     contents, so that floor disappears and the flex algorithm shrinks it to
     nothing: the remembered height is set, and the row still measures zero.
     Measured here at 0px with an explicit `height: 60px` on it, which is how
     obvious it is once you look. */
  flex-shrink: 0;
}
.msg-col { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.msg.mine { align-self: flex-end; }
.msg.mine .msg-col { align-items: flex-end; }

.avatar {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  display: grid;
  place-content: center;
  font-size: 15px;
  line-height: 1;
  font-weight: 800;
  color: #fff;
  overflow: hidden;
  user-select: none;
  box-shadow: 0 1px 4px rgba(217, 64, 122, 0.18);
}

.avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }

.avatar.big {
  width: 72px;
  height: 72px;
  font-size: 34px;
  box-shadow: 0 4px 14px rgba(217, 64, 122, 0.25);
}

.msg-who {
  font-size: 11px;
  font-weight: 700;
  color: var(--ink-soft);
  padding: 0 4px;
}

.bubble {
  padding: 9px 13px;
  border-radius: 16px;
  background: var(--lav-soft);
  line-height: 1.45;
  word-wrap: break-word;
  overflow-wrap: anywhere;
  white-space: pre-wrap;
}

.msg.mine .bubble {
  background: linear-gradient(135deg, var(--pink), var(--lav));
  color: #fff;
}

.bubble img {
  display: block;
  max-width: 220px;
  max-height: 220px;
  width: auto;
  height: auto;
  border-radius: 12px;
}

.msg.media .bubble { padding: 5px; background: transparent; }
.bubble img { -webkit-touch-callout: none; }

/* A link somebody pasted. Underlined rather than only coloured, because on
   your own lines the bubble is already pink and a colour alone would not say
   "this opens something".

   `-webkit-touch-callout: none` for the same reason the images have it: iOS
   answers a long press on a link with its own preview sheet, and a long press
   on a line in this room means reply. The tap itself still opens the link. */
.bubble-link {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-thickness: 1px;
  -webkit-touch-callout: none;
  /* A pasted URL is one long unbreakable word; the bubble already breaks
     anywhere, this keeps the link itself from pushing the bubble wider. */
  overflow-wrap: anywhere;
}

.bubble-link:hover { text-decoration-thickness: 2px; }
.msg:not(.mine) .bubble-link { color: var(--pink-deep); }

/* A photo is not a sticker. 220px is the right size for something whose whole
   job is to be a reaction, and the wrong size for a picture of a person, so
   these get a bigger box and open full size when they are tapped. Capped
   against the viewport as well: a portrait photo at 360px tall is most of a
   phone screen already. */
.msg.photo .bubble img {
  max-width: min(320px, 74vw);
  max-height: min(400px, 52dvh);
  cursor: zoom-in;
}

/* A video in the log is a still with a badge on it, not a player: the reasons
   are in `videoEl()`. Sized like a photo, because that is what it is until it
   is tapped. `.vid-bare` is the third fallback, where neither a poster nor the
   video itself would paint anything -- it still has to be a tappable rectangle,
   because a file this browser cannot decode is often perfectly fine on the
   phone it was going to. */
.msg.video .bubble .vid {
  position: relative;
  display: block;
  max-width: min(320px, 74vw);
  max-height: min(400px, 52dvh);
  border-radius: 12px;
  overflow: hidden;
  cursor: pointer;
}

.msg.video .bubble .vid img {
  display: block;
  width: 100%;
  height: 100%;
  max-width: min(320px, 74vw);
  max-height: min(400px, 52dvh);
  object-fit: cover;
  border-radius: 12px;
  -webkit-touch-callout: none;
}

.msg.video .bubble .vid.vid-bare {
  display: grid;
  place-items: center;
  min-width: 180px;
  min-height: 120px;
  background: linear-gradient(135deg, rgba(255, 122, 168, 0.22), rgba(178, 150, 255, 0.22));
}

.msg.video .bubble .vid.vid-bare::before {
  content: "\1F3A5";
  font-size: 34px;
  opacity: 0.85;
}

/* Bottom left rather than centred: a centred play button lands on the face in
   most of these, and the badge is a label saying "this one moves" plus how long
   it is, not a control. The tap target is the whole rectangle. */
.vid-play {
  position: absolute;
  left: 8px;
  bottom: 8px;
  padding: 3px 8px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.2px;
  pointer-events: none;
}

/* The upload percentage, in a button built for one emoji. */
.icon-btn.busy {
  font-size: 12px;
  font-weight: 700;
}

/* The lightbox: the picker's peek with nothing in it but the picture. Wider
   than the sticker card, which is sized for a 320px square. */
.peek-card.plain {
  width: auto;
  max-width: min(96vw, 1000px);
  padding: 12px;
  gap: 8px;
}

.peek-card.plain video {
  display: block;
  max-width: min(94vw, 1000px);
  max-height: 78dvh;
  border-radius: 12px;
  background: #000;
}

/* What replaces the player when the codec is not one this browser has. Says
   which half of the problem it is, and offers the way out. */
.peek-sorry {
  display: grid;
  gap: 6px;
  justify-items: center;
  padding: 28px 18px;
  text-align: center;
}

.peek-sorry-why {
  font-size: 12px;
  opacity: 0.7;
}

.peek-save {
  margin-top: 4px;
  color: var(--pink-deep);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.peek-card.plain img {
  max-width: 100%;
  max-height: min(74dvh, 900px);
  background: transparent;
}

/* ---------------------------------------------------------- replies */

/* The quoted line, inside the bubble above the reply itself. */
.quote {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  margin-bottom: 5px;
  padding: 6px 9px;
  border: none;
  border-left: 3px solid var(--pink);
  border-radius: 8px;
  background: rgba(255, 122, 168, 0.13);
  font: inherit;
  text-align: left;
  color: inherit;
}

.quote:hover { background: rgba(255, 122, 168, 0.22); }
.quote img { width: 30px; height: 30px; border-radius: 6px; object-fit: cover; flex: 0 0 auto; }
.quote-col { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.quote-who { font-size: 11px; font-weight: 800; color: var(--pink-deep); }

.quote-text {
  display: block;
  /* Capped so a long quote cannot stretch the bubble to the full 88%. */
  max-width: 220px;
  font-size: 12.5px;
  line-height: 1.3;
  color: var(--ink-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Your own bubble is a pink gradient, so the quote needs to sit on light. */
.msg.mine .quote { background: rgba(255, 255, 255, 0.24); border-left-color: #fff; }
.msg.mine .quote:hover { background: rgba(255, 255, 255, 0.36); }
.msg.mine .quote-who { color: #fff; }
.msg.mine .quote-text { color: rgba(255, 255, 255, 0.88); }

.msg.media .quote { background: rgba(255, 122, 168, 0.13); }
.msg.mine.media .quote { background: rgba(255, 122, 168, 0.13); border-left-color: var(--pink); }
.msg.mine.media .quote-who { color: var(--pink-deep); }
.msg.mine.media .quote-text { color: var(--ink-soft); }

/* Reply and react in one click for mouse users. Touch has the long press, and
   permanent buttons beside every line there would be clutter you never aim at. */
.msg-tools {
  display: none;
  flex: 0 0 auto;
  gap: 4px;
  opacity: 0;
  transition: opacity 0.15s;
}

.msg-tool {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--lav-soft);
  color: var(--ink-soft);
  font-size: 13px;
  display: grid;
  place-content: center;
  padding: 0;
}

.msg.mine .msg-tools { order: -1; }
.msg-tool:hover { background: var(--pink-soft); color: var(--pink-deep); }

@media (hover: hover) and (pointer: fine) {
  .msg-tools { display: flex; }
  .msg:hover .msg-tools { opacity: 1; }
}

/* ------------------------------------------------- reactions on a line */

/* Under the bubble rather than inside it, so a row of emoji wraps across the
   column instead of stretching the bubble it belongs to. */
.msg-rx { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 2px; }
.msg-rx[hidden] { display: none; }
.msg.mine .msg-rx { justify-content: flex-end; }

.rx {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  padding: 2px 7px;
  border-radius: 999px;
  background: var(--card);
  border: 1px solid var(--line);
  font-size: 12px;
  line-height: 1.5;
  box-shadow: 0 1px 3px rgba(217, 64, 122, 0.1);
}

.rx:hover { border-color: var(--pink); }
.rx.mine { background: var(--pink-soft); border-color: var(--pink); }
.rx-n { font-size: 11px; font-weight: 800; color: var(--ink-soft); }
.rx.mine .rx-n { color: var(--pink-deep); }
.rx-n:empty { display: none; }

/* The emoji row at the top of the action menu. */
.menu-rx {
  display: flex;
  gap: 2px;
  padding: 2px 2px 6px;
  margin-bottom: 4px;
  border-bottom: 1px solid var(--line);
}

.menu-rx button {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: transparent;
  font-size: 19px;
  line-height: 1;
  display: grid;
  place-content: center;
  padding: 0;
}

.menu-rx button:hover { background: var(--pink-soft); }
.menu-rx button.on { background: var(--pink-soft); box-shadow: inset 0 0 0 2px var(--pink); }

@media (pointer: coarse) {
  .menu-rx button { width: 42px; height: 42px; font-size: 21px; }
  .rx { padding: 4px 9px; font-size: 13px; }
}

/* Held open in the action menu. */
.msg.held .bubble { filter: brightness(0.94); }

/* Jumped to from a quote. */
.msg.flash { animation: flash 1.3s ease; border-radius: 16px; }

@keyframes flash {
  0%, 100% { background: transparent; }
  20%, 55% { background: rgba(255, 122, 168, 0.28); }
}

/* Long pressing a bubble on touch must not start a text selection or pop the
   native callout. Copy lives in the action menu instead, the way it does in
   every messaging app. Selection stays available with a mouse. */
@media (pointer: coarse) {
  .msg {
    -webkit-user-select: none;
    user-select: none;
    -webkit-touch-callout: none;
  }
}

/* --------------------------------------------------------- action menu */

.menu-wrap { position: fixed; inset: 0; z-index: 70; }

.msg-menu {
  position: fixed;
  min-width: 154px;
  padding: 6px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--card);
  box-shadow: 0 14px 40px rgba(217, 64, 122, 0.3);
  animation: menu-pop 0.16s ease-out;
}

@keyframes menu-pop {
  from { opacity: 0; transform: scale(0.93); }
  to { opacity: 1; transform: none; }
}

.msg-menu button {
  padding: 11px 14px;
  border-radius: 10px;
  background: transparent;
  color: var(--ink);
  font-size: 14px;
  font-weight: 700;
  text-align: left;
  white-space: nowrap;
}

.msg-menu button:hover { background: var(--pink-soft); }

.msg.system, .msg.award {
  align-self: center;
  align-items: center;
  max-width: 92%;
  text-align: center;
}

.msg.system .bubble {
  background: transparent;
  color: var(--ink-soft);
  font-size: 12px;
  font-style: italic;
}

.msg.award .bubble {
  background: linear-gradient(135deg, var(--surface-2), #f3ebff);
  border: 1px dashed var(--pink);
  color: var(--pink-deep);
  font-weight: 700;
  font-size: 13px;
}

/* ------------------------------------------------------------ composer */

.composer {
  border-top: 1px solid var(--line);
  padding: 10px;
  padding-bottom: max(10px, env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  gap: 8px;
  background: var(--card);
}

/* The line you are about to answer, sitting above the box you type into. */
.reply-chip {
  display: none;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: var(--surface-2);
}

.reply-chip.show { display: flex; }
.reply-bar { flex: 0 0 3px; align-self: stretch; border-radius: 3px; background: var(--pink); }
.reply-body { flex: 1; min-width: 0; }
.reply-who { font-size: 11.5px; font-weight: 800; color: var(--pink-deep); }

.reply-text {
  font-size: 12.5px;
  color: var(--ink-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.reply-thumb { flex: 0 0 auto; width: 34px; height: 34px; border-radius: 8px; object-fit: cover; }

.reply-x {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: transparent;
  color: var(--ink-soft);
  font-size: 13px;
}

.reply-x:hover { background: var(--pink-soft); color: var(--pink-deep); }

.composer-row { display: flex; gap: 8px; align-items: flex-end; }

/* The box grows with the text, and nothing measures anything to do it.
 *
 * `.say-wrap` is a single grid cell with two things in it: the textarea, and an
 * ::after carrying a hidden copy of the same text. The copy is what the row is
 * sized to, so the browser works the height out in the layout it was doing
 * anyway. What this replaces was `height = auto` plus a read of `scrollHeight`
 * on every keystroke — a synchronous layout of the entire page, chat log and
 * all, per letter — which is what made typing on the iPad feel broken.
 *
 * The two must stay the same shape: same font, same padding, same border, same
 * wrapping. Anything that changes one has to change both, which is why the
 * dimensions live in custom properties (phone.css overrides them).
 */
.say-wrap {
  --say-pad-y: 11px;
  --say-pad-x: 14px;
  --say-min: 42px;
  flex: 1;
  min-width: 0;
  display: grid;
}

.composer textarea,
.say-wrap::after {
  grid-area: 1 / 1;
  min-height: var(--say-min);
  max-height: 120px;
  padding: var(--say-pad-y) var(--say-pad-x);
  border: 1px solid transparent;
  border-radius: 20px;
  font-family: inherit;
  font-size: 16px; /* 16px stops iOS Safari zooming on focus */
  line-height: 1.35;
  box-sizing: border-box;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

.composer textarea {
  resize: none;
  outline: none;
  overflow-y: auto;
  border-color: var(--line);
  background: var(--input);
}

/* The trailing space is what keeps a line you have just broken from collapsing,
   so the box grows as the caret moves down rather than after the next letter.
   It also caps the row: past 120px this stops growing and the textarea scrolls
   inside itself, the same as the old `Math.min(scrollHeight, 120)`. */
.say-wrap::after {
  content: attr(data-value) " ";
  visibility: hidden;
  overflow: hidden;
}

.composer textarea:focus { border-color: var(--pink); }

.icon-btn {
  flex: 0 0 auto;
  width: 42px;
  height: 42px;
  border-radius: 50%;
  background: var(--lav-soft);
  font-size: 17px;
  display: grid;
  place-content: center;
}

.icon-btn:hover { background: #e0d0ff; }
.icon-btn.send { background: linear-gradient(135deg, var(--pink), var(--lav)); color: #fff; }

.quick { display: flex; gap: 6px; flex-wrap: wrap; }

.quick button {
  padding: 5px 10px;
  border-radius: 999px;
  background: var(--surface-2);
  font-size: 16px;
  line-height: 1;
}

.quick button:hover { background: var(--pink-soft); }

/* ------------------------------------------------------------ pickers */

.picker {
  display: none;
  flex-direction: column;
  gap: 8px;
  /* A real height, not just a cap: the grid then fills and scrolls, which is
     the "longer list" — max-height alone left the panel short on a tablet. */
  height: min(74dvh, 680px);
  padding: 10px;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--input);
}

.picker.open { display: flex; }

.picker-head {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
}

.picker-x {
  flex: 0 0 auto;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--card);
  border: 1px solid var(--line);
  font-size: 15px;
  line-height: 1;
  color: var(--ink-soft);
  padding: 0;
}

.picker-x:hover { background: var(--pink-soft); color: var(--ink); }

.picker-tabs {
  display: flex;
  gap: 5px;
  flex: 1 1 auto;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
}

.picker-tabs::-webkit-scrollbar { display: none; }

.picker-tabs button {
  flex: 0 0 auto;
  padding: 7px 12px;
  border-radius: 999px;
  background: var(--card);
  border: 1px solid var(--line);
  font-size: 12.5px;
  font-weight: 700;
  color: var(--ink-soft);
  white-space: nowrap;
}

.picker-tabs button.on {
  background: linear-gradient(135deg, var(--pink), var(--lav));
  border-color: transparent;
  color: #fff;
}

/* The one input in this file that had no `background`, and at night that made
   it the one you could not type into: with nothing set, the field keeps the
   browser's default white, while the text colour is inherited from the room and
   in dark mode that is near-white. White on white. The placeholder stayed
   readable, which is why it looked like an empty search box rather than a
   broken one. Everything else here already carries `var(--input)`. */
.picker input {
  flex: 0 0 auto;
  padding: 9px 12px;
  border: 1px solid var(--line);
  border-radius: 999px;
  font-size: 16px; /* 16px stops iOS Safari zooming on focus */
  outline: none;
  background: var(--input);
  color: var(--ink);
}

/* One grey for every field. The browser's default placeholder is tuned for a
   white background and is close to invisible on a dark one. */
input::placeholder,
textarea::placeholder { color: var(--ink-soft); opacity: 1; }

.picker input:focus { border-color: var(--pink); }
.picker input[hidden] { display: none; }

.picker-chips {
  display: flex;
  gap: 5px;
  flex: 0 0 auto;
  overflow-x: auto;
  scrollbar-width: none;
}
.picker-chips::-webkit-scrollbar { display: none; }
.picker-chips[hidden] { display: none; }
.picker-chips button {
  flex: 0 0 auto;
  padding: 5px 10px;
  border-radius: 999px;
  background: var(--card);
  border: 1px solid var(--line);
  font-size: 12px;
  font-weight: 700;
  color: var(--ink-soft);
  white-space: nowrap;
}
.picker-chips button:hover { border-color: var(--pink); color: var(--ink); }
/* 🦋 ours is a toggle, not a word to search, so it has to look pressed while it
 * is on: the grid it produces (only the stickers drawn for this room) is a
 * short list, and a short list with no explanation reads as a broken search.
 * Ink rather than white on the pink, which is the same #ff7aa8 in both themes
 * and is a mid pink: white on it is 2.3:1. */
.picker-chips button.on {
  background: var(--pink);
  border-color: var(--pink);
  color: #40101f;
}

.picker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
  gap: 6px;
  flex: 1 1 auto;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  min-height: 280px;
  align-content: start;
}

/* A cell is a fixed box with nothing clever in it, and that is the whole point.
 *
 * **`content-visibility: auto` used to be here and has been taken out.** It was
 * a real optimisation: a cell scrolled out of the grid costs nothing to render,
 * which matters when a full grid is 13MB of animated GIF and every one of them
 * decodes for as long as it is rendered. But it hands the height of the box to
 * size containment, and iOS Safari gets that wrong here in a way nothing else
 * does. Cells collapse to ten-pixel bands with the stars floating off them,
 * different ones each time you scroll, on every tab. It was reported twice,
 * once as "an empty tab" (a grid of zero-height cells looks exactly like one)
 * and once with screenshots of the stacking.
 *
 * What is left is enough. `PAGE = 48` in room.js means the DOM holds a page at
 * a time rather than four hundred results, `loading="lazy"` means the ones
 * below the fold are never even fetched, and `decoding="async"` keeps the
 * decode off the main thread. Those were always doing most of the work; this
 * was the part that could break the layout, and it did.
 *
 * If it is ever put back, the rule is that the box must not depend on its
 * contents: a pixel height that `contain-intrinsic-size` matches exactly, never
 * an `aspect-ratio`, and never `height: 100%` on the image inside it. */
.pick {
  position: relative;
  cursor: pointer;
  /* A grid item's automatic minimum size is its content's, and a cell holding a
     600px-wide gif would otherwise refuse to be narrower than 600px. */
  min-width: 0;
}

/* `contain`, not `cover`: the fit is the whole fix. Cover filled the box by
   cutting the picture, which is what "i only see the top part" was. Contain
   fits the whole thing inside a box whose size never moves. */
.pick img {
  display: block;
  width: 100%;
  height: 92px;
  object-fit: contain;
  border-radius: 10px;
  cursor: pointer;
  background: var(--lav-soft);
  transition: transform 0.12s ease;
}

/* Stickers get a tinted tile, because a transparent drawing on the page
   background does not read as an object you can pick up. Gifs bring their own
   frame and do not want one. */
.pick.sticker img {
  padding: 3px;
  background: var(--surface-2);
}

.pick:hover img { outline: 2px solid var(--pink); }

/* Held, and about to open. The cell answers the finger before the peek does,
   or a 450ms hold feels like nothing happening. */
.pick:active img { transform: scale(0.96); }

/* Always visible rather than hover-only: there is no hover on a phone, and a
   star you cannot find is a star you never use. */
.pick .fav {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: rgba(43, 29, 37, 0.55);
  color: #fff;
  font-size: 14px;
  line-height: 1;
  display: grid;
  place-content: center;
  padding: 0;
  backdrop-filter: blur(2px);
}

.pick .fav.on { background: var(--pink); color: #fff; }
.pick .fav:hover { background: var(--pink-deep); }

.picker-note { font-size: 12px; color: var(--ink-soft); text-align: center; padding: 6px; }

/* ------------------------------------------------------------- the peek
 *
 * One result, held, at the size of the screen. Above the picker rather than
 * inside it, because the picker on a phone is already as tall as it can be.
 */
.peek {
  position: fixed;
  inset: 0;
  z-index: 45;
  display: grid;
  place-content: center;
  padding: 20px;
  background: rgba(43, 29, 37, 0.62);
  backdrop-filter: blur(4px);
  animation: peek-in 0.16s ease;
}

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

.peek-card {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  width: min(420px, 100%);
  padding: 16px;
  border-radius: 22px;
  background: var(--card);
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
}

/* Height first: a portrait gif in a box sized by width is a thing you scroll
   past rather than look at, and the whole point of holding it is to see it. */
.peek-card img {
  display: block;
  max-width: 100%;
  max-height: min(52dvh, 420px);
  border-radius: 14px;
  background: var(--lav-soft);
}

.peek-card img.sticker { background: var(--surface-2); padding: 6px; }

.peek-cap {
  font-size: 13px;
  font-weight: 700;
  color: var(--ink-soft);
  text-align: center;
  overflow-wrap: anywhere;
}

/*
 * The naming box, standing exactly where the caption was.
 *
 * Replacing the caption rather than appearing under it is deliberate: the card
 * is already the height of a picture on a phone, and a row that appears pushes
 * the buttons under the keyboard the moment it opens.
 */
.peek-name {
  width: 100%;
  padding: 9px 12px;
  border-radius: 12px;
  border: 1px solid var(--pink);
  background: var(--surface-2);
  color: var(--ink);
  font: inherit;
  font-size: 15px;
  font-weight: 700;
  text-align: center;
}

/* 16px would stop iOS zooming the page on focus, but this box sits inside a
   sheet that is already the size of the screen, so the zoom has nowhere to go
   and the weight reads better at 15. */
.peek-name:focus { outline: 2px solid var(--pink); outline-offset: 1px; }

.peek-row { display: flex; gap: 10px; width: 100%; }

.peek-row button {
  flex: 1 1 auto;
  padding: 13px 10px;
  border-radius: 999px;
  font-size: 14px;
  font-weight: 800;
}

.peek-fav {
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--ink-soft);
}

.peek-fav.on { background: var(--pink); border-color: transparent; color: #fff; }

.peek-send {
  background: linear-gradient(135deg, var(--pink), var(--lav));
  color: #fff;
}

/* Only ever on our own stickers, so it is the third button on a row built for
   two: narrower than the others, because saving and sending are what this card
   is for and naming is the thing you do once. */
.peek-name-btn {
  flex: 0 1 auto;
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--ink-soft);
}

/*
 * The way back out, and the narrowest button on the card.
 *
 * A bin and no word until it is armed: it sits on a row whose job is saving and
 * sending, and the one control that takes something away should not be as easy
 * to hit as the two that do not. Pressed once it says "sure?" and turns pink,
 * which is both the confirmation and the reason it is suddenly wider.
 */
.peek-del {
  flex: 0 0 auto;
  /* A bin glyph is about half the width of a word, which left a 31px target on
     a row of 100px ones. Min-width rather than more padding, so arming it and
     saying "sure?" still grows the button rather than moving it. */
  min-width: 46px;
  padding: 13px 12px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--ink-soft);
}

.peek-del.armed { background: var(--pink); border-color: transparent; color: #fff; }
.peek-del[disabled] { opacity: 0.5; }

.peek-x {
  position: absolute;
  top: -12px;
  right: -12px;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  background: var(--card);
  border: 1px solid var(--line);
  box-shadow: var(--shadow);
  font-size: 15px;
  color: var(--ink-soft);
  padding: 0;
}

@media (prefers-reduced-motion: reduce) {
  .peek { animation: none; }
  .pick img { transition: none; }
}

/* iPad and anything else stacked. The panel is the full width of the screen
   here, so 84px cells were eight columns of postage stamp. */
@media (max-width: 900px) {
  .picker { height: 66dvh; }
  .picker-grid { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); min-height: 220px; }
  .pick img { height: 104px; }
}

/* ------------------------------------------------------------ medals */

.sheet {
  position: fixed;
  inset: 0;
  display: none;
  place-content: center;
  background: rgba(74, 53, 64, 0.45);
  backdrop-filter: blur(3px);
  z-index: 40;
  padding: 20px;
}

.sheet.open { display: grid; }

.sheet-card {
  width: min(560px, 100%);
  max-height: 80dvh;
  overflow-y: auto;
  background: var(--card);
  border-radius: 22px;
  padding: 22px;
  box-shadow: 0 20px 60px rgba(217, 64, 122, 0.3);
}

.sheet-card h2 { margin: 0 0 4px; color: var(--pink-deep); }
.sheet-card .hint { margin: 0 0 16px; color: var(--ink-soft); font-size: 13px; }

.medal-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 10px;
}

.medal {
  padding: 13px;
  border-radius: 14px;
  background: var(--surface);
  border: 1px solid var(--line);
  text-align: center;
}

.medal.locked { opacity: 0.4; filter: grayscale(1); }
.medal .m-emoji { font-size: 28px; }
.medal .m-title { font-weight: 800; font-size: 13px; margin-top: 4px; }
.medal .m-cap { font-size: 11px; color: var(--ink-soft); margin-top: 3px; line-height: 1.35; }

/* ------------------------------------------------------------ settings */

.set-row { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
.set-fields { flex: 1; min-width: 0; }

.set-label {
  display: block;
  font-size: 12px;
  font-weight: 800;
  color: var(--ink-soft);
  margin: 14px 0 6px;
  text-transform: lowercase;
}

.set-row .set-label { margin-top: 0; }

#set-name {
  width: 100%;
  padding: 11px 14px;
  border: 1px solid var(--line);
  border-radius: 14px;
  font-size: 16px;
  outline: none;
  background: var(--input);
}

#set-name:focus { border-color: var(--pink); }

.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }

.chip-row button {
  width: 40px;
  height: 40px;
  border-radius: 12px;
  background: var(--surface);
  border: 2px solid transparent;
  font-size: 20px;
  line-height: 1;
  display: grid;
  place-content: center;
}

.chip-row button:hover { background: var(--pink-soft); }
.chip-row button.on { border-color: var(--pink-deep); background: var(--pink-soft); }
.chip-row .swatch { border: 2px solid rgba(0, 0, 0, 0.06); }
.chip-row .swatch.on { border-color: var(--ink); }

.set-photo { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }

.file-btn {
  padding: 9px 14px;
  border-radius: 999px;
  background: var(--lav-soft);
  color: #6a4bb0;
  font-weight: 700;
  font-size: 13px;
  cursor: pointer;
}

.file-btn:hover { background: #e0d0ff; }

/* The file input behind "choose photo". Off the screen but still laid out and
   still clickable: `hidden` and `display: none` are what iOS refuses to open,
   even from a script inside the tap that asked for it. */
.file-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

/* The small picture switch: the one control in this sheet that is not part of
   the profile form. It applies on press and the cancel button does not cover
   it, which its caption says; see LITE_KEY in stage.js for what it does. */
.lite-btn {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  margin-top: 4px;
  padding: 10px 12px;
  text-align: left;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--input);
  color: var(--ink);
  cursor: pointer;
}

/* `min-width: 0` or the caption, which is a long unbreakable flex item by
   default, pushes the pill off the edge of the card on a phone. */
.lite-col { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; }
.lite-title { font-weight: 800; font-size: 13.5px; }
.lite-cap { font-size: 11.5px; line-height: 1.35; color: var(--ink-soft); }

/* `flex: 0 0 auto` is load-bearing: the caption beside it wraps to two or three
   lines, and a track that can be squeezed reads as a broken switch rather than
   an off one. */
.lite-pip {
  position: relative;
  flex: 0 0 auto;
  width: 44px;
  height: 26px;
  border-radius: 999px;
  background: var(--line);
  transition: background 0.18s ease;
}

.lite-pip::after {
  content: "";
  position: absolute;
  top: 3px;
  left: 3px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--card);
  box-shadow: 0 1px 3px rgba(74, 53, 64, 0.35);
  transition: transform 0.18s ease;
}

.lite-btn[aria-pressed="true"] .lite-pip { background: var(--pink); }
.lite-btn[aria-pressed="true"] .lite-pip::after { transform: translateX(18px); }

.set-status { min-height: 18px; margin-top: 12px; font-size: 12.5px; color: var(--ink-soft); text-align: center; }
.set-photo-status { margin-top: 8px; text-align: left; }
/* A failure has to read as one. Everything else in this line is quiet grey,
   which is fine for "uploading..." and invisible for "cannot open that". */
.set-status.bad { color: var(--pink-deep); font-weight: 700; }
.set-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }

.save-btn {
  padding: 10px 22px;
  border-radius: 999px;
  background: linear-gradient(135deg, var(--pink), var(--lav));
  color: #fff;
  font-weight: 800;
  font-size: 13px;
}

/* ------------------------------------------------------------ layout modes */

/* Video only: the chat panel goes away and the video takes the whole grid.
   The aspect-ratio and height cap from the stacked layout have to be undone or
   the video stays letterboxed in the middle of an empty screen. */
body.layout-video .panel { display: none; }
body.layout-video .app {
  grid-template-columns: 1fr;
  grid-template-rows: minmax(0, 1fr);
}
body.layout-video .video-wrap {
  aspect-ratio: auto;
  max-height: none;
  flex: 1 1 auto;
}

/* Below 900px the same box carries `aspect-ratio: 16/10` and `margin: 0 auto`,
   and dropping the ratio above leaves the auto cross-margin behind. An auto
   margin on the cross axis cancels the stretch in a flex column, so the wrap
   falls back to its content width — and its contents are all absolutely
   positioned, so that width is zero.

   Video only then renders no video at all: a black nothing, the Go Live block
   under it, and the ◫ that leaves the mode drawn outside a zero width box
   where nothing can hit it. The chat panel is hidden in this mode, so that
   button is the only way out, and the mode is remembered across reloads. It
   stranded her mid-stream. */
@media (max-width: 900px) {
  body.layout-video .video-wrap {
    width: 100%;
    margin: 0;
  }
}

/* Chat only: the whole stage goes, including the video, the xp bar and the
   broadcaster controls. */
body.layout-chat .stage { display: none; }
body.layout-chat .app {
  grid-template-columns: 1fr;
  grid-template-rows: minmax(0, 1fr);
}

/* Two overlay buttons now sit on the video, so they cannot share a corner. */
.fs-btn.layout-btn { right: 54px; }
.viewers { right: 98px; }

@media (pointer: coarse) {
  .fs-btn.layout-btn { right: 60px; }
  .viewers { right: 110px; }
}

/* ------------------------------------------------------------ fullscreen */

/* The document root is what goes fullscreen, so that sheets and toasts (which
   are siblings of .app) still render. The root paints black by default in
   fullscreen, which would frame the page in a hard edge. */
:root:fullscreen,
:root:-webkit-full-screen {
  background: var(--cream);
}

:root:fullscreen body,
:root:-webkit-full-screen body {
  height: 100dvh;
}

/* ------------------------------------------------------------ reactions */

/* The bar exists twice on every page: over the video, and inside the composer
   for chat-only mode. Same reason the layout button is duplicated — one of the
   two is always hidden, and a control you cannot reach in the mode you are in
   is a trap. */
.love-bar {
  position: absolute;
  left: 50%;
  bottom: 10px;
  transform: translateX(-50%);
  z-index: 3;
  display: flex;
  gap: 6px;
  max-width: calc(100% - 16px);
  padding: 6px;
  border-radius: 999px;
  background: rgba(43, 29, 37, 0.55);
  backdrop-filter: blur(4px);
  /* Seven buttons fit a phone in portrait with room to spare, but not once the
     touch sizes below kick in on a narrow phone in split view. Scrolling beats
     wrapping here: a second row would eat the video. */
  overflow-x: auto;
  scrollbar-width: none;
}

.love-bar::-webkit-scrollbar { display: none; }

.love-bar button {
  flex: 0 0 auto;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.16);
  font-size: 19px;
  line-height: 1;
  display: grid;
  place-content: center;
  padding: 0;
}

.love-bar button:hover { background: rgba(255, 255, 255, 0.34); }

/* Local, instant feedback. The burst itself waits for the server to echo the
   press back, so both screens fire from the same event — but the button must
   answer the thumb immediately or the round trip reads as a dead button. */
.love-bar button.hit { animation: fx-press 0.4s cubic-bezier(0.2, 1.6, 0.4, 1); }

@keyframes fx-press {
  0% { transform: scale(1); }
  35% { transform: scale(1.45); }
  100% { transform: scale(1); }
}

.love-bar.in-chat {
  position: static;
  transform: none;
  display: none;
  justify-content: center;
  background: var(--surface-2);
  backdrop-filter: none;
}

.love-bar.in-chat button { background: var(--card); box-shadow: 0 1px 4px rgba(217, 64, 122, 0.14); }
.love-bar.in-chat button:hover { background: var(--pink-soft); }

body.layout-chat .love-bar.in-chat { display: flex; }

@media (pointer: coarse) {
  .love-bar button { width: 42px; height: 42px; font-size: 21px; }
}

/* ------------------------------------------------------------ effects */

.fx {
  position: fixed;
  inset: 0;
  z-index: 55; /* over the sheets, under the toasts and the action menu */
  pointer-events: none;
  overflow: hidden;
}

/* Inside .fx, which is its own stacking context: the wash sits under
   everything, the words sit over everything. Left to DOM order the tint would
   dull the caption and the particles would fly across the middle of it. */
.fx-tint { z-index: 0; }
.fx-big { z-index: 1; }
.fx-p { z-index: 2; }
.fx-caption { z-index: 3; }

.fx-p {
  position: absolute;
  line-height: 1;
  will-change: transform, opacity;
  animation-timing-function: cubic-bezier(0.25, 0.55, 0.35, 1);
  animation-fill-mode: both;
}

/* Every keyframe carries the same three transform functions in the same order.
   Drop one and the browser interpolates through a matrix instead, which turns a
   drifting heart into a sheared one. */
.fx-drift { animation-name: fx-drift; }

@keyframes fx-drift {
  0% { opacity: 0; transform: translate(0, 0) scale(var(--s0)) rotate(0deg); }
  12% { opacity: 1; transform: translate(calc(var(--dx) * 0.12), calc(var(--dy) * 0.12)) scale(1) rotate(calc(var(--rot) * 0.12)); }
  80% { opacity: 1; transform: translate(calc(var(--dx) * 0.8), calc(var(--dy) * 0.8)) scale(1) rotate(calc(var(--rot) * 0.8)); }
  100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(var(--s1)) rotate(var(--rot)); }
}

/* Same travel, but wandering from side to side on the way, so hearts float
   rather than fly in a straight line. */
.fx-sway { animation-name: fx-sway; }

@keyframes fx-sway {
  0% { opacity: 0; transform: translate(0, 0) scale(var(--s0)) rotate(0deg); }
  10% { opacity: 1; transform: translate(calc(var(--dx) * 0.5), calc(var(--dy) * 0.1)) scale(1) rotate(calc(var(--rot) * 0.1)); }
  35% { opacity: 1; transform: translate(calc(var(--dx) * -0.8), calc(var(--dy) * 0.35)) scale(1) rotate(calc(var(--rot) * 0.35)); }
  60% { opacity: 1; transform: translate(var(--dx), calc(var(--dy) * 0.6)) scale(1) rotate(calc(var(--rot) * 0.6)); }
  85% { opacity: 0.9; transform: translate(calc(var(--dx) * -0.5), calc(var(--dy) * 0.85)) scale(1) rotate(calc(var(--rot) * 0.85)); }
  100% { opacity: 0; transform: translate(0, var(--dy)) scale(var(--s1)) rotate(var(--rot)); }
}

.fx-big {
  position: absolute;
  left: 50%;
  top: 46%;
  font-size: min(38vmin, 250px);
  line-height: 1;
  text-shadow: 0 0 46px var(--tint);
  animation: fx-pop 1.6s cubic-bezier(0.2, 1.4, 0.35, 1) both;
}

/* The translate(-50%, -50%) that centres it lives inside the keyframes. Put it
   on the element instead and the animation overwrites it, dropping the emoji
   into the bottom-right quadrant. */
@keyframes fx-pop {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(0.2) rotate(-10deg); }
  30% { opacity: 1; transform: translate(-50%, -50%) scale(calc(var(--k) * 1.12)) rotate(4deg); }
  65% { opacity: 0.95; transform: translate(-50%, -50%) scale(calc(var(--k) * 0.97)) rotate(-2deg); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(calc(var(--k) * 1.32)) rotate(0deg); }
}

/* A kiss lands on the screen rather than growing out of it. */
.fx-big.stamp { animation: fx-stamp 1.5s cubic-bezier(0.3, 1.2, 0.3, 1) both; }

@keyframes fx-stamp {
  0% { opacity: 0; transform: translate(-50%, -50%) scale(3.6) rotate(-20deg); }
  22% { opacity: 1; transform: translate(-50%, -50%) scale(0.84) rotate(7deg); }
  42% { opacity: 1; transform: translate(-50%, -50%) scale(1.04) rotate(-4deg); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(1.16) rotate(0deg); }
}

.fx-tint {
  position: absolute;
  inset: 0;
  background: radial-gradient(130% 95% at 50% var(--ty), var(--tint), transparent 62%);
  animation: fx-wash 1.7s ease-out both;
}

@keyframes fx-wash {
  0% { opacity: 0; }
  22% { opacity: 0.4; }
  100% { opacity: 0; }
}

/* Low, so it never collides with a toast or a medal popup up top, but not so
   low that it lands on the reaction bar itself in chat-only mode. */
.fx-caption {
  position: absolute;
  left: 50%;
  bottom: 24%;
  padding: 9px 17px;
  border-radius: 999px;
  border: 2px solid var(--tint);
  background: var(--fx-pill);
  color: var(--ink);
  font-weight: 800;
  font-size: 14px;
  white-space: nowrap;
  max-width: calc(100vw - 32px);
  overflow: hidden;
  text-overflow: ellipsis;
  box-shadow: 0 10px 30px rgba(217, 64, 122, 0.28);
  animation: fx-cap 2.2s ease both;
}

@keyframes fx-cap {
  0% { opacity: 0; transform: translate(-50%, 14px) scale(0.9); }
  12% { opacity: 1; transform: translate(-50%, 0) scale(1); }
  80% { opacity: 1; transform: translate(-50%, 0) scale(1); }
  100% { opacity: 0; transform: translate(-50%, -8px) scale(0.96); }
}

/* Applied to .app, so the whole room moves. */
.fx-shake { animation: fx-shake 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }

@keyframes fx-shake {
  0%, 100% { transform: translate(0, 0) rotate(0deg); }
  10% { transform: translate(-9px, 4px) rotate(-0.6deg); }
  25% { transform: translate(8px, -5px) rotate(0.5deg); }
  40% { transform: translate(-7px, 3px) rotate(-0.4deg); }
  55% { transform: translate(6px, -3px) rotate(0.35deg); }
  70% { transform: translate(-4px, 2px) rotate(-0.25deg); }
  85% { transform: translate(3px, -1px) rotate(0.15deg); }
}

.fx-wobble { animation: fx-wobble 0.85s ease both; }

@keyframes fx-wobble {
  0%, 100% { transform: rotate(0deg); }
  20% { transform: rotate(-1.2deg); }
  45% { transform: rotate(1deg); }
  70% { transform: rotate(-0.6deg); }
  88% { transform: rotate(0.3deg); }
}

/* Shaking the room is exactly the thing motion sensitivity means. The centre
   emoji, the wash and the caption still deliver the message. */
@media (prefers-reduced-motion: reduce) {
  .fx-shake, .fx-wobble { animation: none; }
}

/* ------------------------------------------------------------ toast */

.toasts {
  position: fixed;
  top: 18px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  gap: 8px;
  z-index: 60;
  pointer-events: none;
  width: min(420px, calc(100vw - 32px));
}

.toast {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 18px;
  border-radius: 18px;
  background: linear-gradient(135deg, var(--card), var(--surface-2));
  border: 2px solid var(--pink);
  box-shadow: 0 12px 34px rgba(217, 64, 122, 0.28);
  animation: pop 0.45s cubic-bezier(0.2, 1.4, 0.4, 1);
}

@keyframes pop {
  from { opacity: 0; transform: translateY(-14px) scale(0.94); }
  to { opacity: 1; transform: none; }
}

.toast .t-emoji { font-size: 30px; }
.toast .t-title { font-weight: 800; color: var(--pink-deep); font-size: 14px; }
.toast .t-cap { font-size: 12.5px; color: var(--ink); line-height: 1.4; }

/* ------------------------------------------------------------ gate */

.gate {
  height: 100dvh;
  display: grid;
  place-content: center;
  justify-items: center;
  text-align: center;
  gap: 10px;
  padding: 30px;
}

.gate .big { font-size: 54px; }
.gate h1 { margin: 0; color: var(--pink-deep); font-size: 22px; }
.gate p { margin: 0; color: var(--ink-soft); max-width: 34ch; line-height: 1.55; }

/* ------------------------------------------------------------ broadcaster */

.go-btn {
  width: 100%;
  padding: 19px;
  border-radius: var(--radius);
  font-size: 18px;
  font-weight: 800;
  color: #fff;
  background: linear-gradient(135deg, var(--pink), var(--lav));
  box-shadow: var(--shadow);
}

.go-btn.stop { background: linear-gradient(135deg, #ff6b6b, #d9407a); }
.go-btn:disabled { opacity: 0.55; cursor: wait; }

/* The stream stopped on its own and could not restart itself. The one tap left
   has to be findable on a propped-up phone from across the room, hence the
   breathing. Held still for anyone who asked their phone to stop animating. */
.go-btn.resume { animation: go-breathe 1.6s ease-in-out infinite; }

@keyframes go-breathe {
  0%, 100% { box-shadow: var(--shadow); }
  50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--pink) 35%, transparent); }
}

@media (prefers-reduced-motion: reduce) {
  .go-btn.resume { animation: none; outline: 3px solid var(--pink); outline-offset: 3px; }
}

.controls { display: flex; gap: 8px; flex-wrap: wrap; }

.controls button {
  flex: 1 1 auto;
  min-width: 110px;
  padding: 12px;
  border-radius: 14px;
  background: var(--card);
  box-shadow: var(--shadow);
  font-weight: 700;
  font-size: 13px;
  color: var(--ink);
}

.controls button.off { opacity: 0.5; }
.status { font-size: 13px; color: var(--ink-soft); text-align: center; min-height: 18px; }

/* ------------------------------------------------------------ the strip */

/* Her controls, under the chat header: the ghost clock, the scale, the
   calendar, the basement door and the court. A scrolling row rather than a
   wrapping one, because a second row on a phone eats the chat, and these are
   glanced at far more often than they are pressed. */
.strip {
  flex: 0 0 auto;
  display: flex;
  gap: 6px;
  padding: 8px 10px;
  border-bottom: 1px solid var(--line);
  overflow-x: auto;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

.strip::-webkit-scrollbar { display: none; }
.strip:empty { display: none; }

.chip {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 6px 11px;
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--line);
  font-size: 12px;
  font-weight: 800;
  color: var(--ink-soft);
  white-space: nowrap;
  line-height: 1.5;
}

button.chip:hover { background: var(--pink-soft); color: var(--pink-deep); }
.chip.static { cursor: default; }
.chip.bad { background: #ffe9e9; border-color: #ffc4c4; color: #c0392b; }
.chip.warn { background: #fff4e3; border-color: #ffdcae; color: #a86400; }
/* A high reading on the hard and wet dials. Deliberately not `.bad`: red is
   for a sentence being served, and these two are not a punishment. */
.chip.hot { background: #ffe6f2; border-color: #ffb6d8; color: #c2186b; }

.chip-court {
  background: linear-gradient(135deg, var(--pink), var(--lav));
  border-color: transparent;
  color: #fff;
}

button.chip-court:hover { filter: brightness(1.06); color: #fff; }

.chip-question {
  background: linear-gradient(135deg, var(--lav), var(--pink-soft));
  border-color: transparent;
  color: #fff;
}

button.chip-question:hover { filter: brightness(1.06); color: #fff; }

button.chip-question:disabled { opacity: 0.7; }

/* The one chip that is counting rather than reporting. */
.chip-ghost.bad { animation: chip-pulse 2.4s ease-in-out infinite; }

@keyframes chip-pulse {
  0%, 100% { box-shadow: 0 0 0 0 rgba(192, 57, 43, 0); }
  50% { box-shadow: 0 0 0 4px rgba(192, 57, 43, 0.11); }
}

@media (pointer: coarse) {
  .chip { padding: 9px 13px; font-size: 12.5px; }
  .strip { padding: 8px; gap: 7px; }
}

@media (prefers-reduced-motion: reduce) {
  .chip-ghost.bad { animation: none; }
}

/* --------------------------------------------------------- the verdict */

.msg.verdict { align-self: center; align-items: center; max-width: 94%; text-align: center; }

.msg.verdict .bubble {
  background: linear-gradient(135deg, #f6efff, var(--surface-2));
  border: 1px solid var(--lav);
  color: #5b4a7a;
  font-size: 12.5px;
  font-weight: 700;
  line-height: 1.55;
}


/* -------------------------------------------------------- the question
 *
 * A card in the middle of the log, wider than a bubble because it is a form
 * and not a sentence. It stays a `.bubble` so it inherits the log's radius,
 * wrapping and dark theme without a second set of rules.
 */

.msg.question {
  align-self: center;
  align-items: center;
  max-width: 96%;
  width: 420px;
}

.msg.question .msg-col { width: 100%; }

.msg.question .bubble {
  width: 100%;
  padding: 13px 15px;
  background: linear-gradient(135deg, var(--lav-soft), var(--surface-2));
  border: 1px solid var(--lav);
}

.qcard { display: flex; flex-direction: column; gap: 9px; }

.qcard-head {
  font-size: 11px;
  font-weight: 900;
  letter-spacing: 0.03em;
  color: var(--pink-deep);
}

.qcard-q { font-size: 15px; font-weight: 800; line-height: 1.45; color: var(--ink); }

.qcard-write { display: flex; gap: 7px; align-items: flex-end; }

.qcard-input {
  flex: 1;
  min-width: 0;
  resize: none;
  font: inherit;
  font-size: 16px; /* 16px stops iOS Safari zooming on focus */
  line-height: 1.4;
  padding: 8px 11px;
  border-radius: 14px;
  border: 1px solid var(--line);
  background: var(--input);
  color: var(--ink);
}

.qcard-input:focus { outline: 2px solid var(--lav); outline-offset: 1px; }

.qcard-send {
  flex: 0 0 auto;
  padding: 9px 13px;
  border: 0;
  border-radius: 999px;
  background: linear-gradient(135deg, var(--pink), var(--lav));
  color: #fff;
  font-size: 12.5px;
  font-weight: 900;
  cursor: pointer;
}

.qcard-send:disabled { opacity: 0.6; cursor: default; }

.qcard-hint, .qcard-wait { font-size: 11.5px; font-weight: 700; color: var(--ink-soft); }

.qcard-again {
  align-self: flex-start;
  padding: 5px 10px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--ink-soft);
  font-size: 11.5px;
  font-weight: 800;
  cursor: pointer;
}

.qcard-answers { display: flex; flex-direction: column; gap: 9px; }

.qa { display: flex; gap: 8px; align-items: flex-start; }

.qa-col { min-width: 0; display: flex; flex-direction: column; gap: 2px; }

.qa-who { font-size: 11px; font-weight: 900; color: var(--pink-deep); }

.qa.mine .qa-who { color: var(--lav); }

.qa-body {
  font-size: 14px;
  line-height: 1.45;
  color: var(--ink);
  overflow-wrap: anywhere;
}

/* ---------------------------------------------------- the slider card */

/* Built on .qcard, so the head and the line on it are already right. What is
   different is everything below them: a number being dragged rather than a
   sentence being written. */

.scard-read {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 8px;
  padding: 2px 0;
}

.scard-emoji { font-size: 26px; line-height: 1; }

.scard-n { font-size: 34px; font-weight: 900; color: var(--pink-deep); line-height: 1.05; }

.scard-band { font-size: 13px; font-weight: 800; color: var(--ink-soft); }

/* Tighter than the sheet's copy of it. The dial in a sheet has the whole panel
   to breathe in; this one is inside a chat bubble with a line of text above it. */
.scard .dial-range { margin: 4px 0 2px; }

.scard-note { width: 100%; }

.scard-send { align-self: flex-end; }

.scard-who { font-size: 11px; font-weight: 900; color: var(--pink-deep); text-align: center; }

.scard-said {
  font-size: 13.5px;
  line-height: 1.45;
  color: var(--ink);
  text-align: center;
  overflow-wrap: anywhere;
}

/* ----------------------------------------------------- the match card */

/* Built on .qcard like the slider. While it is being answered it is one
   question at a time: dots along the top, a big line, four big answers
   stacked under it. Once it opens, the four questions stack with the two
   picks side by side and a tick or a cross on each line. */

.mform { display: flex; flex-direction: column; gap: 11px; }

.mdots { display: flex; align-items: center; gap: 6px; }

.mdot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--line);
  transition: transform 0.15s, background 0.15s;
}

.mdot.done { background: var(--pink); }

.mdot.now { background: var(--lav); transform: scale(1.35); }

.mdots-label { margin-left: 4px; font-size: 11px; font-weight: 800; color: var(--ink-soft); }

.mstep-q { font-size: 17px; font-weight: 900; line-height: 1.35; color: var(--ink); }

.mstep-opts { display: flex; flex-direction: column; gap: 8px; }

.mstep-opt {
  min-height: 46px;
  padding: 12px 14px;
  border-radius: 14px;
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--ink);
  font: inherit;
  font-size: 14.5px;
  font-weight: 800;
  line-height: 1.3;
  text-align: left;
  cursor: pointer;
  overflow-wrap: anywhere;
  transition: transform 0.08s;
}

.mstep-opt:active { transform: scale(0.98); }

.mstep-opt.on {
  border-color: transparent;
  background: linear-gradient(135deg, var(--pink), var(--lav));
  color: #fff;
}

.mform-back {
  align-self: flex-start;
  padding: 5px 10px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--card);
  color: var(--ink-soft);
  font: inherit;
  font-size: 11.5px;
  font-weight: 800;
  cursor: pointer;
}

/* The review: four rows, question left, answer right, each a button back to
   that question. */
.mrev-list { display: flex; flex-direction: column; gap: 6px; }

.mrev {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border-radius: 12px;
  border: 1px solid var(--line);
  background: var(--card);
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.mrev-q { flex: 1; min-width: 0; font-size: 12.5px; font-weight: 700; line-height: 1.3; color: var(--ink-soft); }

.mrev-a { flex: 0 1 auto; font-size: 13.5px; font-weight: 900; color: var(--pink-deep); text-align: right; overflow-wrap: anywhere; }

.mq-list { display: flex; flex-direction: column; gap: 10px; }

.mq { display: flex; flex-direction: column; gap: 6px; }

.mq-q {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 8px;
  font-size: 13.5px;
  font-weight: 800;
  line-height: 1.4;
  color: var(--ink);
}

.mq-mark { flex: 0 0 auto; font-size: 13px; font-weight: 900; color: var(--ink-soft); }

.mq.hit .mq-mark { color: var(--pink-deep); }

.mcard-send { align-self: stretch; padding: 12px 14px; font-size: 14px; }

/* Once it is open: the two picks on one line, yours in lavender, theirs in
   pink, the same colours the question card gives the two names. A row where
   they agree gets a soft wash so the score reads at a glance without counting
   ticks. */
.mq-picks { display: flex; flex-wrap: wrap; gap: 6px; }

.mq-pick {
  padding: 4px 9px;
  border-radius: 999px;
  background: var(--card);
  border: 1px solid var(--line);
  font-size: 12px;
  font-weight: 700;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.mq-pick b { font-weight: 900; color: var(--pink-deep); }

.mq-pick.mine b { color: var(--lav); }

.mq.done.hit {
  margin: 0 -6px;
  padding: 6px;
  border-radius: 12px;
  background: color-mix(in srgb, var(--pink) 14%, transparent);
}

.mscore {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 8px;
  padding: 2px 0;
}

.mscore-emoji { font-size: 26px; line-height: 1; }

.mscore-n { font-size: 40px; font-weight: 900; color: var(--pink-deep); line-height: 1.05; }

.mscore-label { font-size: 13px; font-weight: 800; color: var(--ink-soft); }

/* The whole card on a phone is the width of the log, and the button under the
   box rather than beside it: at 320px a pill and a textarea on one row leaves
   the textarea about eight characters wide. */
@media (max-width: 520px) {
  .msg.question { width: 100%; max-width: 100%; }
  .qcard-write { flex-direction: column; align-items: stretch; }
  .qcard-send { align-self: flex-end; }
}

/* ---------------------------------------------------------- the search */

/* Over the log rather than above it. The panel replaces what you are reading
   for as long as you are searching, which is the only arrangement that leaves
   room for results on a phone. */
.search {
  position: absolute;
  inset: 0;
  z-index: 8;
  display: none;
  flex-direction: column;
  background: var(--card);
}

.search.open { display: flex; }

/* Searching on a phone is a whole-attention job. The chat body on a short
   screen is about a hundred pixels tall once the video and the xp bar have had
   their share, which is one and a half results. While the panel is open the
   stage steps out of the way and the results get the window. */
@media (max-width: 900px) {
  body.searching .stage { display: none; }
}

.search-bar {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px;
  border-bottom: 1px solid var(--line);
}

.search-icon { font-size: 15px; }

.search-input {
  flex: 1;
  min-width: 0;
  padding: 10px 14px;
  border: 1px solid var(--line);
  border-radius: 999px;
  font-size: 16px; /* 16px stops iOS Safari zooming on focus */
  outline: none;
  background: var(--input);
}

.search-input:focus { border-color: var(--pink); }

.search-x {
  flex: 0 0 auto;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: var(--lav-soft);
  font-size: 13px;
  display: grid;
  place-content: center;
}

.search-x:hover { background: #e0d0ff; }
.search-count { flex: 0 0 auto; padding: 8px 12px 4px; font-size: 11.5px; color: var(--ink-soft); }

.search-results {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 4px 10px 14px;
}

.hit {
  text-align: left;
  padding: 9px 12px;
  border-radius: 12px;
  background: var(--surface);
  border: 1px solid var(--line);
}

.hit:hover { background: var(--pink-soft); }
.hit-head { display: flex; justify-content: space-between; gap: 10px; font-size: 11px; }
.hit-who { font-weight: 800; color: var(--pink-deep); }
.hit-when { color: var(--ink-soft); white-space: nowrap; }
.hit-kind { margin-right: 5px; }

.hit-text {
  margin-top: 3px;
  font-size: 13px;
  line-height: 1.45;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.hit mark { background: #ffe08a; color: inherit; border-radius: 3px; padding: 0 2px; }

/* ----------------------------------------------------------- the sheets */

.save-btn.wide, .medals-btn.wide {
  display: block;
  width: 100%;
  margin-top: 10px;
  padding: 14px;
  font-size: 14px;
}

.save-btn.danger { background: linear-gradient(135deg, #7a5566, #2b1d25); }

/* A dial holding a number that has not been sent yet. The dials no longer save
   as you drag them, so this ring is the only thing on screen saying the room has
   not been told. Deliberately a ring and not a colour change: it has to read at
   a glance on a phone in the dark, in either theme. */
.save-btn.dirty {
  box-shadow: 0 0 0 3px var(--pink-soft), 0 6px 18px rgba(255, 122, 184, 0.35);
  animation: save-nudge 2s ease-in-out infinite;
}

@keyframes save-nudge {
  0%, 100% { transform: none; }
  50% { transform: scale(1.02); }
}

@media (prefers-reduced-motion: reduce) {
  .save-btn.dirty { animation: none; }
}

.bs-state {
  padding: 14px;
  border-radius: 14px;
  background: var(--surface);
  border: 1px solid var(--line);
  text-align: center;
  font-weight: 800;
  font-size: 14px;
}

.bs-state.on { background: #2b1d25; color: #ffd7e6; border-color: #160f13; }

/* The charge, under the line that says how long. Empty means no charge, so the
   row collapses rather than leaving a gap where a sentence used to be. */
.bs-said { font-size: 12.5px; font-weight: 700; opacity: 0.85; }
.bs-said:not(:empty) { margin-top: 6px; }

/* The dial */

.dial { text-align: center; padding: 6px 0 2px; }
.dial-emoji { font-size: 40px; line-height: 1; }
.dial-n { font-size: 48px; font-weight: 900; color: var(--pink-deep); line-height: 1.05; }
.dial-label { font-size: 13px; font-weight: 800; color: var(--ink-soft); }
.dial-cool { font-size: 12px; font-weight: 700; color: var(--ink-soft); opacity: 0.8; margin-top: 4px; }

.dial-range {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 14px;
  margin: 16px 0 12px;
  border-radius: 999px;
  outline: none;
  background: linear-gradient(90deg, var(--ok), #ffd978 46%, #ff6b6b);
}

/* Both vendor thumbs, spelled out twice: a browser drops the whole rule if it
   does not know the selector, so they cannot be combined. */
.dial-range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--card);
  border: 3px solid var(--pink-deep);
  box-shadow: 0 2px 8px rgba(217, 64, 122, 0.35);
  cursor: pointer;
}

.dial-range::-moz-range-thumb {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: var(--card);
  border: 3px solid var(--pink-deep);
  box-shadow: 0 2px 8px rgba(217, 64, 122, 0.35);
  cursor: pointer;
}

.dial-range:disabled { opacity: 0.7; }

.dial-quick { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }

.dial-quick button {
  padding: 9px 15px;
  border-radius: 999px;
  background: var(--lav-soft);
  color: #6a4bb0;
  font-weight: 800;
  font-size: 13px;
}

.dial-quick button:hover { background: #e0d0ff; }

.dial-note {
  width: 100%;
  margin-top: 12px;
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: 14px;
  font-size: 16px;
  outline: none;
  background: var(--input);
}

.dial-note:focus { border-color: var(--pink); }

/* The two of you */

.us-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
  gap: 8px;
}

.us-section {
  margin: 16px 0 8px;
  font-size: 11px;
  font-weight: 900;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.us-btn {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 12px 8px;
  border-radius: 14px;
  background: var(--surface);
  border: 1px solid var(--line);
  color: var(--ink);
}

.us-btn:hover { background: var(--pink-soft); border-color: var(--pink); }
.us-btn-spicy {
  background: linear-gradient(160deg, #fff0f5, #ffe0ec);
  border-color: #f4a0bc;
  color: #4a3540;
}
.us-btn-spicy:hover { background: #ffd6e6; border-color: var(--pink); }
.us-btn-date {
  background: linear-gradient(160deg, #f3fff6, #e4f8ea);
  border-color: #8fd4a4;
  color: #4a3540;
}
.us-btn-date:hover { background: #d9f5e2; border-color: #57c98a; }
.us-btn-intimate {
  background: linear-gradient(160deg, #f6f0ff, #ebe0ff);
  border-color: #c4a8f0;
  color: #4a3540;
}
.us-btn-intimate:hover { background: #e8dcff; border-color: #b892ff; }
.us-e { font-size: 24px; line-height: 1; }
.us-l { font-size: 12px; font-weight: 800; text-align: center; line-height: 1.25; color: inherit; }

.beat { margin-top: 16px; }

.beat-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  width: 100%;
  padding: 16px;
  border-radius: 16px;
  background: linear-gradient(135deg, #ff6b6b, #d9407a);
  color: #fff;
  font-size: 16px;
  font-weight: 900;
  box-shadow: 0 8px 20px rgba(217, 64, 122, 0.32);
}

.beat-btn .beat-e { font-size: 22px; }
.beat-btn .beat-n { font-size: 13px; opacity: 0.85; }
.beat-btn.hit { animation: beat-hit 0.28s cubic-bezier(0.2, 1.7, 0.4, 1); }

@keyframes beat-hit {
  0% { transform: scale(1); }
  40% { transform: scale(0.94) rotate(-1.5deg); }
  70% { transform: scale(1.04) rotate(1deg); }
  100% { transform: scale(1); }
}

.beat-meter {
  height: 8px;
  margin-top: 10px;
  border-radius: 999px;
  background: var(--lav-soft);
  overflow: hidden;
}

.beat-fill {
  height: 100%;
  width: var(--fill, 0%);
  border-radius: 999px;
  background: linear-gradient(90deg, #ffd978, #ff6b6b);
  transition: width 0.35s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.beat-hint { margin-top: 6px; font-size: 12px; font-weight: 800; color: var(--ink-soft); text-align: center; }

@media (pointer: coarse) {
  .us-btn { padding: 14px 8px; }
}

/* The calendar */

/* The one surface in the room that hardcoded its own background instead of
   taking one of the five, so it stayed white when everything around it went
   dark: pale text on a near-white card, 1.15:1, which is not low contrast so
   much as invisible. `--surface` is #fff6fa in the light theme, which is the
   colour this was reaching for anyway. */
.cyc-card {
  --phase: var(--pink);
  padding: 16px;
  border-radius: 16px;
  background: var(--surface);
  border: 2px solid var(--phase);
  text-align: center;
}

.cyc-emoji { font-size: 34px; line-height: 1; }
.cyc-phase { font-size: 19px; font-weight: 900; color: var(--phase-ink, var(--phase)); }
.cyc-day { font-size: 12.5px; font-weight: 700; color: var(--ink-soft); margin-top: 2px; }
.cyc-blurb { font-size: 13.5px; margin-top: 8px; line-height: 1.45; }
.cyc-next { margin-top: 10px; font-size: 12.5px; font-weight: 800; color: var(--pink-deep); }

.cyc-coach { margin: 16px 0 0; padding-left: 22px; font-size: 13.5px; line-height: 1.5; }
.cyc-coach li { margin-bottom: 6px; }
.cyc-coach[hidden] { display: none; }

.cyc-form input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: 14px;
  font-size: 16px;
  outline: none;
  background: var(--input);
}

.cyc-form input:focus { border-color: var(--pink); }
.cyc-two { display: flex; gap: 10px; }
.cyc-two > div { flex: 1; min-width: 0; }

/* The court */

.court { text-align: center; padding: 4px 0 2px; }
.court-emoji { font-size: 44px; line-height: 1; }
.court-head { margin-top: 6px; font-size: 20px; font-weight: 900; color: var(--pink-deep); }
.court-score { margin-top: 2px; font-size: 12px; font-weight: 800; color: var(--ink-soft); }
.court-reason { margin: 14px auto 0; max-width: 42ch; font-size: 14px; line-height: 1.55; }

.court-sentence {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 9px;
  margin-top: 16px;
  padding: 13px;
  border-radius: 14px;
  background: var(--surface-2);
  border: 1px dashed var(--pink);
  font-size: 13.5px;
  font-weight: 800;
  text-align: left;
}

.court-gavel { font-size: 18px; }
.court-wait { padding: 34px 10px; color: var(--ink-soft); text-align: center; font-size: 13.5px; }

/* -------------------------------------------------------- the basement */

/* The room, dimmed. A filter rather than a covering layer, so the chat stays
   readable and usable: he is in the basement, not locked out of it. Nothing
   inside .app is position:fixed, which is what makes filtering it safe. */
body.in-basement .app {
  filter: brightness(0.58) saturate(0.55) contrast(1.06);
  transition: filter 0.9s ease;
}

/* Locked down.
 *
 * The overlay already says why, so this only has to make the room look as
 * unavailable as it is. `inert` is what actually stops the presses (see
 * lockdown() in controls.js); `pointer-events` is here for the parts that are
 * built too late to be caught by it, and for a browser old enough to ignore the
 * attribute. The message tools go entirely: two buttons beside every line that
 * cannot be pressed are worse than no buttons.
 */
body.in-basement .locked { opacity: 0.45; }
body.in-basement .locked,
body.in-basement .cast { pointer-events: none; }
body.in-basement .msg-tools { display: none; }

/* Except the camera, which carries neither class: he is expected to be on it
   down there and the buttons for it keep working (see LOCKED in controls.js).
   Being the one lit thing in a room turned down to 58% is the point, so they
   undo the .app filter on themselves rather than fade with everything he can no
   longer press. `.chip-cam` is the same row mirrored into the strip on a phone.
   Safe to filter: nothing inside any of them is position: fixed. */
body.in-basement .go-btn,
body.in-basement .controls,
body.in-basement .chip-cam {
  filter: brightness(1.72) saturate(1.82);
}

/* Room for the bar at the top, so it cannot sit on the chat header. It can:
   the header is at the very top of the window in chat-only mode, and on a
   phone whenever the stage is hidden. A lower ceiling in the basement is not
   the worst thing that could happen to him either. */
body.in-basement .app { padding-top: 68px; }
body.in-basement .toasts { top: 96px; }

.basement {
  position: fixed;
  inset: 0;
  z-index: 44;
  display: none;
  overflow: hidden;
  /* Only the card takes presses. Everything else is scenery, and scenery that
     swallowed a tap would leave him unable to answer her. */
  pointer-events: none;
}

.basement.on { display: block; }

.bs-dark {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(60% 42% at 68% 0%, rgba(255, 214, 150, 0.22), transparent 60%),
    radial-gradient(130% 100% at 50% 50%, transparent 30%, rgba(9, 5, 11, 0.72) 100%);
}

/* A bare bulb on a cord, swinging. The glow is on the bulb rather than a
   separate layer so the two can never drift apart mid-swing. */
.bs-bulb {
  position: absolute;
  top: 0;
  left: 68%;
  width: 40px;
  margin-left: -20px;
  transform-origin: 50% 0;
  animation: bs-swing 6.5s ease-in-out infinite;
}

.bs-cord { display: block; width: 2px; height: 62px; margin: 0 auto; background: rgba(255, 226, 190, 0.5); }

.bs-glass {
  display: block;
  width: 26px;
  height: 30px;
  margin: 0 auto;
  border-radius: 50% 50% 46% 46%;
  background: radial-gradient(circle at 50% 35%, #fff6dd, #ffcf7a 60%, #e9a33c);
  box-shadow: 0 0 34px 14px rgba(255, 197, 112, 0.34), 0 0 90px 40px rgba(255, 190, 90, 0.16);
}

@keyframes bs-swing {
  0%, 100% { transform: rotate(-3.4deg); }
  50% { transform: rotate(3.4deg); }
}

/* A barred cellar window, top left, with a thin strip of the outside world. */
.bs-bars {
  position: absolute;
  top: 26px;
  left: 6%;
  width: min(190px, 34vw);
  height: 74px;
  border-radius: 4px;
  background:
    repeating-linear-gradient(90deg, rgba(12, 8, 14, 0.92) 0 7px, transparent 7px 30px),
    linear-gradient(180deg, rgba(150, 180, 220, 0.28), rgba(150, 180, 220, 0.08));
  box-shadow: inset 0 0 0 5px rgba(12, 8, 14, 0.92), 0 0 40px rgba(150, 190, 235, 0.12);
}

/* Dust in the light. Three specks, not a particle system: this runs all evening
   while he sits down there, and it must cost nothing. */
.bs-dust {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(2px 2px at 64% 30%, rgba(255, 236, 200, 0.75), transparent 60%),
    radial-gradient(2px 2px at 72% 52%, rgba(255, 236, 200, 0.55), transparent 60%),
    radial-gradient(1.6px 1.6px at 60% 66%, rgba(255, 236, 200, 0.5), transparent 60%);
  animation: bs-dust 11s ease-in-out infinite alternate;
}

@keyframes bs-dust {
  from { transform: translate3d(-8px, 6px, 0); opacity: 0.55; }
  to { transform: translate3d(10px, -10px, 0); opacity: 0.95; }
}

/* Top centre, and the toasts move down to meet it. Every other corner belongs
   to something you need: the live badge, the layout buttons, the send button. */
/* Centred with left:0/right:0 and auto margins, not with left:50% and a
   translate. Under the translate the box is laid out in the space from the
   middle to the right edge, so on a 700px window it had 350px to fit in and
   wrapped the clock onto two lines, which then covered the chat header. */
.bs-card {
  position: absolute;
  top: max(14px, env(safe-area-inset-top));
  left: 0;
  right: 0;
  width: max-content;
  margin: 0 auto;
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 12px;
  max-width: calc(100% - 24px);
  padding: 10px 14px;
  border-radius: 999px;
  background: rgba(20, 12, 18, 0.92);
  border: 1px solid rgba(255, 214, 150, 0.28);
  box-shadow: 0 14px 40px rgba(0, 0, 0, 0.55);
  color: #ffe0c2;
}

.bs-title { font-size: 13px; font-weight: 800; white-space: nowrap; }

/* What she wrote on the door, inside the pill. It is the only part of the bar
   that may be long, so it is the only part allowed to shrink: everything else
   is `nowrap` and the pill is `width: max-content`, which would otherwise push
   the beg button off a phone. */
.bs-why {
  font-size: 12.5px;
  font-style: italic;
  color: #ffd7e6;
  min-width: 0;
  max-width: 34ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.bs-why:empty { display: none; }

.bs-clock {
  font-size: 13px;
  font-weight: 900;
  color: #ffcf7a;
  white-space: nowrap;
  /* Fixed-width digits, or the bar twitches once a second. */
  font-variant-numeric: tabular-nums;
}

/* The window belongs over the video. With the stage hidden it would hang over
   the chat header instead, which reads as a rendering bug rather than a cell. */
body.layout-chat .bs-bars, body.searching .bs-bars { display: none; }

.bs-beg {
  flex: 0 0 auto;
  padding: 8px 14px;
  border-radius: 999px;
  background: linear-gradient(135deg, var(--pink), var(--lav));
  color: #fff;
  font-weight: 800;
  font-size: 12.5px;
  white-space: nowrap;
}

.bs-beg:hover { filter: brightness(1.08); }

/* On a phone the pill becomes a bar across the top. A centred pill there is
   either two lines tall or wider than the screen, and a status bar is what
   this is anyway. The sentence loses its first half to fit on one line. */
@media (max-width: 620px) {
  .bs-card {
    left: 0;
    right: 0;
    top: 0;
    width: 100%;
    max-width: none;
    transform: none;
    justify-content: center;
    gap: 10px;
    padding: 8px 10px;
    padding-top: max(8px, env(safe-area-inset-top));
    border-radius: 0;
    border-width: 0 0 1px;
  }
  .bs-fluff { display: none; }
  .bs-title, .bs-clock { font-size: 12.5px; }
  .bs-why { font-size: 12px; max-width: 40vw; }
  .bs-beg { padding: 7px 12px; font-size: 12px; }
  .bs-bars { display: none; }
  body.in-basement .app { padding-top: 54px; }
  body.in-basement .toasts { top: 62px; }
}

@media (prefers-reduced-motion: reduce) {
  .bs-bulb, .bs-dust { animation: none; }
}

/* --------------------------------------------------------- dark mode, part 2
 *
 * The variables above cover every surface. What is left is the handful of
 * places that hardcode a colour for a reason: purple text that only works on
 * pale lavender, a yellow highlighter, the two soft gradients on the award and
 * verdict cards, and the coloured chips.
 */

:root[data-theme="dark"] .medals-btn,
:root[data-theme="dark"] .file-btn,
:root[data-theme="dark"] .dial-quick button { color: #cbb6ff; }

:root[data-theme="dark"] .medals-btn:hover,
:root[data-theme="dark"] .file-btn:hover,
:root[data-theme="dark"] .dial-quick button:hover,
:root[data-theme="dark"] .icon-mini:hover,
:root[data-theme="dark"] .icon-btn:hover,
:root[data-theme="dark"] .search-x:hover { background: #3d3159; }

:root[data-theme="dark"] .hit mark { background: #7a5c14; color: #ffeec2; }

:root[data-theme="dark"] .msg.award .bubble { background: linear-gradient(135deg, #3a2438, #2c2440); }
:root[data-theme="dark"] .msg.question .bubble { background: linear-gradient(135deg, #2b2440, #33283f); }
:root[data-theme="dark"] .qcard-q, :root[data-theme="dark"] .qa-body { color: #e7dcf5; }
:root[data-theme="dark"] .qcard-head, :root[data-theme="dark"] .qa-who { color: #f0a7c4; }
:root[data-theme="dark"] .scard-n, :root[data-theme="dark"] .scard-who { color: #f0a7c4; }
:root[data-theme="dark"] .scard-said { color: #e7dcf5; }
:root[data-theme="dark"] .mq-q, :root[data-theme="dark"] .mq-pick, :root[data-theme="dark"] .mstep-q { color: #e7dcf5; }
:root[data-theme="dark"] .mstep-opt, :root[data-theme="dark"] .mrev, :root[data-theme="dark"] .mform-back { background: #2b2440; border-color: #4a3f63; color: #e7dcf5; }
:root[data-theme="dark"] .mstep-opt.on { background: linear-gradient(135deg, var(--pink), var(--lav)); border-color: transparent; color: #fff; }
:root[data-theme="dark"] .mdot { background: #4a3f63; }
:root[data-theme="dark"] .mdot.done { background: var(--pink); }
:root[data-theme="dark"] .mdot.now { background: var(--lav); }
:root[data-theme="dark"] .mrev-a { color: #f0a7c4; }
:root[data-theme="dark"] .mq-pick { background: #2b2440; border-color: #4a3f63; }
:root[data-theme="dark"] .mscore-n, :root[data-theme="dark"] .mq.hit .mq-mark, :root[data-theme="dark"] .mq-pick b { color: #f0a7c4; }
:root[data-theme="dark"] .qa.mine .qa-who { color: #c9aaff; }
:root[data-theme="dark"] .msg.verdict .bubble { background: linear-gradient(135deg, #2b2440, #3a2438); color: #d9c9f2; }

:root[data-theme="dark"] .chip.bad { background: #4a2222; border-color: #6d3232; color: #ff9d92; }
:root[data-theme="dark"] .chip.warn { background: #4a3418; border-color: #6d4d22; color: #ffc477; }
:root[data-theme="dark"] .chip.hot { background: #4a2036; border-color: #6d3053; color: #ff9dc9; }
:root[data-theme="dark"] .ghost-line.away { color: #ff9d92; }

/* At night the card is dark, so the bright phase colour is the readable one and
   the darkened twin would be the mistake. Straight back to `--phase`. */
:root[data-theme="dark"] .cyc-card { --phase-ink: var(--phase); }

/* The two places the accent is a fill rather than ink. A pale pink behind the
   white ✕ of a favourite star is nothing to read, and the selected-swatch ring
   wants to stay visible against a dark card. */
:root[data-theme="dark"] .pick .fav:hover { background: #9c3b62; }
:root[data-theme="dark"] .chip-row button.on { border-color: #ff9ec4; }

:root[data-theme="dark"] .quote { background: rgba(255, 122, 168, 0.16); }
:root[data-theme="dark"] .medal.locked { opacity: 0.32; }
:root[data-theme="dark"] .beat-meter, :root[data-theme="dark"] .xp-track { background: #2f2743; }

/* Dimming a room that is already dark leaves nothing to read. */
:root[data-theme="dark"] body.in-basement .app { filter: brightness(0.72) saturate(0.7); }

/* The video letterbox and the offline card are already dark by design; only
   the page behind them changes. */
:root[data-theme="dark"] .sheet { background: rgba(8, 5, 10, 0.6); }
/* The speech bubbles are coloured by `--say-ink` in cast.css, which is set for
   both themes at the top of this file. There used to be an override here that
   read `var(--ink)`, which inside `.cast` is the cartoon outline colour, so it
   quietly painted dark text onto a dark bubble instead of fixing anything. */

/* Sheet action buttons: light pastel fills would leave light ink on light
   pink and nothing to read. Dark variants keep the tint and the contrast. */
:root[data-theme="dark"] .us-btn {
  background: var(--surface-2);
  border-color: #4a3a58;
  color: #f3e7ef;
}
:root[data-theme="dark"] .us-btn:hover {
  background: #3d3150;
  border-color: #c48aaa;
  color: #fff;
}
:root[data-theme="dark"] .us-btn-spicy {
  background: linear-gradient(160deg, #4a2a38, #3a2230);
  border-color: #d07098;
  color: #ffe8f2;
}
:root[data-theme="dark"] .us-btn-spicy:hover {
  background: #5a3444;
  border-color: #ff9ec0;
  color: #fff;
}
:root[data-theme="dark"] .us-btn-date {
  background: linear-gradient(160deg, #24382c, #1e3026);
  border-color: #5aaa78;
  color: #e4ffe8;
}
:root[data-theme="dark"] .us-btn-date:hover {
  background: #2e4a38;
  border-color: #7ad49a;
  color: #fff;
}
:root[data-theme="dark"] .us-btn-intimate {
  background: linear-gradient(160deg, #322848, #2a2040);
  border-color: #9a7ad0;
  color: #f0e8ff;
}
:root[data-theme="dark"] .us-btn-intimate:hover {
  background: #3e3260;
  border-color: #c4a8f0;
  color: #fff;
}
:root[data-theme="dark"] .us-section { color: #c4b0c8; }
:root[data-theme="dark"] .sheet-card h2 { color: #f3e7ef; }
:root[data-theme="dark"] .sheet-card .hint { color: #b8a4b4; }
:root[data-theme="dark"] .sheet-card .ghost {
  background: var(--surface-2);
  color: #e8d8e8;
  border-color: #4a3a58;
}
:root[data-theme="dark"] .chip {
  background: var(--surface-2);
  color: #e8d8e8;
  border-color: #4a3a58;
}
:root[data-theme="dark"] button.chip:hover {
  background: #3d3150;
  color: #ffc0d8;
}

/* ------------------------------------------------- escalation (effects.js)

   What a reaction looks like when nobody stops pressing. Six levels, each one
   additive: a tremble, then torn picture, then cracked glass, then the room
   shedding its own furniture, then a collapse.

   Everything here animates transform, opacity or clip-path only. Nothing in
   this block asks for a layout or a paint, which is what lets five of them run
   at once on a phone. The single exception is the debris clone, which is
   painted once when it detaches and then only moved.
                                                                            */

/* Sits at the very end of the file on purpose. Several of these classes land
   on elements that already have an animation of their own (.msg, .fs-btn), and
   at equal specificity the last rule wins. */

/* ---------------------------------------------------------- the quake */

/* One keyframe set, five amplitudes, because the shape of a shake does not
   change as it gets worse — only how far it throws the room. First and last
   frames match so the loop has no seam. */
@keyframes fx-quake {
  0%   { transform: translate3d(calc(var(--qa) * -1), calc(var(--qa) * 0.4), 0) rotate(calc(var(--qr) * -1)); }
  20%  { transform: translate3d(calc(var(--qa) * 0.8), calc(var(--qa) * -0.6), 0) rotate(var(--qr)); }
  40%  { transform: translate3d(calc(var(--qa) * -0.6), calc(var(--qa) * -0.3), 0) rotate(calc(var(--qr) * -0.5)); }
  60%  { transform: translate3d(calc(var(--qa) * 0.9), calc(var(--qa) * 0.5), 0) rotate(calc(var(--qr) * 0.6)); }
  80%  { transform: translate3d(calc(var(--qa) * -0.4), calc(var(--qa) * 0.7), 0) rotate(calc(var(--qr) * -0.8)); }
  100% { transform: translate3d(calc(var(--qa) * -1), calc(var(--qa) * 0.4), 0) rotate(calc(var(--qr) * -1)); }
}

.fx-q1, .fx-q2, .fx-q3, .fx-q4, .fx-q5 {
  will-change: transform;
  animation-name: fx-quake;
  animation-timing-function: linear;
  animation-iteration-count: infinite;
}

.fx-q1 { --qa: 1.5px; --qr: 0.08deg; animation-duration: 0.34s; }
.fx-q2 { --qa: 3px;   --qr: 0.28deg; animation-duration: 0.24s; }
.fx-q3 { --qa: 5.5px; --qr: 0.55deg; animation-duration: 0.17s; }
.fx-q4 { --qa: 9px;   --qr: 0.95deg; animation-duration: 0.13s; }
.fx-q5 { --qa: 14px;  --qr: 1.6deg;  animation-duration: 0.09s; }

/* A backdrop blur under a subtree that moves every frame is the one thing on
   this page that repaints continuously. Dropping the blur for the few seconds
   the room is shaking is invisible and costs nothing; keeping it is the
   difference between smooth and a slideshow on a phone. */
body.fx-rage .love-bar,
body.fx-rage .fs-btn,
body.fx-rage .fav,
body.fx-rage .sheet { backdrop-filter: none; }

/* --------------------------------------------------------- the glitch */

/* Horizontal tears, in the colour of whatever caused them. `screen` blending
   means they brighten what is under them rather than covering it, which reads
   as a signal problem instead of a sticker. */
.fx-slice {
  position: absolute;
  left: -6%;
  width: 112%;
  z-index: 2;
  mix-blend-mode: screen;
  will-change: transform, opacity;
  animation: fx-slice 0.3s steps(4, end) both;
}

/* Kept well under full strength. `screen` blending is additive, so a dozen of
   these overlapping at full opacity bleach the room to white and hide the
   wreckage they are supposed to be part of. */
@keyframes fx-slice {
  0%   { opacity: 0.6; transform: translate3d(calc(var(--sx) * -1), 0, 0); }
  50%  { opacity: 0.45; transform: translate3d(var(--sx), 0, 0); }
  100% { opacity: 0; transform: translate3d(0, 0, 0); }
}

/* Scanlines over the whole room. `overlay` keeps the picture readable — a
   solid layer this size would just dim everything. */
.fx-static {
  position: absolute;
  inset: -4px;
  z-index: 2;
  opacity: 0.5;
  mix-blend-mode: overlay;
  background: repeating-linear-gradient(
    0deg,
    rgba(255, 255, 255, 0.22) 0 1px,
    rgba(43, 29, 37, 0.1) 1px 3px
  );
  animation: fx-static 0.2s steps(2, end) infinite;
}

@keyframes fx-static {
  0%   { transform: translate3d(0, 0, 0); }
  50%  { transform: translate3d(-1px, -2px, 0); }
  100% { transform: translate3d(1px, 1px, 0); }
}

.fx-flash {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: var(--card);
  animation: fx-flash-out 0.26s ease-out both;
}

/* Only a `to` frame, so the implicit start is whatever opacity JS set inline —
   which is how one keyframe serves a quarter-strength blink at level two and a
   near-white one at the collapse. Named apart from the `flash` already in this
   file, which highlights a chat line someone jumped to. */
@keyframes fx-flash-out {
  to { opacity: 0; }
}

/* ---------------------------------------------------------- the glass */

.fx-cracks {
  position: absolute;
  inset: 0;
  z-index: 2;
  width: 100%;
  height: 100%;
  transition: opacity 0.6s ease;
}

.fx-cracks.gone { opacity: 0; }

.fx-crack {
  fill: none;
  stroke: rgba(255, 255, 255, 0.92);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* The dark halo is what makes a white line read as a split in glass rather
     than as a drawing on top of it. */
  filter: drop-shadow(0 0 3px rgba(43, 29, 37, 0.9));
  animation: fx-crack-draw 0.24s ease-out both;
}

/* --len is the measured path length, set from JS. */
@keyframes fx-crack-draw {
  from { stroke-dashoffset: var(--len); }
  to { stroke-dashoffset: 0; }
}

/* --------------------------------------------------------- the debris */

/* Above the app and the particles, below the toasts. Clipped at the viewport,
   so a piece that falls out of the window is gone rather than growing the
   page. */
.fx-debris-layer {
  position: fixed;
  inset: 0;
  z-index: 56;
  pointer-events: none;
  overflow: hidden;
}

.fx-debris {
  pointer-events: none;
  will-change: transform, opacity;
  animation: fx-fall 2.3s cubic-bezier(0.4, 0, 0.75, 1) both;
}

/* The small hop before the drop is the whole trick: it reads as something
   coming loose under load rather than something being deleted. */
@keyframes fx-fall {
  0%   { opacity: 1; transform: translate3d(0, 0, 0) rotate(0deg); }
  10%  { opacity: 1; transform: translate3d(calc(var(--dx) * 0.06), -16px, 0) rotate(calc(var(--rot) * 0.06)); }
  100% { opacity: 0.9; transform: translate3d(var(--dx), var(--fall), 0) rotate(var(--rot)); }
}

/* And the piece coming back. */
.fx-back { animation: fx-back 0.5s cubic-bezier(0.2, 1.5, 0.4, 1) both; }

@keyframes fx-back {
  from { opacity: 0; transform: translate3d(0, -12px, 0) scale(0.84); }
  to { opacity: 1; transform: none; }
}

/* -------------------------------------------------------- the collapse */

/* Ten wedges of the viewport thrown outwards. Each is a full-screen div with
   a triangular clip-path: a solid fill and a clip cost one cheap paint, where
   ten real screenshots of the page would cost ten. */
.fx-shard {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: linear-gradient(160deg, rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.08));
  will-change: transform, opacity;
  animation: fx-shard 1.3s cubic-bezier(0.3, 0.1, 0.6, 1) both;
}

@keyframes fx-shard {
  0%   { opacity: 0.75; transform: translate3d(0, 0, 0) rotate(0deg); }
  100% { opacity: 0; transform: translate3d(var(--dx), var(--dy), 0) rotate(var(--rot)); }
}

/* The room itself tips over and slides out of the bottom of the window. */
.fx-collapse {
  will-change: transform;
  animation: fx-collapse 2.3s cubic-bezier(0.55, 0, 0.85, 0.2) both;
}

@keyframes fx-collapse {
  0%   { transform: none; }
  6%   { transform: translate3d(0, -18px, 0) rotate(-1.4deg) scale(1.02); }
  18%  { transform: translate3d(0, 8px, 0) rotate(2.4deg); }
  38%  { transform: translate3d(0, 52px, 0) rotate(-5deg); }
  100% { transform: translate3d(0, 125vh, 0) rotate(17deg); opacity: 0.4; }
}

.fx-rebuild { animation: fx-rebuild 0.85s cubic-bezier(0.2, 1.35, 0.35, 1) both; }

@keyframes fx-rebuild {
  0%   { opacity: 0.4; transform: translate3d(0, 70vh, 0) rotate(9deg); }
  70%  { opacity: 1; transform: translate3d(0, -10px, 0) rotate(-0.8deg); }
  100% { opacity: 1; transform: none; }
}

/* ----------------------------------------------------------- the count */

/* High enough to clear the big centre emoji, low enough to stay under the
   toasts, which live at the very top. */
.fx-combo {
  position: absolute;
  left: 50%;
  top: max(74px, 11%);
  z-index: 3;
  transform: translateX(-50%);
  text-align: center;
  line-height: 1;
  transition: opacity 0.4s ease;
}

.fx-combo.gone { opacity: 0; }

.fx-combo .n {
  font-size: clamp(40px, 12vmin, 96px);
  font-weight: 900;
  color: var(--tint);
  -webkit-text-stroke: 2px rgba(255, 255, 255, 0.92);
  text-shadow: 0 8px 26px rgba(43, 29, 37, 0.42);
}

.fx-combo .lab {
  margin-top: 4px;
  font-size: 13px;
  font-weight: 800;
  letter-spacing: 0.4px;
  color: #fff;
  text-shadow: 0 2px 10px rgba(43, 29, 37, 0.8);
}

/* Past the cracks it stops sitting still. */
.fx-combo.hot .n { animation: fx-combo-hot 0.14s linear infinite; }

@keyframes fx-combo-hot {
  0%   { transform: translate3d(-2px, 1px, 0) rotate(-1deg); }
  50%  { transform: translate3d(2px, -1px, 0) rotate(1.2deg); }
  100% { transform: translate3d(-2px, 1px, 0) rotate(-1deg); }
}

/* Restarted from JS on every press, which is why it is on the inner number and
   not on the wrapper: the wrapper owns the translateX that centres it. */
.fx-combo.pop .n { animation: fx-combo-pop 0.3s cubic-bezier(0.2, 1.6, 0.4, 1); }
.fx-combo.pop.hot .n { animation: fx-combo-pop 0.3s cubic-bezier(0.2, 1.6, 0.4, 1), fx-combo-hot 0.14s linear infinite; }

@keyframes fx-combo-pop {
  0%   { transform: scale(1.5); }
  60%  { transform: scale(0.94); }
  100% { transform: scale(1); }
}

.fx-banner {
  position: absolute;
  left: 50%;
  top: 42%;
  z-index: 3;
  padding: 14px 26px;
  border-radius: 999px;
  background: rgba(43, 29, 37, 0.92);
  color: #fff;
  font-size: clamp(16px, 4.6vmin, 30px);
  font-weight: 900;
  white-space: nowrap;
  box-shadow: 0 16px 44px rgba(0, 0, 0, 0.5);
  animation: fx-banner 2.4s cubic-bezier(0.2, 1.4, 0.35, 1) both;
}

@keyframes fx-banner {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.4) rotate(-8deg); }
  14%  { opacity: 1; transform: translate(-50%, -50%) scale(1.08) rotate(2deg); }
  26%  { opacity: 1; transform: translate(-50%, -50%) scale(1) rotate(0deg); }
  82%  { opacity: 1; transform: translate(-50%, -50%) scale(1) rotate(0deg); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(1.1) rotate(0deg); }
}

/* Shaking the room, tearing the picture and dropping the furniture is exactly
   what motion sensitivity means. effects.js already skips all of it; these are
   here so a stale class from a mode change mid-mash cannot leave anything
   moving. The count and the caption still carry the whole message. */
@media (prefers-reduced-motion: reduce) {
  .fx-q1, .fx-q2, .fx-q3, .fx-q4, .fx-q5,
  .fx-collapse, .fx-rebuild, .fx-static, .fx-combo.hot .n { animation: none; }
  .fx-collapse, .fx-rebuild { transform: none; opacity: 1; }
}

/* --------------------------------------------------- blur, and the tablet
 *
 * Every one of these sits on top of the video, and `backdrop-filter` over a
 * playing picture is the most expensive thing a phone or a tablet can be asked
 * to draw: the frame has to be read back, blurred and composited again, sixty
 * times a second, for as long as somebody is live. It is also what takes the
 * video off the compositor's fast path entirely.
 *
 * On a mouse it is cheap and it is pretty, so it stays there. On touch it goes,
 * and the panel behind it is made a little more solid to make up for the frost
 * it loses. This is not a new idea in this file: `body.fx-rage` already drops
 * exactly these, for exactly this reason, and the comment there calls it the
 * difference between smooth and a slideshow.
 *
 * Last in the file on purpose: these override rules of the same weight further
 * up, so source order is what decides.
 */
@media (pointer: coarse) {
  .love-bar,
  .fs-btn,
  .sheet,
  .peek,
  .pick .fav {
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  .love-bar { background: rgba(43, 29, 37, 0.72); }
  .fs-btn { background: rgba(43, 29, 37, 0.78); }
  .pick .fav { background: rgba(43, 29, 37, 0.72); }
  .sheet { background: rgba(74, 53, 64, 0.62); }
  .peek { background: rgba(43, 29, 37, 0.82); }
}
