/* =========================================================================
   Fortnite Teams — the whole stylesheet.

   The sister product (Sprites Vault) is navy #070a1c, cyan + gold, Anton
   condensed italic, sticky TOP bar, card grid. This is deliberately none of
   those: graphite, one warm signal, Archivo set WIDE, IBM Plex Mono for
   data, fixed LEFT rail. Two products that share a database should not share
   a face — someone landing here from a share link needs to know which one
   they are on before they read a word.

   THREE RULES THIS FILE KEEPS
   ---------------------------
   1. BRAND COLOUR IS NOT STATE COLOUR. --flare is the brand and marks
      actions; the presence lamps are conventional green / amber / grey.
      Nobody should have to learn a colour code to read a roster, so novelty
      is spent on the chrome and never on the states.

   2. "COULDN'T CHECK" IS NOT "OFFLINE". The unknown lamp is a HATCH, not a
      colour — it reads as absent information rather than as a fourth state
      on the same scale. This is the one visual idea the product actually
      turns on (core/service.py is emphatic that a failed presence burst must
      not be reported as offline), so it gets its own texture.

   3. STALENESS IS SHOWN, NOT HIDDEN. Status is a snapshot a bot writes every
      ~30s, so .decay drains between checks. A live-looking dot over
      minute-old data would be a lie the layout tells for free.

   Dark only, on purpose, and painted explicitly — there is no light variant
   to fall back to, so `body` sets its own ground rather than inheriting one.
   ========================================================================= */

@import url("./fonts.aa2ea9527f25.css");

:root {
  /* ---- ground ---- */
  --ground: #0F1215;
  --deck: #161A1F;   /* rail, cards */
  --raised: #1C2127; /* hover, inputs */
  --rule: #2A3138;

  /* ---- ink ---- */
  --chalk: #E8EBEE;
  --ash: #8B959F;
  --faint: #5A636C;

  /* ---- brand ---- */
  --flare: #FF4D2E;
  --flare-lift: #FF6647;
  --flare-ink: #12080A;  /* text ON flare — near-black, not white: #fff on
                            this orange is 2.9:1 and fails AA outright */

  /* ---- state (conventional, never reused as brand) ---- */
  --lamp-on: #3DDC84;
  --lamp-away: #F0A22E;
  --lamp-off: #454D55;

  --rail-w: 17rem;
  --measure: 62rem;

  /* One easing for the whole product. Data surfaces should settle rather
     than bounce — no overshoot, and a tail long enough to be seen finishing. */
  --ease-settle: cubic-bezier(.22, .68, .3, 1);

  --font-display: "Archivo", system-ui, -apple-system, sans-serif;
  --font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Width axis settings, named so a heading never carries a magic number.
     The width axis is the whole typographic idea here; losing it to a
     find-and-replace would flatten the design into a default grotesque. */
  --wide-brand: 118;
  --wide-head: 122;
  --wide-hero: 124;
}

* { box-sizing: border-box; }

