/* Light only, deliberately neutral. The mascots and avatars are the only
   colour. Every screen is exactly one viewport with the legal links on the
   floor. No external requests. */

/* Manrope, self-hosted so the page still makes no external requests.
   One variable file per subset covering 400 to 800, which is why the whole
   weight range costs 38KB rather than five separate files.

   font-display: swap means the system font renders first and is replaced. That
   swap changes the height of the copy, which is exactly the reflow the face
   layout waits for via document.fonts.ready and watches for with a
   ResizeObserver. Without those, faces would be positioned against the metrics
   of a font that is about to be replaced.

   Licensed under the SIL Open Font License, which permits self-hosting. */
@font-face {
  font-family: "Manrope";
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url("/assets/fonts/manrope-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
    U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "Manrope";
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url("/assets/fonts/manrope-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  --font: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI",
    Roboto, "Helvetica Neue", Arial, sans-serif;
  --bg: #fbfaf7;
  --ink: #15151a;
  --soft: #6a6a72;
  --line: #e3e1da;
  --line-strong: #cfcabd;
  --field: #fff;
  --tile: #efece5;
  --r: 12px;
}

* { box-sizing: border-box; }

/* Artwork is not for saving off the page. This will not stop devtools, but it
   stops the casual drag-to-desktop and long-press-to-save. */
img { -webkit-user-drag: none; user-select: none; -webkit-user-select: none; }
.face, .avatarbtn, .pick img, .phone { -webkit-touch-callout: none; }
html, body { height: 100%; }

/* The app pages are exactly one viewport by design, so nothing should scroll.
   Legal pages have no .screen and scroll normally.

   🔴 body only, deliberately. overflow:hidden on the documentElement makes the
   root unscrollable, and Chrome on Android disables pull-to-refresh when the
   root cannot scroll. That left a phone with no way to refresh the page at all,
   on a page whose whole point is a list that changes. The screens clip their
   own overflow and are capped at 100svh, so the document still never scrolls;
   it is simply no longer declared unscrollable. */
/* 🔴 Nothing here declares the viewport unscrollable, and that is the point.
   Overflow on <html> propagates to the viewport, but when <html> computes to
   `visible` the value is taken from <body> instead. So `body { overflow:
   hidden }` was still making the viewport `hidden`, which is exactly what
   disabled Chrome's pull-to-refresh. Moving the declaration from html to body
   changed nothing; only removing it does.

   The page still does not scroll, because every .screen is capped at 100svh
   and clips its own overflow, so the document never exceeds the viewport and
   there is nothing to scroll. 100svh is the *small* viewport, so when the
   address bar hides and the visual viewport grows, the content is shorter than
   the screen rather than taller. */
html:has(.screen) { overscroll-behavior-y: auto; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 16px/1.5 var(--font);
  -webkit-font-smoothing: antialiased;
}

.screen {
  min-height: 100vh;
  min-height: 100svh;
  max-height: 100svh;
  display: flex;
  flex-direction: column;
  padding: 1.1rem 1.25rem 0.9rem;
  overflow: hidden;
}

/* Phone gutters.
   1.25rem left nothing between a field's edge and the bezel, which on a real
   handset is where the thumb rests and the screen curves. The join screen gets
   more than the landing screen because a full-width input reaches the gutter on
   every row, where the landing copy is centred and only touches it on a long
   line. */
@media (max-width: 46rem) {
  #intro.screen { padding-left: 1.6rem; padding-right: 1.6rem; }
  #join.screen  { padding-left: 1.75rem; padding-right: 1.75rem; }
}
/* Narrowest handsets still need the room, but not at the cost of the layout:
   below this the form starts squeezing the two name buttons. */
@media (max-width: 22.5rem) {
  #intro.screen { padding-left: 1.3rem; padding-right: 1.3rem; }
  #join.screen  { padding-left: 1.4rem; padding-right: 1.4rem; }
}
.screen[hidden] { display: none; }

/* Cross-screen fade. The swap is instant in the DOM, so without this it reads
   as a jump cut. */
.screen { animation: fadeUp 0.34s cubic-bezier(0.22, 0.7, 0.3, 1) both; }
@keyframes fadeUp {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .screen, .face { animation: none !important; }
}

.body { flex: 1; display: flex; flex-direction: column; justify-content: center; min-height: 0; }
.wrap { width: 100%; max-width: 30rem; margin: 0 auto; }

/* The landing screen is centre-aligned. The join screen stays left-aligned,
   because centred form labels are hard to scan. */
#intro .wrap { max-width: 34rem; text-align: center; position: relative; z-index: 2; }
#intro .points { display: grid; gap: 0; text-align: center; }
#intro .points b { display: block; margin-bottom: 0.1rem; }
@media (min-width: 44rem) {
  #intro .points { grid-template-columns: repeat(3, 1fr); gap: 0 1.75rem; }
  #intro .points li { border-top: 1px solid var(--line); border-bottom: 0 !important; }
}
#intro .primary { max-width: 20rem; margin-left: auto; margin-right: auto; }

/* Root screen, given its room back. Same rule as the join form: the tight
   values are the base so the shortest phone still fits in one viewport, and
   the space is only spent where it exists. */
@media (min-height: 46rem) {
  #intro h1 { margin-bottom: 1.1rem; }
  #intro .lede { margin-bottom: 2.2rem; }
  #intro .points { margin-bottom: 2.2rem; }
  #intro .points li { padding: 0.85rem 0; }
  #intro .primary { margin-top: 0.5rem; padding: 0.95rem 1rem; }
}
@media (min-height: 54rem) {
  #intro .wrap { max-width: 44rem; }
  #intro h1 { margin-bottom: 1.5rem; }
  #intro .lede { margin-bottom: 3rem; font-size: 1.22rem; line-height: 1.6; }
  #intro .points { margin-bottom: 3rem; }
  #intro .points li { padding: 1.15rem 0; font-size: 1.02rem; }
  #intro .primary { font-size: 1.06rem; padding: 1.05rem 1rem; max-width: 22rem; }
}

/* Widest desktops. Everything steps up together: a headline that grows while
   the body stays put reads as a mistake rather than a hierarchy. */
@media (min-width: 70rem) and (min-height: 54rem) {
  #intro .wrap { max-width: 48rem; }
  #intro .lede { font-size: 1.3rem; }
  #intro .points li { font-size: 1.06rem; padding: 1.25rem 0; }
}

/* Ribbons.
   Eight anchors, not four: the corners plus the middle of each edge, two
   ribbons each, sixteen in total. Four corner boxes can only ever decorate
   corners, which left the long stretches of every edge bare.

   Each ribbon lies ALONG its stretch of edge and bows gently inward. An
   earlier version aimed them from the edge toward the centre, which read as
   spokes pointing at the headline and put ribbons through the copy.

   Geometry is generated by tools/ribbons.py. Filled outlines, not strokes: a
   stroke has one width, while a ribbon's width changes and passes through zero
   wherever it turns over. Each box is parked so its centre sits just inside the
   page edge, which is where the band ends up.

   z-index 0 keeps them above the background grid and below both the copy
   (z-index 2) and the faces (z-index 1). pointer-events: none so they can never
   swallow a click meant for an avatar. */
.ribbons {
  position: fixed; inset: 0;
  z-index: 0; pointer-events: none; overflow: hidden;
}
.ribbons svg { position: absolute; overflow: visible; width: 21rem; height: 21rem; }
/* 🔴 No `fill: none` here. These paths are filled and carry their own fill
   attribute, and a CSS declaration beats a presentation attribute. */
.ribbons path { shape-rendering: geometricPrecision; }

/* Desktop: four corners, ribbons fanning out of each. */
.ribbons.desk svg { width: 23.8rem; height: 23.8rem; }
.desk .rb-tl { top: -3.8rem; left: -3.8rem; }
.desk .rb-tr { top: -3.8rem; right: -3.8rem; }
.desk .rb-bl { bottom: -3.8rem; left: -3.8rem; }
.desk .rb-br { bottom: -3.8rem; right: -3.8rem; }

/* Phone: anchored along the edges. */
.mob .rb-tl { top: -7.25rem; left: -7.25rem; }
.mob .rb-tc { top: -7.25rem; left: 50%; margin-left: -10.5rem; }
.mob .rb-tr { top: -7.25rem; right: -7.25rem; }
.mob .rb-rc { right: -7.25rem; top: 50%; margin-top: -10.5rem; }
.mob .rb-br { bottom: -7.25rem; right: -7.25rem; }
.mob .rb-bc { bottom: -7.25rem; left: 50%; margin-left: -10.5rem; }
.mob .rb-bl { bottom: -7.25rem; left: -7.25rem; }
.mob .rb-lc { left: -7.25rem; top: 50%; margin-top: -10.5rem; }

/* One set at a time. Desktop is the default; the phone set takes over below
   46rem, which is the same breakpoint the rest of the page uses. */
.ribbons.mob { display: none; }
@media (max-width: 46rem) {
  .ribbons.desk { display: none; }
  .ribbons.mob { display: block; }
}

/* Each drifts on its own duration and delay. Matched durations sync into a
   pulse, which reads as animation rather than as air moving. */
.ribbons svg { animation: drift 21s ease-in-out infinite; }
.ribbons .rb-tc { animation-duration: 26s; animation-delay: -3s; }
.ribbons .rb-tr { animation-duration: 23s; animation-delay: -7s; }
.ribbons .rb-rc { animation-duration: 29s; animation-delay: -11s; }
.ribbons .rb-br { animation-duration: 25s; animation-delay: -15s; }
.ribbons .rb-bc { animation-duration: 31s; animation-delay: -5s; }
.ribbons .rb-bl { animation-duration: 27s; animation-delay: -19s; }
.ribbons .rb-lc { animation-duration: 24s; animation-delay: -9s; }
@keyframes drift {
  0%, 100% { transform: translate3d(0, 0, 0); }
  50%      { transform: translate3d(0, -9px, 0); }
}
@media (prefers-reduced-motion: reduce) { .ribbons svg { animation: none; } }

/* Phones: smaller boxes, and the left and right anchors go. On a narrow screen
   those two sit right against the copy with nowhere to bow into. */
@media (max-width: 46rem) {
  .mob svg { width: 13rem; height: 13rem; }
  /* 🔴 -6.5rem, not -4.5rem. The drawn ink reaches 0.917 of the box height
     below the box top, so with a 13rem box it ends 191px down. The copy is
     vertically centred, which means the shorter the phone the higher the
     headline sits: 148px down on a 844px screen but only 113px on a 640px one.
     At -4.5rem the ink ended at 119px and overlapped the headline on any phone
     under about 700px tall. It did not on mine, which is exactly why it took
     someone else's phone to find it. */
  .mob .rb-tl, .mob .rb-tr { top: -7.6rem; }
  .mob .rb-bl, .mob .rb-br { bottom: -4.5rem; }
  .mob .rb-tl, .mob .rb-bl { left: -4.5rem; }
  .mob .rb-tr, .mob .rb-br { right: -4.5rem; }
  .mob .rb-tc { top: -7.6rem; margin-left: -6.5rem; }
  .mob .rb-bc { bottom: -4.5rem; margin-left: -6.5rem; }
  .mob .rb-lc, .mob .rb-rc { display: none; }
}

/* Short phones. Measured, not guessed: the headline sits 93px down on a 700px
   screen and 148px down on an 844px one, while the ink of a 13rem box ends 87px
   down. That leaves only 8px of gap at 700px, which is not a margin, it is a
   coincidence waiting to break on the next handset. Below 768px the box shrinks
   as well, because no offset keeps a 13rem box clear there without pushing it
   entirely off screen. Smaller ribbons beat ribbons across the headline. */
@media (max-width: 46rem) and (max-height: 48rem) {
  .mob svg { width: 9rem; height: 9rem; }
  /* 🔴 -6.1rem, not -7rem. At -7rem the drawn ink ended 20px down the page,
     which on a handset with browser chrome is a sliver you cannot see, so the
     ribbons read as simply missing. This lands the band at about 34px: clear of
     the headline even on the shortest phone, where it starts 48px down, and
     still visibly a ribbon rather than a rumour. */
  .mob .rb-tl, .mob .rb-tr, .mob .rb-tc { top: -6.1rem; }
  .mob .rb-tl { left: -3rem; }
  .mob .rb-tr { right: -3rem; }
  .mob .rb-tc { margin-left: -4.5rem; }
}

/* The footer sits hard against the bottom edge on a phone, and a coloured band
   behind 12px legal text makes it harder to read. */
@media (max-width: 46rem), (max-height: 40rem) {
  .mob .rb-bl, .mob .rb-br, .mob .rb-bc { display: none; }
}

/* The footer is in normal flow, so without a stacking position of its own the
   ribbon layer at z-index 0 would paint over the legal links. */
#intro .foot { position: relative; z-index: 2; }

/* Background texture.
   Two 1px gradients tiled into a grid, drawn on the page rather than shipped
   as an image, so it stays a zero-request background. Kept near the floor of
   what is visible: it should register as paper having a surface, never as a
   pattern competing with the copy. The mask fades it out at the edges so the
   grid has no hard boundary. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  /* Negative, so it sits behind every screen without any of them needing a
     stacking context. At z-index 0 a positioned pseudo-element paints above
     ordinary in-flow content, which would have laid the grid over the copy. */
  z-index: -1;
  pointer-events: none;
  background-image:
    linear-gradient(to right, rgba(21, 21, 26, 0.028) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(21, 21, 26, 0.028) 1px, transparent 1px);
  background-size: 68px 68px;
  -webkit-mask-image: radial-gradient(ellipse 90% 75% at 50% 45%, #000 55%, transparent 100%);
  mask-image: radial-gradient(ellipse 90% 75% at 50% 45%, #000 55%, transparent 100%);
}

/* The vw term is what makes this scale on a wide desktop rather than sitting
   at a phone size in the middle of a large screen. The floor keeps it readable
   on a 320px device; the ceiling stops it becoming a poster. */
h1 { font-size: clamp(2.1rem, 5.2vw, 3.9rem); line-height: 1.06; letter-spacing: -0.032em; margin: 0 0 0.75rem; }
/* Sized against the controls below it. At 2rem the heading was smaller than
   the name buttons were tall, which is what made the form read as bottom-heavy
   and the title as an afterthought. */
h2 { font-size: clamp(1.75rem, 3.4vw, 2.4rem); line-height: 1.12; letter-spacing: -0.03em; margin: 0 0 0.3rem; }
.lede { color: var(--soft); margin: 0 0 1.3rem; }
.small { font-size: 0.9rem; margin-bottom: 1.1rem; }

.points { list-style: none; padding: 0; margin: 0 0 1.3rem; }
.points li { padding: 0.55rem 0; border-top: 1px solid var(--line); font-size: 0.93rem; }
.points li:last-child { border-bottom: 1px solid var(--line); }
.points span { color: var(--soft); }

/* Scattered faces.
   Positioned by a jittered grid in JS: one cell per person, random point
   inside each cell. Random-looking, but evenly covered at any headcount. */

.scatter { position: fixed; inset: 0; z-index: 1; pointer-events: none; overflow: hidden; }

.face {
  position: absolute; pointer-events: auto;
  width: 46px; height: 46px; border-radius: 50%;
  background: var(--tile);
  border: 2px solid var(--bg);
  box-shadow: 0 3px 10px rgba(0,0,0,0.07);
  /* 🔴 The resting state is visible, and the animation only supplies the
     entrance. It used to be the other way round: opacity started at 0 and only
     the `pop` animation's forwards fill made the face appear.

     That made the face's visibility depend on one specific animation still
     being attached, which two other rules quietly break.

     `.face.spotlight` sets its own `animation`, replacing `pop` entirely, so
     highlighting someone's face made it vanish while its tooltip stayed. That
     is the "my avatar disappears when I come back to the page" bug.

     `prefers-reduced-motion` sets `animation: none`, which made every face
     invisible for anyone who has that turned on.

     `both` holds the from-state during the per-face delay, so they still
     arrive one after another rather than all being visible up front. */
  opacity: 1; transform: none;
  animation: pop 0.45s cubic-bezier(0.2, 0.9, 0.3, 1.3) both;
  transition: transform 0.16s ease, box-shadow 0.16s ease;
}
.face img { width: 100%; height: 100%; display: block; border-radius: 50%; }
.face:hover { transform: scale(1.16); box-shadow: 0 6px 18px rgba(0,0,0,0.14); z-index: 3; }

@keyframes pop { from { opacity: 0; transform: scale(0.8); } }

/* 🔴 One rule, keyed on width only, and it must match `size` in layoutFaces.
   There used to be a second rule keyed on height, so a short wide window drew
   28px faces while the placement maths worked in 46px. Placement computed
   against one box while the browser draws another is a bug that only shows up
   on the window sizes nobody tests. */
@media (max-width: 40rem) { .face { width: 26px; height: 26px; } }

/* Short viewports: the copy alone nearly fills the screen, so everything
   tightens rather than letting the page scroll. The one-viewport rule matters
   more than the breathing room. */
@media (max-height: 640px) {
  #intro h1 { font-size: 1.55rem; margin-bottom: 0.5rem; }
  #intro .lede { font-size: 0.9rem; margin-bottom: 0.8rem; }
  #intro .points { margin-bottom: 0.85rem; }
  #intro .points li { padding: 0.35rem 0; font-size: 0.84rem; }
  .screen { padding: 0.8rem 1rem 0.7rem; }
  .foot { padding-top: 0.6rem; gap: 0.8rem; font-size: 0.72rem; }
  button.primary { padding: 0.7rem 1rem; margin-top: 0.7rem; }
}

/* Toasts.
   All validation feedback lives here, never as inline text beside a field:
   inline messages shift the layout and read as unfinished. */

.toasts {
  position: fixed; z-index: 300;
  left: 50%; transform: translateX(-50%);
  bottom: 1.25rem;
  display: flex; flex-direction: column-reverse; gap: 0.5rem;
  /* column-reverse puts the newest toast at the top of a bottom-anchored
     stack, nearest the eye. The phone rule below flips both. */
  width: calc(100% - 2.5rem); max-width: 24rem;
  pointer-events: none;
}

.toast {
  pointer-events: auto;
  display: flex; align-items: flex-start; gap: 0.6rem;
  background: var(--ink); color: #fff;
  padding: 0.7rem 0.85rem; border-radius: 11px;
  box-shadow: 0 10px 30px rgba(0,0,0,0.2);
  font-size: 0.88rem; line-height: 1.35;
  animation: toastIn 0.26s cubic-bezier(0.2, 0.9, 0.3, 1.2) both;
}
.toast.out { animation: toastOut 0.2s ease forwards; }
.toast .dot {
  flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%;
  background: #ff8a80; margin-top: 0.42rem;
}
.toast[data-kind="ok"] .dot { background: #6ee7a8; }

@keyframes toastIn  { from { opacity: 0; transform: translateY(12px) scale(0.97); } }
@keyframes toastOut { to   { opacity: 0; transform: translateY(8px) scale(0.98); } }

/* 🔴 Phones put toasts at the top.
   At the bottom they sit exactly where the software keyboard appears, and
   every validation message fires while a field is focused, which is precisely
   when the keyboard is up. The message the user most needs to read is the one
   most likely to be hidden.

   Anchoring to the top unconditionally rather than reacting to the keyboard:
   there is no reliable cross-browser event for the keyboard opening, and a
   toast that moves depending on focus state is worse than one that is always
   in the same place. */
@media (max-width: 46rem) {
  .toasts {
    /* transform is set from JS to follow the visual viewport, so the base
       translate lives here and JS overwrites the whole property. */
    top: 1rem; bottom: auto;
    /* Plain column, so the newest is at the bottom of a top-anchored stack,
       again nearest the eye. */
    flex-direction: column;
  }
  .toast { animation-name: toastInTop; }
  .toast.out { animation-name: toastOutTop; }
}
@keyframes toastInTop  { from { opacity: 0; transform: translateY(-12px) scale(0.97); } }
@keyframes toastOutTop { to   { opacity: 0; transform: translateY(-8px) scale(0.98); } }

@media (prefers-reduced-motion: reduce) {
  .toast, .toast.out { animation: none; }
}

/* Reusable tooltip.
   position:fixed with JS coordinates, so no ancestor overflow or stacking
   context can clip it, and it is clamped to the viewport near an edge. */

.tt {
  /* Parked off-screen until positioned. Without this the unpositioned element
     sat past the viewport bottom and added 8px of scrollable page. */
  position: fixed; top: -999px; left: -999px;
  z-index: 400; pointer-events: none;
  background: var(--ink); color: #fff;
  font-size: 0.75rem; font-weight: 500; line-height: 1.4;
  padding: 0.4rem 0.6rem; border-radius: 8px;
  max-width: 16rem;
  opacity: 0; transform: translateY(3px);
  transition: opacity 0.13s ease, transform 0.13s ease;
  box-shadow: 0 8px 22px rgba(0,0,0,0.18);
}
.tt[data-show="1"] { opacity: 1; transform: none; }
.tt .arrow {
  position: absolute; width: 8px; height: 8px; background: var(--ink);
  transform: rotate(45deg);
}

/* Flexing the label is what actually aligns this. vertical-align guesswork
   never lines a circle up with a cap-height baseline. */
label:has(.info) { display: flex; align-items: center; gap: 0.32rem; }

.info {
  display: grid; place-items: center;
  width: 15px; height: 15px; flex: 0 0 auto; padding: 0;
  border-radius: 50%; border: 1px solid var(--soft); color: var(--soft);
  font-size: 9px; font-weight: 700; font-style: normal; line-height: 1;
  cursor: pointer; background: none;
}
.info:hover, .info:focus-visible { border-color: var(--ink); color: var(--ink); }

/* Fields */

.field { margin-bottom: 0.8rem; }
label { display: block; font-size: 0.82rem; font-weight: 600; margin-bottom: 0.3rem; }

input[type="text"], input[type="email"], .combo-btn {
  width: 100%; padding: 0.72rem 0.85rem;
  font: inherit; font-size: 1rem; color: var(--ink);
  background: var(--field); border: 1px solid var(--line);
  border-radius: 10px; appearance: none;
}
input:focus-visible, button:focus-visible, .combo-btn:focus-visible {
  outline: 2px solid var(--ink); outline-offset: 1px;
}

/* Name row */

.namerow { display: flex; gap: 0.75rem; align-items: flex-end; }
.namerow .grow { flex: 1; min-width: 0; margin: 0; }

.avatarbtn {
  position: relative; flex: 0 0 auto;
  width: 64px; height: 64px; padding: 0;
  border: 1px solid var(--line); border-radius: 50%;
  background: var(--tile); cursor: pointer;
}
.avatarbtn img {
  width: 100%; height: 100%; display: block; border-radius: 50%;
}
/* Persistent badge, not a hover-only overlay. Discoverable and it never looks
   half-painted the way a partial hover state did. */
.avatarbtn .badge {
  position: absolute; right: -2px; bottom: -2px;
  width: 24px; height: 24px; border-radius: 50%;
  background: var(--ink); border: 2px solid var(--bg);
  display: grid; place-items: center;
  transition: transform 0.14s ease;
}
.avatarbtn .badge svg { width: 12px; height: 12px; display: block; }
.avatarbtn:hover .badge, .avatarbtn:focus-visible .badge { transform: rotate(-90deg) scale(1.08); }
/* No opacity dip on swap. The next avatar is already in cache, so the only
   thing a fade added was a flicker. The badge spin carries the feedback. */
.avatarbtn:active .badge { transform: rotate(-180deg) scale(0.95); }

/* Custom country combobox */

.combo { position: relative; }
.combo-btn {
  text-align: left; cursor: pointer; display: flex; align-items: center; gap: 0.5rem;
}
.combo-btn .val { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.combo-btn .val[data-empty="1"] { color: var(--soft); }
.combo-btn .chev { flex: 0 0 auto; width: 12px; height: 8px; transition: transform 0.15s ease; }
.combo[data-open="1"] .combo-btn .chev { transform: rotate(180deg); }
.combo[data-open="1"] .combo-btn { border-color: var(--line-strong); }

/* position: fixed, with coordinates set in JS. The .screen ancestor has
   overflow:hidden to hold the one-viewport rule, which was clipping an
   absolutely positioned popup and made the whole control look broken. */
.combo-pop {
  position: fixed; z-index: 100; display: none;
  background: var(--field); border: 1px solid var(--line-strong);
  border-radius: var(--r); box-shadow: 0 16px 34px rgba(0,0,0,0.13);
  overflow: hidden;
}
.combo[data-open="1"] .combo-pop { display: block; }

.combo-search {
  width: 100%; padding: 0.7rem 0.85rem; font: inherit; font-size: 0.95rem;
  color: var(--ink); border: 0; border-bottom: 1px solid var(--line);
  outline: none; background: var(--field); border-radius: 0;
}
.combo-search::placeholder { color: var(--soft); }
.combo-search:focus, .combo-search:focus-visible { outline: none; box-shadow: none; }
.combo-list { max-height: 190px; overflow-y: auto; margin: 0; padding: 0.25rem; list-style: none; }
.combo-list li {
  padding: 0.5rem 0.6rem; border-radius: 8px; cursor: pointer; font-size: 0.95rem;
}
/* Flat 4x3 flags. The hairline matters: several flags are mostly white, and
   without a border they dissolve into the field background. */
.cflag {
  width: 20px; height: 15px; border-radius: 2px;
  object-fit: cover; display: inline-block; vertical-align: -2px;
  margin-right: 0.55rem; flex: 0 0 auto;
  box-shadow: 0 0 0 1px rgba(21, 21, 26, 0.10);
  background: var(--tile);
}
.combo-btn .val { display: flex; align-items: center; }
.combo-list li { display: flex; align-items: center; }
.combo-list li[aria-selected="true"] { font-weight: 600; }
.combo-list li.active { background: var(--tile); }
.combo-empty { padding: 0.75rem 0.8rem; color: var(--soft); font-size: 0.9rem; }

/* Mascot picker */

.pick { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; }
.pick label {
  margin: 0; cursor: pointer; padding: 0.6rem 0.5rem;
  background: var(--field); border: 1px solid var(--line); border-radius: var(--r);
  display: flex; align-items: center; gap: 0.55rem; justify-content: center;
}
.pick label:has(input:checked) { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
.pick label:focus-within { outline: 2px solid var(--ink); outline-offset: 1px; }
.pick input { position: absolute; opacity: 0; pointer-events: none; }
.pick img { width: 40px; height: 40px; border-radius: 10px; background: var(--tile); display: block; }
.pick b { font-size: 1rem; }

/* Room to breathe.
 *
 * The one-viewport rule (.screen is capped at 100svh and hides overflow) is
 * what kept this form tight: every value had to fit the shortest phone. So the
 * tight values stay as the base and the room is spent only once the viewport
 * is genuinely tall enough, rather than pushing the submit button off-screen
 * on a small device to make a desktop look better.
 *
 * The two thresholds are measured, not guessed. The roomy layout needs 747px
 * of content, the roomiest about 802px, so they switch on at 50rem and 56rem
 * with enough slack that a different font stack cannot tip either into
 * clipping the Join button behind the footer.
 */
/* 🔴 :not(.grow) matters. .namerow .grow is the name field nested inside the
   name row, and it must keep margin: 0 so the input's bottom edge sits level
   with the avatar beside it. An earlier `#join .field` here was id + class and
   so out-specified `.namerow .grow`, which gave that inner field a bottom
   margin and lifted the input off the avatar. That was the misalignment. */
/* Controls are deliberately smaller than the room allows. Oversized inputs
   with matching gaps made the column tall and loose, and left the phone beside
   it looking like a different design. Shorter fields, proportional gaps. */
@media (min-height: 50rem) {
  #join .lede { margin-bottom: 1.5rem; }
  #join .field:not(.grow) { margin-bottom: 0.9rem; }
  #join label { margin-bottom: 0.35rem; }
  #join .namerow { gap: 0.7rem; }
  #join input[type="text"], #join input[type="email"], #join .combo-btn {
    padding: 0.62rem 0.8rem; font-size: 0.95rem;
  }
  /* Sized to span the label and the input together, the way it already does
     on a phone, rather than matching the input alone. Matching the input made
     it read as an afterthought stuck to the side of the field. */
  #join .avatarbtn { width: 60px; height: 60px; }
  /* The badge is a fixed 24px, so on a smaller avatar it covered a real part of
     the face. Scaled with the avatar instead of sitting on top of it. */
  #join .avatarbtn .badge { width: 20px; height: 20px; }
  #join .avatarbtn .badge svg { width: 10px; height: 10px; }
  #join .pick { gap: 0.6rem; }
  #join .pick label { padding: 0.6rem 0.6rem; gap: 0.6rem; }
  #join .pick img { width: 34px; height: 34px; border-radius: 9px; }
  #join .pick b { font-size: 0.95rem; }
  #join button.primary { margin-top: 1.1rem; padding: 0.78rem 1rem; }
}

/* Taller still. Spends the remaining room on the gaps between rows rather than
   on the controls: past a point, bigger buttons stop reading as generous and
   start reading as oversized next to the heading. */
@media (min-height: 56rem) {
  #join h2 { margin-bottom: 0.4rem; }
  #join .lede { margin-bottom: 1.8rem; }
  #join .field:not(.grow) { margin-bottom: 1.05rem; }
  #join .avatarbtn { width: 64px; height: 64px; }
  #join .avatarbtn .badge { width: 21px; height: 21px; }
  #join .avatarbtn .badge svg { width: 10px; height: 10px; }
  #join .pick label { padding: 0.7rem 0.65rem; }
  #join .pick img { width: 38px; height: 38px; border-radius: 10px; }
  #join button.primary { margin-top: 1.3rem; }
}

/* Turnstile.
   The widget is an iframe Cloudflare owns, so essentially nothing inside it can
   be styled. What can be controlled is the box it sits in, and that is enough:
   `flexible` sizing spans the column exactly like every input above it, which
   is the difference between a check that belongs to the form and one bolted to
   the side of it.

   The height is reserved before the widget exists. Turnstile renders a moment
   after the screen opens, and without a reserved box the Join button would jump
   down at the exact moment someone is reaching for it. */
.ts {
  margin-top: 1rem;
  min-height: 65px;
}
.ts iframe { display: block; }

/* `flexible` spans its container but refuses to go below 300px, and on a 320px
   screen this column is 275px, so the widget would hang 25px off the right edge
   and push the whole page sideways. Scaled down to fit instead. The transform
   does not shrink the layout box, hence the negative margin to take back the
   height the scale no longer uses. Only 320px-wide phones reach this; anything
   375px and up has room for the widget at full size. */
@media (max-width: 21.25rem) {
  .ts {
    transform: scale(0.9);
    transform-origin: left top;
    margin-bottom: -7px;
  }
}

/* Buttons */

button.primary {
  width: 100%; margin-top: 1rem; padding: 0.85rem 1rem;
  font: inherit; font-size: 1rem; font-weight: 600; color: #fff;
  background: var(--ink); border: 0; border-radius: 10px; cursor: pointer;
}
button.primary:hover { opacity: 0.88; }
button.primary:disabled { opacity: 0.45; cursor: default; }

.back {
  align-self: flex-start; background: none; border: 0; padding: 0.1rem 0;
  font: inherit; font-size: 0.85rem; color: var(--soft); cursor: pointer;
}
.back:hover { color: var(--ink); }

/* Field-level error styling. The message itself goes in a toast; this is only
   the visual marker on the offending input. */
.invalid, .combo[data-invalid="1"] .combo-btn { border-color: #b3261e !important; }

/* A toast names the problem; the shake points at it. Small and quick: enough
   to draw the eye, not enough to feel like an error state. */
.shake { animation: shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97); }
@keyframes shake {
  10%, 90% { transform: translateX(-2px); }
  20%, 80% { transform: translateX(3px); }
  30%, 50%, 70% { transform: translateX(-5px); }
  40%, 60% { transform: translateX(5px); }
}
@media (prefers-reduced-motion: reduce) { .shake { animation: none; } }

/* Done */

.done { text-align: center; }
.done .tick {
  width: 46px; height: 46px; margin: 0 auto 0.9rem; border-radius: 50%;
  border: 1.5px solid var(--ink); display: grid; place-items: center;
  color: var(--ink);
}
.done .tick svg { width: 22px; height: 22px; display: block; }

.back-avatar {
  width: 72px; height: 72px; border-radius: 50%; display: block;
  margin: 0 auto 0.9rem; background: var(--tile); border: 1px solid var(--line);
}
.done .primary { max-width: 18rem; margin-left: auto; margin-right: auto; }

/* Confirmation states drop the phone.
   The mockup answers "what would this look like on my phone", which is a
   question the form asks while you are still choosing a name. Once you are on
   the list it answers nothing, and because the phone is now sized from the
   column's height, a short confirmation panel shrank it to a thumbnail. One
   centred column reads as finished; a tiny phone next to it reads as broken.

   The rule is on the screen, not on each panel, so every confirmation state
   gets it without anyone remembering to add a class. */
#join[data-mode="done"] .stage { display: none; }
#join[data-mode="done"] .wrap.split {
  display: block;
  max-width: 32rem;
}

/* Ring pulse when a returning visitor is shown their own face. */
.face.spotlight { z-index: 5; animation: spotlight 2.4s ease-out; }
@keyframes spotlight {
  0%, 100% { box-shadow: 0 3px 10px rgba(0,0,0,0.07); }
  15%, 60% { box-shadow: 0 0 0 6px rgba(21,21,26,0.16), 0 6px 18px rgba(0,0,0,0.16); }
}
@media (prefers-reduced-motion: reduce) { .face.spotlight { animation: none; box-shadow: 0 0 0 4px rgba(21,21,26,0.25); } }

/* Phone mockup, wide screens only */

.stage { display: none; }
@media (min-width: 58rem) and (min-height: 36rem) {
  .wrap.split {
    max-width: 60rem;
    display: grid; grid-template-columns: 1fr 17rem; gap: 5rem;
    /* stretch, not center: the phone column is told to be exactly as tall as
       the form column, which is what makes their tops and bottoms line up. */
    align-items: stretch;
  }
  .stage { display: flex; flex-direction: column; min-height: 0; position: relative; }
  .phonebox {
    flex: 1; min-height: 0;
    /* Turns this box into a query container measured on both axes, so the
       phone below can size itself from the form's height in cqh units. */
    container-type: size;
    display: flex; align-items: center; justify-content: center;
  }
}

/* The phone is sized entirely in em, with one font-size at the top driving
   every internal dimension. That is what lets it match the form's height
   without a transform: no blurring, and the island, icon grid and labels all
   scale in proportion rather than drifting.
   1em = 10px reproduces the original 244x496 exactly, which is the fallback
   for anything without container query units. */
.phone {
  font-size: 10px;
  width: 24.4em; height: 49.6em; margin: 0 auto;
  border-radius: 4.4em; padding: 1em;
  background: linear-gradient(150deg, #55555c 0%, #2a2a30 22%, #1a1a1e 60%, #3a3a42 100%);
  box-shadow: 0 24px 54px rgba(0,0,0,0.22), 0 2px 5px rgba(0,0,0,0.3);
}
.phone .glass {
  position: relative; height: 100%; border-radius: 3.5em; overflow: hidden;
  background: linear-gradient(168deg, #5b6b86 0%, #46536c 38%, #333d52 70%, #232a3a 100%);
}
.phone .island {
  position: absolute; top: 10px; left: 50%; transform: translateX(-50%);
  width: 7em; height: 2.1em; border-radius: 1.2em; background: #1c1c1f;
}
.phone .grid {
  position: absolute; inset: 5.2em 1.8em auto; display: grid;
  grid-template-columns: repeat(4, 1fr); gap: 1.5em 1.2em;
}
.phone .app .ic {
  width: 100%; aspect-ratio: 1; border-radius: 1.2em;
  background: rgba(255,255,255,0.16);
  box-shadow: inset 0 1px 0 rgba(255,255,255,0.12);
}
.phone .app .lb {
  font-size: 0.7em; text-align: center; margin-top: 0.4em;
  color: rgba(255,255,255,0.82); text-shadow: 0 1px 2px rgba(0,0,0,0.35);
}
.phone .app.ours .ic {
  background: var(--tile) center / cover no-repeat;
  box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.phone .app.ours .lb { font-weight: 700; color: #fff; }
/* Out of flow on purpose. In flow it took ~47px off the phone's height, so the
   phone came up short of the form beside it. Absolute means the phone gets the
   full column height and the caption hangs in the clear space below, where
   there is 135px before the footer. */
.stage .caption {
  text-align: center; font-size: 0.8rem; color: var(--soft);
  position: absolute; top: 100%; left: 0; right: 0; margin: 0.85rem 0 0;
}

/* Drive the phone from the container. 100/49.6 = 2.0161cqh makes it exactly
   the container's height; the cqw term stops it overflowing the 17rem column
   when the form is tall. min() takes whichever limit binds first. */
@supports (container-type: size) {
  @media (min-width: 58rem) and (min-height: 36rem) {
    .phonebox .phone { font-size: min(2.0161cqh, 4.098cqw); }
  }
}

/* Footer, on the floor and aligned with the content above it */

.foot {
  margin-top: auto; padding-top: 0.9rem;
  border-top: 1px solid var(--line);
  display: flex; flex-wrap: wrap; gap: 1.1rem; justify-content: center;
  font-size: 0.78rem;
  width: 100%; max-width: 30rem; margin-left: auto; margin-right: auto;
}
/* Matches .wrap.split, so the footer rule lines up with the content above it
   rather than stopping short of it. */
.screen#join .foot { max-width: 60rem; }
.foot a { color: var(--soft); text-decoration: none; }
.foot a:hover { color: var(--ink); text-decoration: underline; }

/* Legal pages scroll normally. They are documents, not screens. */

.legal { max-width: 46rem; margin: 0 auto; padding: 3.5rem 1.5rem 1rem; }
.legal p, .legal li { max-width: 42rem; }
.legal ul { padding-left: 1.15rem; }
.legal li { margin-bottom: 0.35rem; }
.legal h1 { font-size: 1.7rem; }
.legal h2 { font-size: 1.05rem; margin: 2rem 0 0.4rem; }
.legal .updated { color: var(--soft); font-size: 0.85rem; }
.legal a { color: var(--ink); }

/* Legal pages scroll, so their footer sits in flow rather than on the floor
   of a fixed screen. It is centred like every other footer on the site. */
.legal-foot {
  max-width: 46rem; margin: 3rem auto 0; padding: 1.1rem 1.5rem 2.5rem;
  border-top: 1px solid var(--line);
  display: flex; flex-wrap: wrap; gap: 1.1rem; justify-content: center;
  font-size: 0.78rem;
}
.legal-foot a { color: var(--soft); text-decoration: none; }
.legal-foot a:hover { color: var(--ink); text-decoration: underline; }