html {
  /* Dark-only site, told to the browser so native controls — the time and
     number inputs in the notifications dialog, select dropdowns, scrollbars —
     render dark instead of arriving as white patches. */
  color-scheme: dark;
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

body {
  margin: 0;
  background: var(--ground);
  color: var(--chalk);
  font-family: var(--font-display);
  font-variation-settings: "wdth" 100;
  font-size: 15px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

a { color: inherit; }
button { font: inherit; color: inherit; }
code, kbd { font-family: var(--font-mono); font-size: .92em; }

/* One focus treatment, everywhere. :focus-visible only, so a mouse click
   doesn't leave a ring behind but a Tab key always does. */
:focus-visible {
  outline: 2px solid var(--flare);
  outline-offset: 2px;
  border-radius: 3px;
}

.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}

/* Skip link — the rail is a long list of squads, so a keyboard user must be
   able to get past it. Visible only when focused. */
.skip {
  position: fixed; top: .5rem; left: .5rem; z-index: 100;
  transform: translateY(-200%);
  background: var(--flare); color: var(--flare-ink);
  padding: .5rem .9rem; border-radius: 6px;
  font-weight: 600; text-decoration: none;
}
.skip:focus { transform: none; }

/* =========================================================================
   The rail
   ========================================================================= */

.rail {
  position: fixed;
  inset: 0 auto 0 0;
  width: var(--rail-w);
  background: var(--deck);
  border-right: 1px solid var(--rule);
  display: flex;
  flex-direction: column;
  padding: 1.25rem 0 .75rem;
  z-index: 20;
}

.rail-brand {
  display: flex; align-items: center; gap: .6rem;
  padding: 0 1.1rem 1.15rem;
  text-decoration: none;
}
.rail-brand svg { flex: none; }
.rail-brand-name {
  font-variation-settings: "wdth" var(--wide-brand);
  font-weight: 800; font-size: .98rem; line-height: 1.05;
  letter-spacing: .055em; text-transform: uppercase;
}
.rail-brand-name em { display: block; font-style: normal; color: var(--flare); }

.rail-label {
  font-family: var(--font-mono);
  font-size: .66rem; font-weight: 500;
  letter-spacing: .16em; text-transform: uppercase;
  color: var(--faint);
  padding: 0 1.1rem; margin: .35rem 0 .55rem;
  display: flex; justify-content: space-between; align-items: center;
}

.rail-scroll { flex: 1; overflow-y: auto; min-height: 0; }
.rail-foot {
  border-top: 1px solid var(--rule);
  margin-top: .75rem; padding-top: .75rem;
}

/* A rail row: squad entries and plain nav links share it, because they are
   the same affordance and drifting them apart is how a sidebar starts
   looking like two sidebars. */
.rail-row {
  display: flex; align-items: center; gap: .6rem;
  width: 100%;
  background: none; border: 0; cursor: pointer;
  padding: .5rem 1.1rem;
  text-align: left; text-decoration: none;
  border-left: 2px solid transparent;
  transition: background .12s, border-color .12s;
  color: var(--ash);
}
.rail-row:hover { background: var(--raised); color: var(--chalk); }
.rail-row[aria-current="page"] {
  background: var(--raised);
  border-left-color: var(--flare);
  color: #fff;
}
.rail-row-name {
  flex: 1; min-width: 0;
  font-size: .9rem; font-weight: 600;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.rail-row-count {
  font-family: var(--font-mono); font-size: .72rem;
  color: var(--faint); font-variant-numeric: tabular-nums;
}
.rail-row-count b { font-weight: 500; color: var(--lamp-on); }
.rail-row-count.is-quiet b { color: var(--faint); }

.rail-empty {
  padding: .2rem 1.1rem 0;
  font-size: .82rem; color: var(--faint);
}

/* Which page you are on, marked in CSS rather than by a template
   conditional, so it is right in the first frame with no JS at all. One rule,
   keyed on the body's data-page and the link's own href — findable from the
   element in devtools, which a template dialect is not. js/site-chrome.js
   adds the matching aria-current for screen readers once it loads. */
body[data-page="home"] .rail-row[href="/"],
body[data-page="privacy"] .rail-row[href="/privacy"],
body[data-page="terms"] .rail-row[href="/terms"] {
  background: var(--raised);
  border-left-color: var(--flare);
  color: #fff;
}

/* =========================================================================
   Lamps — the state vocabulary
   ========================================================================= */

.lamp {
  width: 8px; height: 8px; border-radius: 50%;
  flex: none; background: var(--lamp-off);
  transition: background .25s, box-shadow .25s;
}
.lamp--on { background: var(--lamp-on); box-shadow: 0 0 0 3px #3ddc8422; }
.lamp--away { background: var(--lamp-away); box-shadow: 0 0 0 3px #f0a22e1f; }
/* Rule 2. Hatched, not coloured: this is missing information, not a state.
   `background-image` over a transparent background so it reads as an empty
   socket rather than as a dimmer version of "offline". */
.lamp--unknown {
  background: transparent;
  border: 1px solid var(--faint);
  background-image: repeating-linear-gradient(
    45deg, var(--faint) 0 1px, transparent 1px 3px
  );
}

/* =========================================================================
   Buttons
   ========================================================================= */

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: .45rem;
  font-size: .85rem; font-weight: 600;
  cursor: pointer; text-decoration: none;
  padding: .55rem .9rem; border-radius: 6px;
  border: 1px solid var(--rule); background: none; color: var(--chalk);
  transition: border-color .12s, background .12s, color .12s;
}
.btn:hover { border-color: #3b444d; background: var(--raised); }
.btn:disabled { opacity: .5; cursor: default; }
.btn--flare {
  background: var(--flare); border-color: var(--flare); color: var(--flare-ink);
}
.btn--flare:hover { background: var(--flare-lift); border-color: var(--flare-lift); }
.btn--block { width: 100%; }
.btn--sm { padding: .34rem .7rem; font-size: .78rem; }
/* Utility-shaped button: mono, uppercase, for actions on data rather than
   actions on the page (Recheck, Copy). */
.btn--util {
  font-family: var(--font-mono); font-weight: 400;
  font-size: .72rem; letter-spacing: .08em; text-transform: uppercase;
  color: var(--ash);
}
.btn--util:hover { border-color: var(--flare); color: var(--chalk); }

/* =========================================================================
   Main column
   ========================================================================= */

.main {
  margin-left: var(--rail-w);
  padding: 3rem 3rem 5rem;
  max-width: calc(var(--rail-w) + var(--measure));
}

.eyebrow {
  font-family: var(--font-mono);
  font-size: .68rem; letter-spacing: .2em; text-transform: uppercase;
  color: var(--flare); margin: 0 0 1.1rem;
}
.crumb {
  font-family: var(--font-mono);
  font-size: .68rem; letter-spacing: .14em; text-transform: uppercase;
  color: var(--faint); margin: 0 0 .7rem;
}
.section-label {
  font-family: var(--font-mono);
  font-size: .68rem; letter-spacing: .2em; text-transform: uppercase;
  color: var(--faint); margin: 0 0 1.6rem;
}

h1, .h1 {
  margin: 0;
  font-variation-settings: "wdth" var(--wide-hero);
  font-weight: 800;
  font-size: clamp(2.4rem, 5.6vw, 4.2rem);
  line-height: .93;
  letter-spacing: -.012em;
  text-transform: uppercase;
}
/* View headings sit next to live counts, so they run smaller than the hero
   and centre on the head row's midline with the tally and its button. */
.view-title {
  margin: 0;
  font-variation-settings: "wdth" var(--wide-head);
  font-weight: 800; font-size: clamp(1.9rem, 3.4vw, 2.6rem);
  line-height: 1; letter-spacing: -.005em; text-transform: uppercase;
}
h2 {
  margin: 0 0 .6rem;
  font-variation-settings: "wdth" 114;
  font-weight: 700; font-size: 1.35rem; letter-spacing: -.004em;
}
h3 {
  margin: 0 0 .45rem;
  font-variation-settings: "wdth" 112;
  font-weight: 700; font-size: 1.02rem; letter-spacing: .01em;
}
.lead { margin: 1.3rem 0 0; font-size: 1.06rem; color: var(--ash); max-width: 34rem; }

.prose { max-width: 42rem; color: var(--ash); }
.prose h2 { color: var(--chalk); margin-top: 2.4rem; }
.prose h3 { color: var(--chalk); margin-top: 1.6rem; }
.prose a { color: var(--chalk); text-decoration-color: var(--faint); }
.prose a:hover { text-decoration-color: var(--flare); }
.prose ul { padding-left: 1.1rem; }
.prose li { margin: .35rem 0; }

/* =========================================================================
   Landing
   ========================================================================= */

.hero { display: grid; grid-template-columns: 1fr 23rem; gap: 3.5rem; align-items: start; }
.hero-cta { display: flex; gap: .6rem; margin-top: 1.8rem; flex-wrap: wrap; }

.steps { margin-top: 4.5rem; border-top: 1px solid var(--rule); padding-top: 2.4rem; }
.step-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2.2rem; }
/* The steps are a real sequence — connect, invite, watch — and each depends
   on the one before, so the numbers carry information rather than decorate. */
.step-n {
  display: block; margin-bottom: .6rem;
  font-family: var(--font-mono); font-size: .72rem;
  letter-spacing: .1em; color: var(--flare);
}
.step p { margin: 0; color: var(--ash); font-size: .9rem; }

.honest {
  margin-top: 3.5rem;
  border: 1px solid var(--rule); border-radius: 10px;
  padding: 1.5rem 1.7rem; max-width: 46rem;
  display: flex; gap: 1.3rem; align-items: flex-start;
}
.honest p { margin: 0; color: var(--ash); font-size: .92rem; }
.honest b { color: var(--chalk); font-weight: 600; }
.honest .lamp { margin-top: .42rem; }

/* =========================================================================
   Board card (the hero's live demo, and the shape a roster echoes)
   ========================================================================= */

.board {
  background: var(--deck); border: 1px solid var(--rule);
  border-radius: 10px; overflow: hidden;
}
.board-top {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 1rem; padding: .85rem 1rem .7rem;
  border-bottom: 1px solid var(--rule);
}
.board-name {
  font-variation-settings: "wdth" 115;
  font-weight: 800; font-size: .92rem;
  letter-spacing: .05em; text-transform: uppercase;
}
.board-when { font-family: var(--font-mono); font-size: .68rem; color: var(--faint); }
.board-row {
  display: flex; align-items: center; gap: .65rem;
  padding: .6rem 1rem; border-bottom: 1px solid #21262c;
}
.board-row:last-child { border-bottom: 0; }
.board-row-name {
  font-family: var(--font-mono); font-size: .84rem;
  flex: 1; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.board-row-state { font-family: var(--font-mono); font-size: .72rem; color: var(--ash); }

/* =========================================================================
   The decay bar — rule 3, and the one thing this design is remembered by
   ========================================================================= */

.decay { height: 2px; background: var(--rule); position: relative; overflow: hidden; }
.decay-fill {
  position: absolute; inset: 0;
  transform-origin: left;
  background: linear-gradient(90deg, var(--flare), #ff8a5c);
  /* Driven by JS (scaleX from the real age of the snapshot), so there is no
     keyframe here — a CSS animation would restart on every repaint and drift
     from the data it claims to describe. */
  transition: transform .4s linear;
}
/* Reduced motion keeps the BAR — it is information, not decoration — and
   only drops the easing, so it steps instead of sliding. */
@media (prefers-reduced-motion: reduce) {
  .decay-fill { transition: none; }
}

.check {
  margin: 1.4rem 0 0;
  display: flex; align-items: center; gap: 1rem;
}
.check-txt {
  font-family: var(--font-mono); font-size: .72rem;
  color: var(--faint); white-space: nowrap;
}
.check .decay { flex: 1; }

/* =========================================================================
   Roster
   ========================================================================= */

.view-head { display: flex; align-items: center; gap: 1.5rem; flex-wrap: wrap; }

.team-info {
  margin: 1rem 0 1.5rem; max-width: 46rem;
  border: 1px solid var(--rule); border-left: 2px solid var(--flare);
  border-radius: 8px; background: var(--deck);
}
.team-info--editor { padding: .75rem; }
.team-info-top, .team-info-actions {
  display: flex; align-items: center; justify-content: space-between; gap: .75rem;
}
.team-info-label, .team-info-hint, .team-info-count {
  font-family: var(--font-mono); font-size: .65rem;
}
.team-info-label {
  color: var(--ash); font-weight: 500; letter-spacing: .1em; text-transform: uppercase;
}
.team-info-hint, .team-info-count { color: var(--faint); }
.team-info-input {
  display: block; width: 100%; min-height: 5.2rem; margin: .55rem 0 .65rem;
  padding: .7rem .8rem; resize: vertical;
  border: 1px solid var(--rule); border-radius: 6px;
  background: var(--raised); color: var(--chalk);
  font: .84rem/1.5 var(--font-mono);
}
.team-info-input::placeholder { color: var(--faint); }
.team-info-input:focus { outline: none; border-color: var(--flare); box-shadow: 0 0 0 2px #ff4d2e20; }
.team-info--readonly { padding: .75rem .9rem; }
.team-info--readonly p { margin: .35rem 0 0; color: var(--ash); white-space: pre-wrap; }
.view-tally {
  display: flex; align-items: center; gap: .45rem;
  margin: 0;
  font-family: var(--font-mono); font-size: .82rem; color: var(--ash);
}
.view-tally b { font-weight: 500; color: var(--lamp-on); }
.view-tally.is-quiet b { color: var(--ash); }
/* The Discord half of the same sentence. Quieter than the Fortnite count
   beside it — this page is a Fortnite tracker, and voice is the supporting
   fact — but not faint: on a squad with nobody in the game it is the only
   thing on the line with anything to say. */
.view-tally-voice { color: var(--faint); white-space: nowrap; }

/* Rows carry no chrome of their own, and neither does the section: the decay
   bar directly above is already a full-width horizontal line, so a rule under
   it read as a second one and boxed the two together. Whitespace separates
   instead. Like a match sheet, a dotted leader ties each name to its status
   across the gap, so it can do that without the eye losing the pairing. */
.roster {
  margin-top: 1.6rem;
  position: relative;   /* the lattice canvas is measured against this box */
}
/* js/lattice.js draws here: a field of pixels inside the party blocks that
   drifts, and scatters where the cursor goes. Behind everything, and inert
   to the pointer — the canvas must never eat a click meant for a row. */
.roster-lattice {
  position: absolute; inset: 0; z-index: 0;
  pointer-events: none;
}
.roster-row {
  display: flex; align-items: center; gap: 1rem;
  padding: .6rem .65rem; margin: 0 -.65rem;
  border-radius: 7px;
}
/* A wash, not a panel. `--raised` is opaque, so inside a party block it drew
   a grey slab over the lattice and the green — the hover has to read as the
   row lifting slightly, whatever is behind it. */
.roster-row:hover { background: rgb(255 255 255 / .045); }
.roster-who { display: flex; align-items: center; gap: .6rem; flex: 1; min-width: 0; }
.roster-who::after {
  content: ""; flex: 1; min-width: 1.5rem; height: 2px; align-self: center;
  background-image: radial-gradient(circle at 1px 1px, var(--rule) 1px, transparent 1.5px);
  background-size: 7px 2px; background-repeat: repeat-x;
}
.roster-name {
  font-family: var(--font-mono); font-size: .92rem; font-weight: 500;
  min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.roster-tag {
  font-family: var(--font-mono); font-size: .66rem;
  letter-spacing: .1em; text-transform: uppercase; color: var(--faint);
  border: 1px solid var(--rule); border-radius: 4px; padding: .1rem .4rem;
  white-space: nowrap;
}
.roster-handle { font-family: var(--font-mono); font-size: .74rem; color: var(--faint); }
.roster-state {
  display: flex; flex-direction: column; align-items: flex-end; gap: .1rem;
  font-family: var(--font-mono); font-size: .76rem;
  color: var(--ash); text-align: right; white-space: nowrap;
}
.roster-state.is-on { color: var(--lamp-on); }
.roster-state.is-away { color: var(--lamp-away); }
.roster-state.is-unknown { color: var(--faint); }
.roster-party-note { font-size: .68rem; color: var(--faint); }
/* "in voice", under whatever the Fortnite state turned out to be. It keeps
   its own colour rather than inheriting .roster-state's tone, because the
   row it matters most on is an OFFLINE one: somebody sitting in the call
   who is not playing is the person a reader is looking for, and letting the
   line go grey with the rest would hide exactly that case. */
.roster-voice-note { font-size: .68rem; color: var(--ash); }

/* Members the last check saw in the SAME Fortnite party — one bracketed
   block, so "these are already playing together" reads as a shape rather
   than a sentence. The bracket and label borrow the online colour because
   the group is, by construction, people currently in the game.

   TWO parties on one roster is the case this styling is really for. Two
   blocks in the same green, five pixels apart, read as one long group with
   the label stuttered — so each block is a slab: a wash that fades out to
   the right, a rounded outer edge, and enough margin that the gap between
   two of them is unmistakably a gap. The heading carries the party's size
   (js/party.js::partyLabel) because that is what tells "3" from "2" before
   anyone counts rows. */
.roster-group {
  position: relative;
  margin: .8rem -.65rem; padding: .35rem 0 .3rem 2px;
  border-radius: 3px 8px 8px 3px;   /* the rail ENDS, visibly, at each block */
  background: linear-gradient(90deg, #3ddc8416, transparent 62%);
}
/* The rail is a pseudo-element rather than a border-left so it can DRAW —
   see the motion block below. The 2px it used to occupy is padding now, so
   the geometry is unchanged. */
.roster-group::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0;
  width: 2px; border-radius: 3px; background: var(--lamp-on);
  transform-origin: top;
}
/* The glow a block gives when it gains somebody. The ambient texture is NOT
   here — it is a canvas behind the whole roster (js/lattice.js), because
   random drift and a cursor it reacts to are not things a background-image
   can do. */
.roster-group::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  pointer-events: none; opacity: 0;
  background: linear-gradient(90deg, #3ddc8422, transparent 62%);
}
/* Two parties back to back is the case the eye gets wrong: rails 5px apart in
   one colour read as a single rail with a stuttered label. The gap has to be
   larger than the space between rows INSIDE a block, or the grouping says
   nothing. */
.roster-group + .roster-group { margin-top: 1.6rem; }

/* ---- motion -------------------------------------------------------------

   Two events on this page are worth moving for: a party FORMS, and somebody
   JOINS one that already existed. Everything else — a poll that found the
   same four people, a page turn, a party losing someone — is still, because
   this roster repaints every few seconds and motion on repaint would both
   strobe and imply news. js/party-motion.js is the gate; these rules are
   only reached through the classes it hands out.

   The rail drawing downwards is the whole idea: the bracket is the shape
   this feature invented, so letting it draw itself says "these people came
   together" in the block's own vocabulary. The label follows it in, the rows
   settle after, and nothing else on the page moves at all — no pulsing lamp,
   because a live-looking dot over a minute-old snapshot is the one lie this
   stylesheet refuses to tell. */
@media (prefers-reduced-motion: no-preference) {
  /* THE ONE CONTINUOUS ANIMATION IN THE PRODUCT, and it earns that by being
     the freshness channel rather than decoration. A pulse leaves the rail
     every few seconds and fades out to the right — the block reads as a live
     game rather than a record of one — and it STOPS the moment the snapshot
     goes stale, which is the same instant `.decay` finishes draining. Rule 3
     says staleness is shown rather than hidden; motion that dies when the
     data dies is that rule with a pulse on it, not an exception to it.
     `paintAge` owns the class, so the pulse and the bar cannot disagree. */
  .roster-group.is-new::before {
    animation: rail-draw 260ms var(--ease-settle) both;
  }
  /* The heading has no entrance of its own: js/flap.js settles it like a
     split-flap board, and a slide underneath that is two entrances on one
     element. */
  /* Rows settle in roster order, --i set per row by views.js. The stagger is
     45ms: enough to read as a sequence, short enough that a party of five is
     finished in under half a second.

     They SLIDE and never fade. Everyone in a party that just formed was
     already on this roster a second ago as an ungrouped row — fading them up
     from nothing makes existing content blink out and back, and freezing the
     animation a third of the way in showed the cost plainly: an empty green
     slab where three people were standing. The delay overlaps the rail rather
     than following it, for the same reason. */
  .roster-group.is-new .roster-row {
    animation: row-settle 200ms var(--ease-settle) both;
    animation-delay: calc(var(--i, 0) * 45ms + 40ms);
  }
  /* An existing party that gained somebody: the block does NOT re-introduce
     itself — only the arriving row moves, under a single wash that brightens
     and fades. Re-running the whole block would make a joiner look like a
     party that formed from nothing. */
  .roster-group.is-grown::after { animation: wash-fade 900ms ease-out both; }
  .roster-row.is-joining { animation: row-join 320ms var(--ease-settle) both; }

  /* Somebody who was in a party and is in none now — they quit, or the squad
     broke up around them. The row slides back out of the block's indent and
     the lamp takes one flare-coloured hit.

     This is the only flare on a state row anywhere in the product, and rule 1
     survives it because the colour NEVER RESTS: it is an overlay on its own
     pseudo-element, at full opacity for a fifth of the animation and gone by
     the end, leaving the lamp underneath to resolve to whatever the person
     actually is — grey if they quit, green if they are still on alone. A
     colour that cannot be seen at rest cannot be read as a state. */
  .roster-row.is-leaving { animation: row-left 520ms var(--ease-settle) both; }
  .roster-row.is-leaving .lamp { position: relative; }
  .roster-row.is-leaving .lamp::after {
    content: ""; position: absolute; inset: -2px; border-radius: 50%;
    background: var(--flare); box-shadow: 0 0 0 3px #ff4d2e33;
    animation: lamp-hit 520ms ease-out both;
  }
}

@keyframes rail-draw { from { transform: scaleY(0); } to { transform: scaleY(1); } }
/* Both of these move a row that is ALREADY on screen into its new place, so
   neither touches opacity: the person did not appear, they changed company. */
@keyframes row-settle {
  from { transform: translateY(6px); }
  to { transform: none; }
}
@keyframes row-join {
  from { transform: translateY(10px); }
  to { transform: none; }
}
@keyframes wash-fade { from { opacity: 1; } to { opacity: 0; } }
@keyframes row-left {
  from { transform: translateX(10px); }
  to { transform: none; }
}
/* Hit, then gone. The hold is short enough to read as an event and too short
   to read as a colour this row now IS. */
@keyframes lamp-hit {
  0% { opacity: 1; transform: scale(1.25); }
  22% { opacity: 1; transform: scale(1); }
  100% { opacity: 0; transform: scale(1); }
}
/* Above the lattice canvas, which is a sibling behind the blocks. */
.roster-group { z-index: 1; }
.roster-group .roster-row { margin: 0; }
.roster-group-label {
  margin: 0 0 .1rem .65rem;
  font-family: var(--font-mono); font-size: .64rem; font-weight: 500;
  letter-spacing: .12em; text-transform: uppercase; color: var(--lamp-on);
}

/* Roster pages. Only rendered when there is more than one, so this never
   shows up under a squad of four. The rule above it separates it from the
   last row without pretending to be another row. */
.pager {
  display: flex; align-items: center; justify-content: center; gap: .75rem;
  margin-top: .9rem; padding-top: .8rem;
  border-top: 1px solid var(--rule);
}
.pager-at {
  font-family: var(--font-mono); font-size: .72rem;
  letter-spacing: .06em; color: var(--faint);
  /* Fixed enough width that stepping 9 → 10 doesn't shuffle both buttons. */
  min-width: 8.5rem; text-align: center;
}
.pager .btn[disabled] { opacity: .4; cursor: default; }

/* =========================================================================
   Scheduled sessions + RSVP
   ========================================================================= */

.sessions {
  margin-top: 2.5rem; border-top: 1px solid var(--rule); padding-top: 1.5rem;
}
.sessions-head {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  margin-bottom: 1rem;
}
.sessions-head h2 { margin: 0; }
.sessions-head p, .session-zone, .field-help, .form-error {
  margin: .25rem 0 0; font-family: var(--font-mono); font-size: .68rem; color: var(--faint);
}
.sessions-body { display: grid; gap: .75rem; }
.sessions-body > .boot { min-height: 9rem; }
.sessions-empty { margin-top: 0; padding: 1.6rem; }
.sessions-empty p { margin-bottom: 0; }
.session-card {
  padding: 1rem 1.1rem; border: 1px solid var(--rule); border-radius: 9px;
  background: linear-gradient(145deg, var(--deck), #111519);
}
.session-card-head {
  display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem;
}
.session-time { display: block; font-size: 1rem; font-weight: 650; }
.session-zone { display: block; }
.session-copy { margin: .75rem 0 0; color: var(--ash); font-size: .88rem; white-space: pre-wrap; }
.rsvp-actions { display: flex; gap: .45rem; flex-wrap: wrap; margin-top: .9rem; }
.rsvp-btn {
  padding: .45rem .7rem; border: 1px solid var(--rule); border-radius: 6px;
  background: var(--raised); color: var(--ash); cursor: pointer;
  font-family: var(--font-mono); font-size: .7rem;
  transition: border-color .12s, background .12s, color .12s;
}
.rsvp-btn:hover, .rsvp-btn:focus-visible { border-color: var(--flare); color: var(--chalk); }
.rsvp-btn.is-selected {
  border-color: var(--flare); background: #4e2521; color: #fff;
  box-shadow: inset 0 0 0 1px #ff694744;
}
.rsvp-btn:disabled { cursor: wait; opacity: .6; }
.session-field-gap { margin-top: 1rem; }
.session-note { resize: vertical; min-height: 5.5rem; }
.session-chats {
  margin: 1rem 0 0; padding: .8rem .9rem; border: 1px solid var(--rule);
  border-radius: 8px; display: grid; gap: .5rem;
  /* fieldset's UA default is min-content, which would let a long chat
     title force the modal wider instead of wrapping */
  min-inline-size: 0;
}
.session-chat-row span { overflow-wrap: anywhere; }
.session-chats legend {
  padding: 0 .3rem; font-size: .8rem; font-weight: 650; color: var(--chalk);
}
.session-chat-row {
  display: flex; align-items: center; gap: .55rem; cursor: pointer;
  color: var(--ash); font-size: .85rem;
}
.session-chat-row input { accent-color: var(--flare); }
.session-chats .field-help { margin-top: 0; }
.field-help { margin-top: .35rem; }
.form-error { min-height: 1.2em; color: #ff836d; }
.btn--ghost { background: transparent; }
.btn--danger { border-color: #8c3529; color: #ff9b88; }
.btn--danger:hover, .btn--danger:focus-visible { border-color: var(--flare); }

@media (max-width: 34rem) {
  .sessions-head { align-items: stretch; flex-direction: column; }
  .sessions-head .btn { align-self: flex-start; }
  .session-card-head { align-items: flex-start; flex-wrap: wrap; }
  .rsvp-actions { display: grid; grid-template-columns: repeat(3, 1fr); }
  .rsvp-btn { padding-inline: .2rem; min-width: 0; }
}
@media (max-width: 22rem) {
  .rsvp-actions { grid-template-columns: 1fr; }
}

/* =========================================================================
   Daily squad activity — week columns, Sunday-to-Saturday rows
   ========================================================================= */

.activity { margin-top: 2.5rem; border-top: 1px solid var(--rule); padding-top: 1.5rem; }
.activity-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.activity-head h2 { margin: 0; }
.activity-head span, .activity-copy, .activity-error {
  font-family: var(--font-mono); font-size: .7rem; color: var(--faint);
}
.activity-copy { margin: .45rem 0 1.1rem; }
/* What a chart measures on the left, its unit and clock on the right —
   the caption that stands in for a heading when two grids share a section.
   Wraps rather than crushing: the right-hand half is an IANA zone name,
   which is long, and on a phone it belongs under the sentence. */
.activity-caption {
  display: flex; flex-wrap: wrap; align-items: baseline;
  justify-content: space-between; gap: .1rem 1.2rem;
}
.activity-caption .activity-copy { margin: .45rem 0 .9rem; flex: 1 1 18rem; }
.activity-caption > span {
  font-family: var(--font-mono); font-size: .7rem; color: var(--faint);
}
/* The two charts are one section, so the gap between them has to say
   "next chart" without saying "next subject" — a rule would do the second. */
.activity-chart + .activity-chart { margin-top: 1.9rem; }
.activity-error { margin: 0; }
.activity-plot { overflow-x: auto; padding-bottom: .45rem; }
.activity-months, .activity-cells {
  display: grid;
  grid-template-columns: repeat(var(--activity-weeks), 11px);
  gap: 3px;
  width: max-content;
  margin-left: 2.3rem;
}
.activity-months { margin-bottom: .35rem; }
.activity-months span {
  font-family: var(--font-mono); font-size: .62rem; color: var(--faint);
  overflow: visible; white-space: nowrap;
}
.activity-weekdays {
  float: left; display: grid; grid-template-rows: repeat(7, 11px); gap: 3px;
  width: 1.9rem;
}
.activity-weekdays span {
  font-family: var(--font-mono); font-size: .56rem; line-height: 11px; color: var(--faint);
}
.activity-cells { grid-template-rows: repeat(7, 11px); grid-auto-flow: column; }
.activity-cell {
  width: 11px; height: 11px; border-radius: 2px; box-sizing: border-box;
  background: transparent; border: 1px solid #252b32;
  transition: border-color 90ms ease, box-shadow 90ms ease, transform 90ms ease;
}
.activity-cell.is-outside { border-color: transparent; }
.activity-cell.is-unsampled {
  background: linear-gradient(to top right,
    transparent calc(50% - .5px), #252b32 calc(50% - .5px),
    #252b32 calc(50% + .5px), transparent calc(50% + .5px));
}
.activity-cell.level-1, .activity-cell.level-2,
.activity-cell.level-3, .activity-cell.level-4 { border-color: transparent; }
.activity-cell.level-1 { background: #4e2521; }
.activity-cell.level-2 { background: #863025; }
.activity-cell.level-3 { background: #c83f29; }
.activity-cell.level-4 { background: var(--flare); }
/* Both grids, one definition. `.is-hovered` is set by the shared tooltip
   helper for either chart, so a rule that named only the calendar's
   container left every cell of the week grid with a tooltip and no visible
   target — which is how it shipped for one commit. */
.activity-cells .activity-cell:not(.is-outside),
.week-cells .week-cell { cursor: default; }
.activity-cells .activity-cell.is-hovered,
.week-cells .week-cell.is-hovered {
  position: relative; z-index: 2;
  border-color: var(--chalk);
  box-shadow: 0 0 0 1px var(--ground), 0 0 0 2px var(--flare-lift);
  transform: scale(1.2);
}
.activity-tooltip {
  position: fixed; z-index: 120; pointer-events: none;
  display: grid; gap: .2rem; max-width: min(18rem, calc(100vw - 1rem));
  padding: .65rem .75rem;
  background: #242a30; color: var(--chalk);
  border: 1px solid var(--rule); border-radius: 7px;
  box-shadow: 0 10px 28px rgba(0, 0, 0, .38), inset 2px 0 var(--flare);
  font-family: var(--font-mono); line-height: 1.35;
}
.activity-tooltip[hidden] { display: none; }
.activity-tooltip-date { font-size: .72rem; font-weight: 500; }
.activity-tooltip-value { font-size: .65rem; color: var(--ash); }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.activity-legend {
  display: flex; justify-content: flex-end; align-items: center; gap: 4px;
  margin-top: .55rem; font-family: var(--font-mono); font-size: .62rem; color: var(--faint);
}
.activity-legend > span:first-child { margin-right: .2rem; }
.activity-legend > span:last-child { margin-left: .2rem; }
@media (prefers-reduced-motion: reduce) {
  .activity-cell { transition: none; }
}

/* ---- the week grid ------------------------------------------------------
   Same 11px cell, same gap, same hatch as the calendar above: they answer
   different questions about one squad, and two unrelated-looking widgets
   would say otherwise. What differs is the axis (24 hours across, 7 days
   down) and the one thing this chart has that a calendar does not — a marked
   best window, because this one exists to be acted on.                    */

/* 16px rather than the calendar's 11px. The calendar is 53 columns wide and
   fills the measure by itself; a week is 24, and at 11px it sat as a small
   huddle in the top-left of a 62rem column, reading as an aside rather than
   as the answer. 24 cells still clear a narrow phone with the plot's own
   scroll, which is the same bargain the calendar already makes.            */
.week-plot { overflow-x: auto; padding-bottom: .45rem; }
/* Corner / hours over days / cells. Not the calendar's floated label column:
   a float holds only while the plot fits: once it scrolls, the max-content
   rows cannot sit beside it and drop below, stranding the day names above a
   grid they no longer line up with. Two columns cannot come apart.        */
.week-inner {
  display: grid;
  grid-template-columns: 1.9rem max-content;
  column-gap: .4rem;
  align-items: start;
  width: max-content;
}
.week-hours, .week-cells {
  display: grid;
  grid-template-columns: repeat(24, 16px);
  gap: 4px;
}
.week-hours { margin-bottom: .35rem; }
.week-hours span {
  font-family: var(--font-mono); font-size: .62rem; color: var(--faint);
  overflow: visible; white-space: nowrap;
}
.week-days {
  display: grid; grid-template-rows: repeat(7, 16px); gap: 4px;
}
.week-days span {
  font-family: var(--font-mono); font-size: .58rem; line-height: 16px; color: var(--faint);
}
.week-cell { width: 16px; height: 16px; border-radius: 3px; }
/* Background transitions too, unlike the calendar's cell: switching the
   reading repaints all 168 at once, and having them settle into the new
   values is what shows it is the SAME buckets being measured differently. */
.week-cell { transition: border-color 90ms ease, box-shadow 90ms ease,
             transform 90ms ease, background-color 160ms var(--ease-settle); }

/* The signature: the best run of hours, ringed where it sits. The sentence
   above the grid says WHEN; only this says where that is in the week. Chalk
   rather than flare, so it is never mistaken for the hover ring. */
.week-cells .week-cell.in-best {
  box-shadow: 0 0 0 1px var(--ground), 0 0 0 2px var(--chalk);
}
/* Needed, not belt-and-braces: `.in-best` and `.is-hovered` carry the same
   specificity and `.in-best` is the later rule, so without this the chalk
   ring would win and the three cells of the best window would be the only
   ones in the grid that did not respond to the pointer. */
.week-cells .week-cell.in-best.is-hovered {
  box-shadow: 0 0 0 1px var(--ground), 0 0 0 2px var(--flare-lift);
}

.week-switch { display: flex; flex-wrap: wrap; gap: .3rem; margin: .1rem 0 .85rem; }
.week-switch-btn {
  font-family: var(--font-mono); font-size: .66rem; letter-spacing: .01em;
  padding: .3rem .6rem; border-radius: 999px;
  border: 1px solid var(--rule); background: transparent; color: var(--ash);
  cursor: pointer; transition: color 120ms ease, border-color 120ms ease,
                               background-color 120ms ease;
}
.week-switch-btn:hover { color: var(--chalk); border-color: var(--faint); }
.week-switch-btn[aria-pressed="true"] {
  background: var(--raised); color: var(--chalk); border-color: var(--faint);
}

.week-claim {
  font-family: var(--font-mono); font-size: .72rem; color: var(--ash);
  margin: 0 0 .8rem;
}
.week-claim b { color: var(--chalk); font-weight: 500; }
.week-claim.is-quiet { color: var(--faint); }

.week-pairs-head {
  margin: 1.6rem 0 0; font-size: .95rem;
  font-variation-settings: "wdth" var(--wide-head);
}
/* Capped rather than filling the measure: across 62rem, 214 and 187 drew as
   near-identical full-width bars and the ranking stopped being visible,
   which is the only thing a bar adds over the number beside it. */
.week-pairs {
  list-style: none; margin: 0; padding: 0; display: grid; gap: .35rem;
  max-width: 30rem;
}
.week-pair {
  display: grid; grid-template-columns: minmax(5rem, 9rem) 1fr auto;
  align-items: center; gap: .6rem;
  font-family: var(--font-mono); font-size: .7rem; color: var(--chalk);
}
.week-pair-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.week-pair-bar {
  height: 6px; border-radius: 3px; background: var(--raised);
  position: relative; overflow: hidden;
}
.week-pair-bar::after {
  content: ""; position: absolute; inset: 0 auto 0 0;
  width: var(--share, 4%); background: var(--flare); border-radius: 3px;
}
.week-pair-count { color: var(--faint); font-size: .65rem; }

@media (prefers-reduced-motion: reduce) {
  .week-cell, .week-switch-btn { transition: none; }
}

/* =========================================================================
   Panels, notices, empty states
   ========================================================================= */

.panel {
  background: var(--deck); border: 1px solid var(--rule);
  border-radius: 10px; padding: 1.2rem 1.4rem;
}
.panel + .panel { margin-top: .8rem; }

.notice {
  border-left: 2px solid var(--rule);
  padding-left: .85rem; margin: 1.6rem 0 0;
  font-size: .86rem; color: var(--faint); max-width: 44rem;
}
.notice b { color: var(--ash); font-weight: 600; }
.notice--warn { border-left-color: var(--flare); color: var(--ash); }

/* Empty states are an invitation to act, so they get a heading and a button
   rather than a shrug in grey text. */
.empty {
  border: 1px dashed var(--rule); border-radius: 10px;
  padding: 2.4rem 2rem; text-align: center; margin-top: 1.6rem;
}
.empty h3 { margin-bottom: .4rem; }
.empty p { margin: 0 auto 1.2rem; color: var(--ash); max-width: 30rem; font-size: .92rem; }

.rows { display: flex; flex-direction: column; }
.row-item {
  display: flex; align-items: center; gap: 1rem;
  padding: .9rem .25rem; border-bottom: 1px solid var(--rule);
  background: none; border-left: 0; border-right: 0; border-top: 0;
  width: 100%; text-align: left; cursor: pointer;
  transition: background .12s;
}
.row-item:hover { background: #14181d; }
.row-item-main { flex: 1; min-width: 0; }
.row-item-name { font-size: .96rem; font-weight: 600; }
.row-item-meta { font-family: var(--font-mono); font-size: .72rem; color: var(--faint); }
.row-item-chevron { color: var(--faint); font-size: 1.1rem; }

/* =========================================================================
   Account
   ========================================================================= */

.acct-id { display: flex; align-items: center; gap: .65rem; width: 100%; }
.acct-av {
  width: 28px; height: 28px; flex: none; border-radius: 7px;
  background: linear-gradient(150deg, #33404b, #20272e);
  border: 1px solid var(--rule);
  display: grid; place-items: center;
  font-family: var(--font-mono); font-size: .72rem; color: var(--ash);
}
.acct-txt { flex: 1; min-width: 0; display: grid; gap: .1rem; }
.acct-name {
  font-size: .85rem; font-weight: 600;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct-sub {
  font-family: var(--font-mono); font-size: .66rem; color: var(--faint);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.method {
  display: flex; align-items: center; gap: .8rem;
  padding: .8rem 0; border-bottom: 1px solid var(--rule);
}
.method:last-child { border-bottom: 0; }
.method--bare { border-bottom: 0; }

/* Small section headers inside modals — the OIDC list gets one, the
   display-name row above deliberately does not. */
.modal-section {
  margin: 1.3rem 0 .1rem;
  font-family: var(--font-mono); font-size: .66rem; font-weight: 600;
  letter-spacing: .09em; text-transform: uppercase; color: var(--faint);
}

.danger-zone {
  display: flex; align-items: center; gap: .8rem;
  margin-top: 1.4rem; padding: .75rem .9rem;
  border: 1px solid #8c3529; border-radius: 7px;
  background: #ff4d2e0d;
}
.danger-zone .method-name { color: #ff9b88; }
/* The warning must be read whole — wrap it rather than ellipsise it. */
.danger-zone .method-sub { white-space: normal; overflow: visible; }
.method-main { flex: 1; min-width: 0; display: grid; gap: .15rem; text-align: left; }
.method-name { font-weight: 600; font-size: .92rem; }
.method-sub {
  font-family: var(--font-mono); font-size: .72rem; color: var(--faint);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

/* Provider marks. One rule per provider so a fifth is a rule, not a rewrite. */
.provider { width: 20px; height: 20px; flex: none; border-radius: 5px; display: grid; place-items: center; }
.provider--telegram { background: linear-gradient(#63b6f5, #3f95dc); }
.provider--discord { background: linear-gradient(#7b87f5, #4752c4); }
.provider--email { background: #3A444E; }

/* =========================================================================
   Modal + toast
   ========================================================================= */

.modal-host {
  position: fixed; inset: 0; z-index: 50;
  display: none; place-items: center;
  padding: 1.2rem;
  background: #06080ab8;
  backdrop-filter: blur(2px);
}
.modal-host.is-open { display: grid; }
.modal {
  background: var(--deck); border: 1px solid var(--rule);
  border-radius: 12px; padding: 1.6rem;
  width: min(28rem, 100%); max-height: 90vh; overflow-y: auto;
}
/* The panel takes focus when it opens (a resting place, not a control), so
   the shared :focus-visible ring would draw a frame around the whole dialog
   in keyboard flows. Tab still rings every control inside as usual. */
.modal:focus-visible { outline: none; }
.modal h2 { font-size: 1.2rem; }
.modal-sub { margin: 0 0 1.2rem; color: var(--ash); font-size: .9rem; }
.modal-actions {
  display: flex; flex-wrap: wrap; gap: .6rem;
  margin-top: 1.4rem; justify-content: flex-end;
}
.field {
  width: 100%; padding: .6rem .75rem;
  background: var(--raised); color: var(--chalk);
  border: 1px solid var(--rule); border-radius: 6px;
  font-family: var(--font-mono); font-size: .9rem;
}
.field:focus { outline: none; border-color: var(--flare); }

/* Selects stay native — the open list is the OS's, already dark through
   color-scheme — but the closed control is drawn in-house: the default
   widget chrome comes off and our own chevron goes on, in ash like every
   other secondary mark, with the flare focus every field shares. */
select.field {
  appearance: none;
  padding-right: 2rem; cursor: pointer;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%238B959F' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right .65rem center;
  transition: border-color .12s;
}
select.field:hover { border-color: #46515c; }
select.field:focus { border-color: var(--flare); box-shadow: 0 0 0 2px #ff4d2e20; }

/* Where the browser supports customizable selects (Chromium), the OPEN
   list is ours as well: deck panel, mono options, the flare checkmark on
   the current choice. Everywhere else these rules are ignored wholesale
   and the OS keeps drawing its own popup — already dark via color-scheme,
   so the fallback is quiet rather than broken. */
@supports (appearance: base-select) {
  select.field, select.field::picker(select) { appearance: base-select; }
  /* The base appearance brings its own arrow; ours is already painted. */
  select.field::picker-icon { display: none; }
  select.field::picker(select) {
    background: var(--deck);
    border: 1px solid var(--rule); border-radius: 7px;
    padding: .3rem; margin-top: .3rem;
    box-shadow: 0 10px 28px rgba(0, 0, 0, .45);
  }
  select.field option {
    display: flex; align-items: center; gap: .5rem;
    padding: .42rem .6rem; border-radius: 5px;
    font-family: var(--font-mono); font-size: .84rem; color: var(--chalk);
    cursor: pointer;
  }
  select.field option:hover,
  select.field option:focus { background: var(--raised); outline: none; }
  select.field option::checkmark { color: var(--flare); }
}
.field-label {
  display: block; margin-bottom: .35rem;
  font-family: var(--font-mono); font-size: .68rem;
  letter-spacing: .12em; text-transform: uppercase; color: var(--faint);
}

.signin-list { display: flex; flex-direction: column; gap: .5rem; }
.signin-btn {
  display: flex; align-items: center; gap: .7rem;
  padding: .7rem .9rem; border-radius: 7px;
  border: 1px solid var(--rule); background: var(--raised);
  cursor: pointer; font-weight: 600; font-size: .9rem;
  transition: border-color .12s;
}
.signin-btn:hover { border-color: #46515c; }

/* -------------------------------------------------------------------------
   Connecting Epic.

   The flow's awkwardness is real and cannot be designed away: Epic's launcher
   client redirects to a fixed endpoint on its own site that answers with a
   JSON document, so nothing returns here and the code is copied by hand. The
   response is to SHOW the page somebody is about to land on rather than
   describe it — .epic-json is a facsimile in the site's data face, with the
   one line that matters lifted out of a deliberately dim block.

   Note what this does NOT do: no lamp, no green, no state colour anywhere.
   --flare marks the action (the line to copy, the button to press) and the
   presence lamps stay the roster's alone, per rule 1 at the top of this file.
   ------------------------------------------------------------------------- */
.epic-hint { margin: .35rem 0 .2rem; color: var(--faint); font-size: .8rem; }

/* The way in for somebody who already fetched a code — an alternative to the
   button above, so it must not look like a second one of equal weight. No
   border, no fill, and it takes itself away once the field it reveals is on
   screen.

   `.btn` sets `display: inline-flex`, which beats the UA's `[hidden]` rule —
   the same trap .boot[hidden] documents further down. Without the explicit
   rule this never disappears, which is invisible in the markup and obvious
   the moment the dialog is rendered. */
.epic-already {
  display: block; margin: .5rem auto 0;
  border-color: transparent; color: var(--ash); font-weight: 400;
}
.epic-already:hover { border-color: transparent; background: none; color: var(--chalk); }
.epic-already[hidden] { display: none; }

.epic-paste { margin-top: 1.5rem; }
.epic-paste .epic-hint { margin: 0 0 .55rem; color: var(--ash); font-size: .84rem; }

.epic-json {
  /* Below --ground on purpose: this is a quotation of somebody else's page,
     so it sits UNDER the surface of ours rather than on a raised card. */
  background: #0B0E11;
  border: 1px solid var(--rule); border-radius: 7px;
  padding: .75rem .9rem; margin-bottom: 1.2rem;
  font-family: var(--font-mono); font-size: .72rem; line-height: 1.7;
  color: var(--faint);
  overflow-x: auto;
}
.epic-json-brace { display: block; }
.epic-json-row { display: block; padding-left: 1.2rem; white-space: pre; }
.epic-json-key { color: var(--ash); }

/* The target line is marked three ways, because colour alone would leave the
   readers most likely to mis-copy with nothing: a rail, a lift off the
   block's own ground, and only then the flare. The paragraph above the block
   also names the field in words — the facsimile is aria-hidden, so that
   sentence is the whole instruction for anyone not looking at it. */
.epic-json-row.is-target {
  margin: .2rem -.9rem; padding: .15rem .9rem .15rem 2.1rem;
  background: #ff4d2e14;
  box-shadow: inset 2px 0 0 var(--flare);
}
.is-target .epic-json-key { color: var(--chalk); }
.is-target .epic-json-value { color: var(--flare); }

/* A notice that carries its own remedy: the sentence and the button that
   answers it, on one line until there is no room for one. */
.notice--act {
  display: flex; align-items: center; gap: .9rem; flex-wrap: wrap;
}
.notice--act .btn { flex: none; }

/* =========================================================================
   Squad notifications dialog
   =========================================================================
   Three sections — the active rules, the add form, quiet hours — separated
   by the same hairline the roster uses. The two conditions are radio ROWS,
   the same affordance as the sign-in buttons, and the chosen one is marked
   by the flare border, never by recolouring its text (rule 1: brand marks
   actions). The section heads echo the team-info header (label left, scope
   hint right) because the hint is the point: quiet hours are the ACCOUNT'S,
   not this squad's — the sections write to different rows. */

.notify-head { display: flex; align-items: center; justify-content: space-between; gap: .75rem; margin-bottom: .7rem; }
.notify-label {
  font-family: var(--font-mono); font-size: .65rem; font-weight: 500;
  letter-spacing: .1em; text-transform: uppercase; color: var(--ash);
}
.notify-hint { font-family: var(--font-mono); font-size: .65rem; color: var(--faint); }
.notify-sect { margin-top: 1.3rem; border-top: 1px solid var(--rule); padding-top: .9rem; }

.notify-list { display: flex; flex-direction: column; }
.notify-rule {
  display: flex; align-items: center; gap: .8rem;
  padding: .45rem 0;
}
.notify-rule + .notify-rule { border-top: 1px solid var(--rule); }
.notify-rule-text {
  flex: 1; min-width: 0;
  font-family: var(--font-mono); font-size: .84rem;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}

.notify-opts { display: flex; flex-direction: column; gap: .5rem; }
.notify-opt {
  display: flex; align-items: center; gap: .6rem; flex-wrap: wrap;
  padding: .65rem .8rem; border-radius: 7px;
  border: 1px solid var(--rule); background: var(--raised);
  cursor: pointer; font-size: .88rem; font-weight: 600;
  transition: border-color .12s;
}
.notify-opt:hover { border-color: #46515c; }
.notify-opt:has(input:checked) { border-color: var(--flare); }
.notify-opt:has(input:disabled) { opacity: .5; cursor: default; }
.notify-opt input[type="radio"] { margin: 0; flex: none; accent-color: var(--flare); }
.notify-opt-text { white-space: nowrap; }
.notify-opt select { flex: 1; min-width: 0; }
/* The channel picker's options are one word each, where the rule builder's
   are whole sentences with a field in the middle — so these sit in a row and
   share the width instead of stacking. */
.notify-opts--inline { flex-direction: row; flex-wrap: wrap; }
.notify-opts--inline .notify-opt { flex: 1 1 7rem; justify-content: center; }
.notify-add-actions { display: flex; justify-content: flex-end; margin-top: .6rem; }

.field--inline { width: auto; padding: .38rem .5rem; font-size: .84rem; }
.field--count { width: 3.6rem; text-align: center; }

.notify-quiet-row { display: flex; gap: 1.2rem; margin: .7rem 0 .2rem; }
.notify-quiet-row label {
  display: flex; align-items: center; gap: .5rem;
  font-family: var(--font-mono); font-size: .68rem;
  letter-spacing: .08em; text-transform: uppercase; color: var(--faint);
}
.notify-fine { margin: .55rem 0 0; font-size: .78rem; color: var(--faint); }
.notify-fine[hidden] { display: none; }

/* Inside the dialog the notice is a footnote, not a section break. */
.modal .notice { margin-top: 1.3rem; font-size: .8rem; }

.toast-host {
  position: fixed; left: 50%; bottom: 1.5rem; transform: translateX(-50%);
  z-index: 60; display: flex; flex-direction: column; gap: .5rem;
  pointer-events: none;
}
.toast {
  background: var(--raised); border: 1px solid var(--rule);
  border-radius: 8px; padding: .6rem 1rem;
  font-size: .86rem; box-shadow: 0 8px 24px #0008;
}
.toast--bad { border-color: var(--flare); }

/* =========================================================================
   Footer
   ========================================================================= */

.foot {
  margin-top: 5rem; padding-top: 1.4rem;
  border-top: 1px solid var(--rule);
  display: flex; gap: 1.2rem; flex-wrap: wrap;
  font-size: .8rem; color: var(--faint);
}
.foot a { text-decoration: none; }
.foot a:hover { color: var(--chalk); }

/* =========================================================================
   Boot + loading
   ========================================================================= */

/* Reserves the viewport so the footer can't sit inside the first screen
   during boot and then get pushed down — the layout-shift trap the vault hit. */
.boot { min-height: 60svh; display: grid; place-items: center; }
/* Author-level `display: grid` overrides the browser's low-priority
   `[hidden] { display: none }` rule unless hidden is stated here too. */
.boot[hidden] { display: none; }
.spinner {
  width: 22px; height: 22px; border-radius: 50%;
  border: 2px solid var(--rule); border-top-color: var(--flare);
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 2s; } }

/* =========================================================================
   The public squad page, and the dialog that publishes it

   Almost nothing new: /s/<slug> reuses .view-head, .roster-row, .activity-*
   and .notice, because a stranger should be looking at the same board a
   member sees, minus whatever the owner withheld. Two things do differ, and
   both are about honesty rather than style.

   THE AGE STRIP HAS NO DECAY BAR. On a member's roster the bar drains over
   one sweep interval because a check really is due. Nothing refreshes a
   public page's snapshot unless a MEMBER opens the squad, so a bar here
   would sit empty for hours and read as a fault. Words instead, and past
   five minutes the strip says why nothing newer exists.

   THE SETTINGS DIALOG IS DELIBERATELY SLOW TO READ. One line per section
   with a sentence saying what it publishes, and a warning that rewrites
   itself as the boxes change. This is the only control in the product that
   shows anything to people who are not on the squad; a tidy row of toggles
   would be quicker to use and worse at being understood.
   ========================================================================= */

.check--public { flex-wrap: wrap; gap: .5rem 1rem; }
.pub-check-note { font-size: .78rem; color: var(--faint); }
.pub-check-note:empty { display: none; }

.pubset-sections {
  display: flex; flex-direction: column; gap: .1rem;
  margin-bottom: 1.1rem;
}
.pubset-row {
  display: flex; gap: .7rem; align-items: flex-start;
  padding: .6rem .7rem; border-radius: 7px;
  cursor: pointer;
}
.pubset-row:hover { background: var(--raised); }
.pubset-row--lead {
  border: 1px solid var(--rule); background: var(--deck);
  margin-bottom: 1.2rem;
}
.pubset-row input { margin-top: .2rem; accent-color: var(--flare); flex: none; }
.pubset-row-main { display: flex; flex-direction: column; gap: .15rem; }
.pubset-row-name { font-size: .92rem; }
.pubset-row-hint { font-size: .78rem; color: var(--faint); line-height: 1.4; }

/* The host is spelled out beside the field rather than in a placeholder:
   what somebody is choosing is the last segment of a URL, and a bare text
   box does not say that. */
.pubset-address {
  display: flex; align-items: center;
  border: 1px solid var(--rule); border-radius: 6px;
  background: var(--raised); overflow: hidden;
}
.pubset-address:focus-within { border-color: var(--flare); }
.pubset-address-host {
  padding: .6rem 0 .6rem .75rem;
  font-family: var(--font-mono); font-size: .84rem; color: var(--faint);
  white-space: nowrap;
}
.pubset-slug {
  flex: 1; min-width: 4rem;
  padding: .6rem .75rem .6rem .1rem;
  background: none; border: 0; color: var(--chalk);
  font-family: var(--font-mono); font-size: .84rem;
}
.pubset-slug:focus { outline: none; }
.pubset-slug::placeholder { color: var(--faint); }
.pubset-hint {
  display: block; margin-top: .35rem;
  font-size: .78rem; color: var(--faint);
}

/* The public page ends on a call to action. Not a .notice — that class draws
   a left rule to mark a caveat, and a rule beside a lone button reads as a
   fragment of something missing. */
.pub-cta { margin: 1.8rem 0 0; }

/* =========================================================================
   Squad management — the owner's half (js/manage.js)
   =========================================================================
   Everything here is reachable only when the caller owns the squad, so none
   of it is ever painted for a member. It borrows the sessions panel's
   vocabulary deliberately: Invites sits directly above Sessions in the same
   column and a second visual language for a second owner-only list would
   read as a different kind of thing. */

/* The owner's one roster-row control, Remove (Edit went with the identity
   columns — see js/manage.js). Always visible rather than revealed on hover:
   a hover-only control is unreachable on a
   touch screen, and the
   sticky-:hover behaviour a WebView leaves behind is exactly the trap the
   vault's sprite tooltip had to be fenced against. Quiet styling does the
   job that hiding was for, and it is what keeps a destructive control from
   shouting on every row of a list that is mostly read. */
.roster-actions {
  display: flex; gap: .35rem; flex: none;
  opacity: .55; transition: opacity .12s;
}
.roster-row--admin:hover .roster-actions,
.roster-actions:focus-within { opacity: 1; }
/* The dotted leader stops before the buttons rather than running under
   them — it pairs the NAME with the STATE, and a control is neither. */
.roster-row--admin .roster-who::after { min-width: .75rem; }

/* Pending invites, inside the Sent invites dialog. The search sits above the
   list rather than in it, so a keystroke that empties the list cannot take
   the field being typed into off the screen with it. */
.invite-search { margin-bottom: .8rem; }
.invite-search[hidden] { display: none; }
.invites-body {
  display: grid; gap: .5rem;
  /* A page of six rows fits; this is the floor so the dialog does not jump
     in height between a full page, a short one and "no match". */
  min-height: 4.5rem; align-content: start;
}
.invites-foot { display: grid; gap: .5rem; justify-items: center; }
.invites-foot .field-help { margin: .6rem 0 0; }
.invites-foot .pager { margin-top: .3rem; width: 100%; }
.invite-row {
  display: flex; align-items: center; gap: .75rem;
  padding: .55rem .65rem; margin: 0 -.65rem; border-radius: 7px;
}
.invite-row:hover { background: var(--raised); }
.invite-target { flex: 1; min-width: 0; display: grid; gap: .1rem; }
.invite-target-name {
  font-family: var(--font-mono); font-size: .88rem;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.invite-target-meta {
  font-family: var(--font-mono); font-size: .66rem;
  letter-spacing: .09em; text-transform: uppercase; color: var(--faint);
}

/* The link a just-created invite hands back. Read-only rather than
   disabled, so the text stays selectable when the clipboard is refused. */
.invite-link {
  display: flex; align-items: center; gap: .5rem; margin-top: .6rem;
}
.invite-link-label {
  flex: none; width: 4rem;
  font-family: var(--font-mono); font-size: .66rem;
  letter-spacing: .09em; text-transform: uppercase; color: var(--faint);
}
.field--copy {
  flex: 1; min-width: 0;
  font-family: var(--font-mono); font-size: .74rem;
}
.invite-mail { display: flex; align-items: center; gap: .6rem; margin-top: 1rem; }
.invite-mail-status {
  font-family: var(--font-mono); font-size: .68rem; color: var(--faint);
}

/* Turn a link off, or replace it: two actions on the link above rather than
   on the dialog, so they sit with it instead of in the footer where Done is. */
.invite-actions {
  display: flex; gap: .5rem; margin-top: .9rem; flex-wrap: wrap;
}

/* The Requests button in the squad head. It only exists while somebody is
   waiting on an answer (js/manage.js::joinRequestsButton), so unlike its
   neighbours it is worth drawing as live rather than available — the same
   flare border a `btn--util` takes on hover, kept on. */
.btn--wants { border-color: var(--flare); color: var(--chalk); }

/* How an invite is addressed. A radiogroup rather than a <select>: there are
   three, they are the first thing the dialog asks, and each changes the
   field below it. */
.invite-methods { display: flex; gap: .45rem; flex-wrap: wrap; margin-top: .4rem; }
.chip {
  padding: .45rem .8rem; border: 1px solid var(--rule); border-radius: 6px;
  background: var(--raised); color: var(--ash); cursor: pointer;
  font-family: var(--font-mono); font-size: .72rem;
  transition: border-color .12s, background .12s, color .12s;
}
.chip:hover, .chip:focus-visible { border-color: var(--flare); color: var(--chalk); }
.chip.is-selected {
  border-color: var(--flare); background: var(--flare); color: var(--flare-ink);
}

/* =========================================================================
   Narrow screens — the rail becomes a top bar rather than disappearing
   =========================================================================
   Below 60rem a 17rem fixed column leaves no measure worth reading, so the
   rail unpins into a normal top strip and the squad list moves into the
   page. It is NOT hidden behind a hamburger: the squad list is the primary
   navigation AND the primary data, and putting it behind a tap would make
   the phone view strictly worse at the one thing the site is for. */
@media (max-width: 60rem) {
  .rail {
    position: static; width: auto;
    border-right: 0; border-bottom: 1px solid var(--rule);
    padding: .9rem 0 .6rem;
    flex-direction: row; align-items: center; gap: .5rem;
    overflow-x: auto;
  }
  .rail-brand { padding: 0 .9rem 0; }
  .rail-label, .rail-scroll, .rail-empty { display: none; }
  .rail-foot {
    border-top: 0; margin: 0 .9rem 0 auto; padding: 0;
    flex: none;
  }
  .main { margin-left: 0; padding: 2rem 1.25rem 4rem; }
  .hero { grid-template-columns: 1fr; gap: 2.2rem; }
  .step-grid { grid-template-columns: 1fr; gap: 1.6rem; }
  /* The squad picker reappears in the page, where there is room for it. */
  .squad-picker { display: block; }
}
@media (min-width: 60.001rem) {
  .squad-picker { display: none; }
}

@media (max-width: 34rem) {
  .roster-handle { display: none; }
  .roster-state { font-size: .72rem; }
  .main { padding: 1.6rem 1rem 3rem; }
  /* An admin row is name + two controls + state on a phone, which does not
     fit on one line. The controls drop under the name rather than shrinking:
     the state is what the row is FOR and it keeps its place. */
  .roster-row--admin { flex-wrap: wrap; row-gap: .4rem; }
  .roster-row--admin .roster-who { flex-basis: 100%; }
  .roster-row--admin .roster-who::after { min-width: 1.5rem; }
  .roster-actions { opacity: 1; order: 2; margin-left: auto; }
  /* An address plus two controls does not fit a phone-width dialog on one
     line. The controls drop under the address rather than squeezing it —
     the address is what says which invite this row is. */
  .invite-row { flex-wrap: wrap; row-gap: .35rem; }
  .invite-target { flex-basis: 100%; }
  .invite-row .btn { margin-left: 0; }
  .invite-link { flex-wrap: wrap; }
  .invite-link-label { width: auto; }
  .field--copy { flex-basis: 100%; }
}
