:root {
  --bg: #0b0d10;
  --panel: #14181d;
  --panel-2: #1b2027;
  --line: #2a323c;
  --text: #e6ebf0;
  --muted: #8b98a6;
  /* A step ABOVE --text, not a repeat of it. Body copy is --text and the quiet second
     half of a line is --muted; this is for the handful of lead phrases that have to be
     read before an action, and it is the only thing on the page allowed to outrank body
     copy. Restated in the light palette, because pure white there is invisible. */
  --text-strong: #ffffff;
  --accent: #e8a33d;
  --accent-dim: #6d4a12;
  /* Teal is no longer the brand colour, it means "connected". */
  --live: #4bd0c0;
  --danger: #ff6b5e;
  --warn: #ffc857;
  --ok: #57d977;
  --radius: 10px;
  --radius-sm: 6px;
  --radius-pill: 999px;

  /* ---- one type system, named once ------------------------------------------------
     Prose is the sans stack. Anything that is DATA rather than prose is the mono stack
     with tabular figures: gate codes, byte counts, timers, verification codes, ports,
     and the uppercase eyebrows that open a section. */
  --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
  /* Comfortable reading measure. Prose wider than this is harder to read on a big
     screen, which is why widening a page is never just raising its max-width. */
  --measure: 68ch;

  /* ---- semantic lines and washes --------------------------------------------------
     These used to be six copies of #5a4a20 and friends scattered through the file, so
     the light palette could not restate them and a warn border stayed dark-on-white.
     Every one of them is a token now, and every token has a light value below. */
  /* A rule, not a border. --line draws the edge of a PANEL and is meant to be seen;
     this one separates two rows of a list and is meant to be felt. They have to be two
     tokens: a ruled list drawn in --line reads as a stack of borderless boxes, which is
     the thing the onboarding screen was redrawn to stop doing. */
  --hairline: #1e242d;
  --line-warn: #5a4a20;
  --line-live: #1f5b54;
  --line-danger: #6b2b26;
  --line-ok: #245c33;
  --warn-wash: rgba(255, 200, 87, 0.10);
  --warn-wash-strong: rgba(255, 200, 87, 0.16);
  --accent-wash: rgba(232, 163, 61, 0.07);
  /* Text printed ON the accent. Amber is light, so the dark theme puts near-black on
     it; the light theme's accent is a dark amber and needs white. Without this token
     a.kofi shipped #231703 on #8a5410 in light mode, which is 2.6:1. */
  --on-accent: #231703;
  /* Small accent-coloured text (10-13px labels). The light accent is 3.99:1 on a card,
     which is fine for a 28px word and short of AA for a 10.5px label. */
  --accent-text: var(--accent);
  /* Always paper, in both themes: a QR code is only scannable as dark-on-white. */
  --qr-paper: #ffffff;
  /* Full-bleed scrim over the page (drop veil, status log). */
  --scrim: rgba(11, 13, 16, 0.96);
  --scrim-veil: rgba(11, 13, 16, 0.86);
  --shadow-lift: 0 24px 60px -40px rgba(0, 0, 0, 0.55);

  /* The height the status log may grow to. It is the bottom row of the page's flex
     column, so this bounds the row rather than reserving space under an overlay. */
  --log-max: 26vh;

  /* ---- the shell -------------------------------------------------------------------
     ONE container width for the whole application, and it is a function of the VIEWPORT
     rather than a fixed number of pixels. `min(2160px, 90vw)` means the window keeps 5%
     of itself as margin on each side from a phone all the way to 2400px: the column
     grows when the window grows instead of sitting in the middle of it.

     Measured before this rule, at 1920x1080: onboarding was a 1280px column with 313px
     of dead margin on the left and 328px on the right, which is 16% of the window doing
     nothing, and the home screen's Create button was below the fold at every width from
     390px to 1920px. Both were the same bug: a phone layout served to a desktop.

     The ceiling is 2160px rather than the 1680px first tried here, because 1680 put that
     same 16% of dead margin back on a 2560px monitor: it just moved the bug to a bigger
     screen. The ceiling is NOT what keeps text readable. Every run of prose carries its
     own measure (--measure, or a local ch cap: see .ob-head, .lp-lede, main.legal li), so
     line length is bounded whatever the shell does. What the ceiling bounds is how far
     the two column grids may stretch, and 2160 leaves 7.8% margin at 2560 while changing
     nothing at or below 2400, where 90vw is the smaller term and already wins.

     Box-sizing is border-box everywhere, so the 16px of gutter INSIDE the shell is part
     of this number and the header can align to the same edge with one calc. */
  --shell: 100%;
  --gutter: 16px;
  /* The legal pages size their column to the measure plus this, so it is a token rather
     than a literal in three places: change it and the column follows. */
  --legal-pad: 18px;
  /* The measure of a POLICY page, which is a different job from the measure of a note
     inside the app. A policy is read start to finish on whatever screen you happen to
     have; an in-app disclosure is glanced at beside a control. They were the same token,
     so widening the reading column for a desktop would have widened every disclosure on
     the connected screen with it. Below the desktop breakpoint this IS --measure, so a
     phone sees the shared number and nothing here moves. */
  --measure-legal: var(--measure);
  /* The table-of-contents rail beside a policy, and the space between it and the prose.
     Both are 0 below the breakpoint, where the rail is display:none. Keeping them as
     zero-valued tokens rather than omitting them means the column-width arithmetic is
     ONE expression at every width instead of two that have to be kept in step: the
     narrow case is the wide case with two terms that vanish. */
  --legal-rail: 0px;
  --legal-gap: 0px;
  /* The masthead is sticky at top:0, so anything that has to clear it needs its height:
     a scroll anchor that would otherwise land underneath it, and the top of the sticky
     rail. Measured at 1900x1000 it is 45.39px including its bottom border; 46 is that
     rounded up, because being a fraction of a pixel generous costs nothing and being a
     fraction short puts a heading under the bar. */
  --bar-h: 46px;

  /* ---- motion ----------------------------------------------------------------------
     Before these existed the file carried six transition declarations between them
     holding five different durations (0.15s twice, 0.18s three times, 0.62s once) and
     two different curves. That was never a scale. It was six independent decisions taken
     at six different times, and none of them could be revisited without first finding
     the other five. These four durations are the scale, and the numbers are Material 3's
     published motion tokens rather than taste, copied from material-web
     tokens/versions/v0_192/_md-sys-motion.scss, so "why 250ms" has an answer.

       micro   a press, a hover colour, a chevron. An acknowledgement, not a journey:
               longer than this and the feedback arrives after the finger has left.
       small   something small arriving or leaving: a message, a scrim, a banner.
       medium  something with size arriving: a modal panel, a section scrolled into view.
       large   a whole screen changing.

     --reveal-rise, --press-scale and --fold-scale are the ONLY three values the
     reduced-motion branch changes. Setting them to 0px, 1 and 1 collapses every translate
     and every scale in the file at once while leaving every fade running, which is what
     reduced actually means:
     MDN's wording is removed, reduced OR REPLACED, and Apple's HIG names the replacement
     directly, "replacing transitions across axes with fades". Killing the fade as well
     would remove the only remaining signal that anything happened, which is a loss for
     the same people the query exists to help. */
  --motion-micro: 100ms;    /* md.sys.motion.duration.short2  */
  --motion-small: 150ms;    /* md.sys.motion.duration.short3  */
  --motion-medium: 250ms;   /* md.sys.motion.duration.medium1 */
  --motion-large: 300ms;    /* md.sys.motion.duration.medium2 */
  --ease-enter: cubic-bezier(0.05, 0.7, 0.1, 1);   /* emphasized-decelerate */
  --ease-exit: cubic-bezier(0.3, 0, 0.8, 0.15);    /* emphasized-accelerate */
  --ease-standard: cubic-bezier(0.2, 0, 0, 1);     /* standard */
  --reveal-rise: 8px;
  --press-scale: 0.985;
  /* How far a panel is folded back at the hinge before it opens. Third travel token, added
     2026-08-11 with the gate drawers: a fold is a scale, so --reveal-rise cannot express it
     and a hard-coded 0.94 would be the one piece of travel the reduced-motion branch could
     not reach. */
  --fold-scale: 0.94;
  /* How far the phone games sheet rises from the bottom edge. A token so the reduce block
     below can flatten it the same way it flattens every other translate in this file. */
  --sheet-travel: 100%;
}

@media (min-width: 700px) {
  :root { --shell: min(2160px, 90vw); }

  /* The landing is the one document that does not want the application's shell, and it
     is the only one whose content is PROSE all the way down rather than controls. The
     shell above is sized so that grids of controls can stretch; a paragraph cannot, so
     on the landing every extra pixel of shell goes to the rules and the section borders
     and none of it goes to the words.

     Measured at 1920x1080 before this rule: `main` was 1728px, `.lp-section` was 1696px
     and its border-top ran to x=1801, `.lp-h2` was the same 1696px, and the text inside
     them stopped at 636px (.lp-lede, capped at 62ch) and 1011px (.lp-hero-head). Every
     heading rule was roughly three times the length of the sentence under it, which is
     what made the page read as pinned to the left edge: the rules were centred, the
     words were not, and the eye follows the longest line.

     1320px is chosen by measurement, not by taste. The hero is a two column grid above
     900px (1.12fr / 0.88fr), and at 1440x900 it resolves to 676px of copy beside a 531px
     gate preview, which is the width the hero was drawn at and reads correctly. A 1320px
     shell reproduces that pair at 1920 (measured after: 678 / 533), so the wide screen
     gets the hero it was designed for rather than a stretched one. Anything much below
     1240 starts squeezing the preview column; anything above ~1400 puts the dead rule
     back.

     `min(1320px, 90vw)` rather than a flat 1320px so this only ever REMOVES width that
     was doing nothing: 90vw is the smaller term below a 1467px viewport, so 1440, 1366
     and every phone render byte-identically to before and only screens wide enough to
     have the problem see the change.

     No @supports fallback. An engine without :has() keeps the application shell here,
     which is exactly what every engine renders today, so the fallback is the current
     page rather than a broken one. `.lp-hero` is a direct child of <main id="main"> in
     index.html and exists in no other document, so this cannot reach the gate. */
  body:has(main > .lp-hero) { --shell: min(1320px, 90vw); }
}
/* A phone has no width to spend on a gutter that is not text. Below this the legal
   column keeps every pixel it can for the prose, which at 360px is the difference
   between a comfortable line and a ladder of three-word rows. */
@media (max-width: 420px) {
  :root { --legal-pad: 13px; }
}
/* The desktop breakpoint for a policy page, and the only place any of this changes.
   Nothing below 1100px reads a different value for any of these four tokens.

   72ch rather than the shared 68ch, and 16px prose rather than 15px (see THE LEGAL
   PAGES): a `ch` is a character of the element's OWN font, so the two together take the
   rendered column from 583px to 659px on the same screen. The character count barely
   moves, which is deliberate. Line length is what makes prose readable and 68 characters
   was already at the comfortable limit; what a big screen should buy is bigger type and
   somewhere to put the navigation, not more words per line. */
@media (min-width: 1100px) {
  :root {
    --legal-pad: 26px;
    --measure-legal: 72ch;
    --legal-rail: 260px;
    --legal-gap: 56px;
  }
}

* { box-sizing: border-box; }

/* The UA stylesheet gives [hidden] display:none, but any author rule setting an
   explicit display silently beats it. That left the donation QR box showing as a blank
   white rectangle, and would have done the same to the pairing QR on the join screen.
   Anything hidden must actually be hidden. */
[hidden] { display: none !important; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font: 16px/1.55 var(--font-sans);
  -webkit-text-size-adjust: 100%;
}

.bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  /* The rule under the header runs the full width of the window; the brand and the
     badge line up with the shell. One calc rather than a second centred wrapper.
     No + var(--gutter): the bar is chrome, not a column of prose, and inset to the
     text column as well the wordmark read as floating in the middle of nowhere. It
     hangs a gutter outboard of the content now, which is where a masthead belongs. */
  padding: 12px max(12px, calc((100% - var(--shell)) / 2));
  border-bottom: 1px solid var(--line);
  background: var(--panel);
  position: sticky;
  top: 0;
  z-index: 5;
}

/* The wordmark is the only thing in the bar carrying the name, and at 14px in a 49px
   bar it read as a caption of the rule underneath rather than as the masthead. */
.brand {
  font-weight: 700;
  letter-spacing: 0.2em;
  font-size: 17px;
  line-height: 1.2;
  color: var(--accent);
}

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

/* The two marks in the corner: source, and the tip jar. Square hit areas rather than
   bare glyphs, because a 17px icon is a 17px target and these are tapped on phones. */
.bar-ico {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  border-radius: var(--radius);
  color: var(--muted);
  text-decoration: none;
  /* The colour moves on hover, the box does not: a bar that reflows under the cursor
     is worse than one that does nothing. That sentence settled it for this element and
     it settles it for the landing's cards too, which get colour and nothing else.
     `background`, not `background-color`, would animate the shorthand including the
     image slot; the longhand is the one the compositor can take. */
  transition: color var(--motion-micro) linear, background-color var(--motion-micro) linear;
}
.bar-ico .ico { width: 19px; height: 19px; vertical-align: 0; }
.bar-ico:hover, .bar-ico:focus-visible { color: var(--accent); background: var(--panel-2); }

.badge {
  display: inline-block;
  padding: 3px 9px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  background: var(--panel-2);
  color: var(--muted);
  white-space: nowrap;
}
.badge-idle { color: var(--muted); }
.badge-work { color: var(--warn); border-color: var(--line-warn); }
.badge-direct { color: var(--live); border-color: var(--line-live); }
.badge-relay { color: var(--warn); border-color: var(--line-warn); }
.badge-bad { color: var(--danger); border-color: var(--line-danger); }
/* The route badge names how the bytes travel, and that answer goes stale the moment the
   connection drops without becoming wrong: the route it names is the route that WAS there.
   Dimming says exactly that. It replaced overwriting the badge with the word RECONNECTING,
   which put a second status vocabulary next to the status pill's own and let the two
   disagree on screen. Opacity only, so it composites and needs no reduced-motion branch. */
.badge.is-stale { opacity: 0.45; }

.ttl { font-variant-numeric: tabular-nums; font-size: 13px; color: var(--muted); }

/* Every content row takes the shell. Nothing carries a max-width of its own any more:
   the screens used to each set their own with `main:has(> #screen-x:not([hidden]))`,
   which meant seven numbers to keep consistent and seven chances for one of them to be
   a phone column. */
main, .banner, .foot {
  width: var(--shell);
  max-width: 100%;
  margin-left: auto;
  margin-right: auto;
}
main { padding: 20px var(--gutter) 4px; }

h1 { font-size: 20px; line-height: 1.35; margin: 8px 0 18px; font-weight: 650; }
h2 { font-size: 15px; margin: 0 0 6px; font-weight: 650; }

.muted { color: var(--muted); }
.small { font-size: 13px; }

.card, .note {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px;
  margin-bottom: 14px;
}
.note p { margin: 6px 0 0; color: var(--muted); font-size: 14px; }
.note ul { margin: 6px 0 0 18px; color: var(--muted); font-size: 14px; }
.note.warn { border-color: var(--line-warn); }
.note.warn h2 { color: var(--warn); }

label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 6px; }

/* Gives a control a real <label for> that screen readers announce without adding
   anything to the layout. A placeholder is not an accessible name: it disappears the
   moment the field is typed into. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

input[type=text], input[type=password], input[type=file], select, textarea {
  width: 100%;
  padding: 11px 12px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  color: var(--text);
  font: inherit;
  margin-bottom: 10px;
}
textarea { resize: vertical; font-family: var(--font-mono); font-size: 14px; }
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-dim); outline-offset: 1px; }
/* Buttons and links had no focus style of their own, so they fell through to the UA ring,
   which Chromium paints PURE WHITE on a dark page. It was reported as a rendering fault
   rather than read as focus. The ring is the one thing a keyboard user navigates by, so it
   is restated in the palette rather than removed. */
button:focus-visible, a:focus-visible { outline: 2px solid var(--accent-dim); outline-offset: 2px; }
/* app.js gives the incoming screen `tabindex="-1"` and focuses it, so that a Tab press
   after a screen change starts inside the new screen and a screen reader is told the screen
   changed. That focus is programmatic and the element is not reachable by Tab, so the ring
   around it marks nothing anybody can act on: measured at 1920x1002 it was 2px of white
   around 1696x923 of layout, drawn around the whole gate. Keyed on the attribute app.js
   sets, so this can never reach a control a person can actually focus. */
.screen[tabindex="-1"]:focus { outline: none; }

button {
  font: inherit;
  font-weight: 600;
  border-radius: var(--radius);
  padding: 11px 16px;
  border: 1px solid transparent;
  cursor: pointer;
  width: 100%;
  /* transform and colour only. Nothing here may touch a layout property: these buttons
     sit in flex rows and grids, and a control that resizes itself on press moves its
     neighbours. */
  transition: transform var(--motion-micro) var(--ease-standard),
              background-color var(--motion-micro) linear,
              border-color var(--motion-micro) linear,
              color var(--motion-micro) linear;
}
button.primary { background: var(--accent); color: var(--on-accent); }
button.secondary { background: var(--panel-2); color: var(--text); border-color: var(--line); }
button.danger { background: transparent; color: var(--danger); border-color: var(--line-danger); margin-top: 20px; }
button.link { background: none; border: none; color: var(--accent); padding: 0; width: auto; text-decoration: underline; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
/* 1.5% down, which is enough to read as a press and small enough that it cannot be
   mistaken for the control moving. :not(:disabled) because a disabled button that
   depresses is telling the user it took the click. */
button:active:not(:disabled) { transform: scale(var(--press-scale)); }
/* .link is a run of underlined inline text inside a paragraph. Scaling that is a wobble,
   not a press, and it drags the words either side of it out of alignment. */
button.link:active { transform: none; }

.row { display: flex; gap: 10px; margin-bottom: 10px; }
.row button { flex: 1; }

.qr-wrap {
  display: flex;
  justify-content: center;
  background: var(--qr-paper);
  border-radius: var(--radius);
  padding: 14px;
  margin-bottom: 16px;
}
#qr { width: 100%; max-width: 300px; height: auto; image-rendering: pixelated; }

.code-block {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 12px 14px;
  margin-bottom: 12px;
}
.code-block .label { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.code {
  font-family: var(--font-mono);
  font-size: 15px;
  letter-spacing: 0.06em;
  /* Hyphen joined words, so the natural break opportunities are the hyphens themselves.
     break-all was right for one unbroken base32 string and splits MEADOW into MEAD / OW
     here, which is exactly the code somebody is trying to read aloud. `anywhere` stays as
     the last resort for a very narrow phone, and cannot trigger at seven letters. */
  word-break: normal;
  overflow-wrap: anywhere;
  color: var(--accent);
}

.route-line { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; flex-wrap: wrap; }

/* Who is in the gate. One pill per participant, this device first. It lives inside
   .route-line rather than as a grid child of #screen-connected, because that grid places
   every one of its children by hand and an unplaced item would land in somebody's cell.
   [hidden] is display:none !important globally, so an empty roster really disappears. */
.roster { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* Pills, so the roster scans as a row of people rather than as a run-on sentence. */
.roster .who-chip {
  font-family: var(--font-mono);
  font-size: 12.5px;
  letter-spacing: 0.04em;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  padding: 4px 11px;
  color: var(--muted);
  white-space: nowrap;
}
/* The name is set in the page font. The pill's monospace and letter-spacing exist for
   codes, and a two-word name typeset in them reads as a serial number rather than as
   something you would say out loud. The verification code keeps them, because it is one. */
.roster .who-name {
  font-family: var(--font-sans);
  letter-spacing: normal;
}
.roster .who-sas { font-variant-numeric: tabular-nums; }
.roster .who-chip.self { border-color: var(--accent); color: var(--accent); }
.roster .who-chip.live { border-color: var(--line-ok); color: var(--text); }
/* A peer that is not connected right now must not read the same as one that is. */
.roster .who-chip.away { opacity: 0.6; font-style: italic; }

/* With more than two people in the gate, a message says which of them it came from. */
.msg .who.from-peer { color: var(--muted); }

.tabs { display: flex; gap: 6px; margin: 16px 0 12px; }
.tab {
  flex: 1;
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--muted);
  padding: 9px 8px;
  font-size: 14px;
}
.tab.active { color: var(--text); border-color: var(--accent-dim); background: var(--panel-2); }

.messages {
  min-height: 180px;
  max-height: 46dvh;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 12px;
  margin-bottom: 10px;
}
.msg { margin-bottom: 9px; font-size: 15px; word-wrap: break-word; overflow-wrap: anywhere; }
.msg .who { font-size: 12px; color: var(--muted); display: block; }
.msg.me .who { color: var(--accent); }

/* ---------------------------------------------- one composer for everything */

.composer-box {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 10px;
}
.composer-box textarea {
  margin-bottom: 8px;
  min-height: 76px;
  max-height: 220px;
  /* Without this, one unbreakable token locks up the tab: Chromium laying out a
     64 KiB word blocked the main thread for 8.5 seconds, and 200 KiB for 29. The
     same bytes with newlines cost 377ms. A pasted key, JWT or password is exactly
     that shape, so this is the composer's most common input, not an edge case.
     .msg-text already wraps this way; the composer was simply missed. */
  overflow-wrap: anywhere;
  word-break: break-word;
  font-family: inherit;
  font-size: 15px;
  background: var(--panel-2);
  resize: none;
  overflow-y: auto;
}
.composer-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.composer-actions button { width: auto; flex: 0 0 auto; padding: 9px 14px; font-size: 14px; }
.composer-actions .primary { margin-left: auto; }
/* #compose-hint is no longer a child of the actions row: it sits inside `.hint-fold`,
   which is the flex item now and carries the `flex`/`order`/`margin-top` this rule used to
   set. See "a long standing note, folded to its first sentence" further down. */

.toggle {
  display: inline-flex;
  align-items: center;
  /* 44px because this is a control you hit with a thumb while holding the phone in one
     hand, and measured at 390x844 it was 61.3 x 20.9. Height on the LABEL and not on the
     checkbox: the label is already the hit area (clicking it toggles the input), so this
     buys the target without drawing a 44px checkbox next to a 14px word. */
  min-height: 44px;
  gap: 6px;
  margin: 0;
  font-size: 13.5px;
  color: var(--muted);
  cursor: pointer;
  white-space: nowrap;
}
.toggle input { width: 16px; height: 16px; accent-color: var(--accent); margin: 0; }

.drop-veil {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--scrim-veil);
  border: 3px dashed var(--accent);
  color: var(--accent);
  font-size: 22px;
  font-weight: 650;
  pointer-events: none;
}

/* ---------------------------------------------- message stream */

.msg-text { white-space: pre-wrap; overflow-wrap: anywhere; }
.msg.is-file, .msg.is-secret {
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 9px 11px;
}
.file-title { font-size: 14px; margin-bottom: 4px; overflow-wrap: anywhere; }
/* The filenames listed on a batch offer. Peer-chosen strings, up to 64 of them, so this
   has to break inside a word: one unbroken 120-character name would otherwise push the
   transcript column wider than the window and take the Accept button off screen with it. */
.file-names { overflow-wrap: anywhere; margin-bottom: 6px; }
/* One entry of that list. Until 2026-08-10 each line was a single text node, "name: state",
 * which CSS cannot truncate selectively: an ellipsis on the line eats the STATE, which is the
 * half the reader needs, and no ellipsis at all wraps every entry onto two lines (measured:
 * seven files went from 101px to 201px, see the note below). batchui.js now writes two spans,
 * so the name is the part that shrinks and the state always survives.
 *
 * `min-width: 0` on the name is the whole trick: a flex item defaults to min-width:auto, which
 * refuses to shrink below its content, so the ellipsis would never engage and the long name
 * would push the state out of the column instead.
 *
 * The state is NOT nowrap on purpose. Most states are two or three words, but a failed file
 * carries a whole sentence ("The connection to the other device was severed..."), and pinning
 * that to one unbreakable line would blow out the transcript width for exactly the entry the
 * reader most needs to read. It shrinks and wraps; the name ellipses. `overflow-wrap: anywhere`
 * is inherited from .file-names and matters here for the same reason it does there. */
.file-line { display: flex; align-items: baseline; }
.file-line-name {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The ": " separator lives at the head of this span, not at the tail of the name, so that an
 * ellipsed name reads "long-name-abo...: saved" rather than swallowing the colon too. */
.file-line-state { flex: 0 1 auto; min-width: 0; }
/* And bounded on a phone, because this list grew a state per file on 2026-08-10 and the
 * row grew with it.
 *
 * Measured at 390x844 in a real connected gate, one batch of seven files, offered and not
 * yet accepted:
 *
 *   before the states existed   .file-names  101px   seven names, one line each
 *   with a state per file       .file-names  201px   seven names, TWO lines each, because
 *                                                    "name: waiting for you to accept" does
 *                                                    not fit in a 308px column
 *   the row around it           .msg.is-file 306px   of an 844px viewport, 36%
 *   the transcript around that  #messages    443px   up from 128px, and that pushed the
 *                                                    composer from y 392..670 to 707..985
 *
 * 64 is the protocol cap (MAX_BATCH_FILES in link.js), so the unbounded version of this row
 * is 64 x 28.7 = 1837px: a single peer decision that owns the screen. 116px is eight lines
 * at the 14.4px line box measured above, which is four files with their states, and the
 * rest scroll. Nothing is hidden and nothing is summarised away: the tally underneath
 * ("3 of 7 saved, 1 arriving now, 3 waiting their turn") is OUTSIDE this box and always
 * visible, so the count never depends on scrolling to it.
 *
 * `overscroll-behavior: contain` for the same reason the status log has it: a nested
 * scroller on a phone must not hand the gesture to the page behind it once it hits its end,
 * which on this screen would scroll the composer away mid-read.
 *
 * 2026-08-10, later the same day: THE JS FIX LANDED. batchui.js emits two spans per line, so
 * the rules below can ellipse the name and keep the state, and an ordinary entry is one line
 * at any width. The cap stays: it is not made redundant by the fix, because 64 one-line
 * entries is still 64 lines, and a failed file carries a whole sentence of detail as its
 * state, which wraps by design rather than being truncated. What changed is that the cap is
 * now rarely reached instead of being reached by any batch of five. */
@media (max-width: 699.98px) {
  .msg.is-file .file-names {
    max-height: 116px;
    overflow-y: auto;
    overscroll-behavior: contain;
  }
}
.msg-image {
  display: block;
  max-width: 100%;
  max-height: 320px;
  border-radius: 6px;
  margin: 6px 0;
}
/* Inline video and audio. Bounded the same way the image is: a transcript row must never
   be able to push the composer off screen because of something a peer sent. width:100% on
   the audio player because a bare <audio> renders at the browser's own default width,
   which is narrower than the bubble on desktop and wider than it on a phone. */
video.msg-media {
  display: block;
  max-width: 100%;
  max-height: 320px;
  border-radius: 6px;
  margin: 6px 0;
  background: #000;
}
audio.msg-media {
  display: block;
  width: 100%;
  max-width: 100%;
  margin: 6px 0;
}
/* Save, Open, Accept and Refuse are the controls a person actually uses during a transfer,
   and at 6px of padding they measured 55.5 x 34.1 on a phone. 11px takes them to 44 tall
   without changing the type size, so a row of them still reads as secondary to the file
   name above it. */
.msg.is-file button, .msg.is-secret button { width: auto; padding: 11px 12px; font-size: 13px; }
.chip {
  display: inline-block;
  font-size: 10.5px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--warn);
  border: 1px solid var(--line-warn);
  border-radius: var(--radius-pill);
  padding: 1px 7px;
  margin-bottom: 5px;
}

.secrets, .transfers { margin-top: 12px; }
.secret-item, .transfer-item {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 10px 12px;
  margin-bottom: 8px;
  font-size: 14px;
}
.secret-value {
  font-family: var(--font-mono);
  word-break: break-all;
  background: var(--panel-2);
  border-radius: 6px;
  padding: 8px;
  margin: 6px 0;
  white-space: pre-wrap;
}
/* While masked the plaintext is not in the DOM at all: a fixed placeholder stands in
   for it. So this styles the placeholder, and deliberately does not blur real text.
   A blur still shows length and word shape on screen and in any screenshot; an even
   run of dots shows neither. */
.secret-value.masked {
  color: var(--muted);
  letter-spacing: 0.22em;
  user-select: none;
}
.secret-actions { display: flex; gap: 8px; }
.secret-actions button { width: auto; flex: 1; padding: 7px 10px; font-size: 13px; }

progress { width: 100%; height: 6px; margin-top: 6px; }

.error { color: var(--danger); font-size: 14px; }

/* ---------------------------------------------- status log, and not covering things

   The log used to be a fixed overlay with a padding reservation under the footer. That
   reserves space at the END of the document and does nothing about the middle of it: at
   any other scroll position the panel simply painted over whatever was beneath it. On a
   browser without showSaveFilePicker the capability sentence made the panel permanent,
   so the bottom 60px of EVERY screen was covered for the whole session.

   It is a layout row now, not an overlay. `body` is a viewport-tall flex column of
   [ .page | .log ], `.page` is the scroll container, and the log is the bottom row. The
   scrollable area is therefore the viewport minus the log, and the two cannot intersect
   at any scroll offset. `position: fixed` is kept as the default so that engines without
   :has(), and the legal pages which have no `.page`, still get a visible log.

   2026-08-10: the row became a DOCK. A layout row that could not be closed still spent
   four lines of every screen on a running commentary nobody had asked to read, and it
   outlived what it described: after Burn, the gate is gone and the strip was still
   reciting the reconnect attempts of a session that no longer exists. So the resting state
   is ONE line, the most recent one, and the history is behind a control. The dock is hidden
   outright until there is something to say, and severing empties it.

   The geometry below is unchanged in the part that mattered: it is still a layout row, not
   an overlay, so it still cannot paint over a control at any scroll offset. What changed is
   how much of the window the resting state costs, from `--log-max` (26vh) to one line. */
.log-dock {
  background: var(--scrim);
  border-top: 1px solid var(--line);
  /* Kept from the row it replaces, so an engine without :has() and the legal pages, which
     have no `.page`, still get a readable dock rather than one lost in the flow. */
  position: fixed;
  left: 0; right: 0; bottom: 0;
  /* Above the sticky bar, below the drop veil. */
  z-index: 40;
}
/* The resting state: the latest line and, when it repeated, how many times.
   A button rather than a bare row because it IS the control that opens the history, and
   the whole line is the hit target: a 12px chevron on a phone is not one. */
.log-toggle {
  display: flex;
  align-items: baseline;
  gap: 10px;
  width: 100%;
  /* Out of the global `button` rule, which is width:100% with a bottom margin and a card's
     padding. This is a status line, not a control card. */
  margin: 0;
  padding: 7px 14px;
  background: none;
  border: 0;
  border-radius: 0;
  text-align: left;
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 1.5;
  color: var(--muted);
  cursor: pointer;
}
.log-toggle:hover { color: var(--text); }
/* The one line must not wrap: a two-line "one line" makes the page height jump on every
   message, which is what a dock exists to stop. */
.log-latest {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.log-more { flex: 0 0 auto; opacity: 0.7; }
/* The chevron, drawn rather than typed: a glyph would inherit the monospace metrics and
   sit off the baseline of the text beside it. Rotates to point up when the history is
   open. */
.log-toggle::after {
  content: '';
  flex: 0 0 auto;
  width: 7px; height: 7px;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  transform: rotate(-45deg);
  transform-origin: 60% 60%;
  transition: transform var(--motion-micro) ease;
}
.log-dock[data-open] .log-toggle::after { transform: rotate(135deg); }
@media (prefers-reduced-motion: reduce) {
  .log-toggle::after { transition: none; }
}

.log {
  padding: 0 14px 8px;
  font-family: var(--font-mono);
  font-size: 12px;
  line-height: 1.5;
  color: var(--muted);
  max-height: var(--log-max);
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* display:none, not height:0. The history must be out of the LAYOUT when closed, because
   the check that nothing clickable sits under the dock measures the dock's rectangle: a
   collapsed-but-present panel would still claim 26vh of it. */
.log-dock:not([data-open]) .log { display: none; }
.log:empty { display: none; }

@supports selector(:has(*)) {
  body:has(> .page) {
    height: 100dvh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
  }
  body:has(> .page) > .page {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior-y: contain;
    /* A flex column so that `order` can move a page-level note without moving it in the
       DOM. Everything defaults to order 0 and stays exactly where it was written. */
    display: flex;
    flex-direction: column;
  }
  /* A block child of a flex column with `margin: 0 auto` is sized to its CONTENT, not
     stretched: auto cross-axis margins switch stretch off. Without this, `main` stopped
     honouring its own width and shrank to whatever its widest line happened to be. The
     header and the status log take the whole window; the content rows take the shell,
     and they have to say so HERE because this selector out-specifies a bare `main`. */
  body:has(> .page) > .page > * { width: 100%; }
  body:has(> .page) > .page > main,
  body:has(> .page) > .page > .banner,
  body:has(> .page) > .page > .foot { width: var(--shell); max-width: 100%; }
  /* #extras used to be a page-level row here, ordered 1 so it painted under `main`. It is
     a cell of the home screen's grid now (see THE HOME SCREEN below), so it is no longer
     a child of `.page` and neither its width nor its order can be set from here. */
  /* margin-top:auto in a flex column eats the free space ABOVE the footer, so on a short
     screen it sits on the bottom edge instead of floating halfway up with dead space
     under it. On a screen taller than the viewport there is no free space and this is a
     no-op, so it never pushes the footer off the end of long content. The connected
     screen overrides it back to 0: there `main` is the thing that grows. */
  body:has(> .page) > .page > .foot { order: 3; }
  /* What this browser can RECEIVE is a standing fact worth knowing, and it is not the
     headline of the consent screen. It used to be the first thing on the page at every
     width above 900px, so the first sentence a new visitor read was a caveat about file
     sizes. `order` moves it in the paint order without moving it in the DOM, so the
     reading order for assistive tech is unchanged.

     2026-08-10: order 1 put it BETWEEN the two gate cards and the numbered steps, which
     is the worst place available. It is a full-shell-width slab and the cards above it
     are narrower, so it read as a rule drawn across the middle of the page, cutting the
     one composition on the screen in half. It is the last thing now, under the grid and
     above the footer. Its WIDTH is dealt with separately, in THE HOME SCREEN below: being
     the last block did not stop it being the widest one. */
  body:has(> .page) > .page > #receive-note { order: 2; margin-top: auto; }
  /* The auto margin belongs to whichever of the two is FIRST in the bottom-anchored pair,
     never to both: two auto margins in a flex column SPLIT the free space, which would
     open a gap above the note as well as above the footer and anchor neither. The note is
     `hidden` on most screens and a hidden element generates no box, so on those screens
     the footer has to take the margin back. */
  body:has(> .page) > .page:not(:has(> #receive-note:not([hidden]))) > .foot { margin-top: auto; }
  /* The dock, not the history inside it: #log is a child now, and leaving this selector on
     .log would have taken the panel out of the dock's flow and put it back on top of the
     page as an overlay, which is the exact bug the row was built to fix. */
  body:has(> .page) > .log-dock {
    position: static;
    flex: 0 0 auto;
  }
}

/* Engines without :has() keep the fixed dock, so they keep a reservation. It is one line
   now rather than --log-max, because the resting height of the dock is one line: reserving
   26vh for a strip that is 30px tall was a band of dead space at the foot of every screen.
   The expanded history can still overlap there, and that is the accepted trade on an engine
   with no :has(): it happens only while the reader is holding it open. */
@supports not selector(:has(*)) {
  .foot { padding-bottom: 46px; }
}
.log div { margin: 2px 0; }
.log .bad { color: var(--danger); }
.log .warn { color: var(--warn); }
.log .ok { color: var(--ok); }

/* ---------------------------------------------- progressive disclosure */

details.disc {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  margin-bottom: 10px;
  overflow: hidden;
}
details.disc.warn { border-color: var(--line-warn); }
details.disc.compact { margin-top: 14px; background: transparent; }

details.disc > summary {
  list-style: none;
  cursor: pointer;
  padding: 13px 14px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  position: relative;
  padding-right: 40px;
}
details.disc > summary::-webkit-details-marker { display: none; }
details.disc > summary::after {
  content: "";
  position: absolute;
  right: 16px;
  top: 19px;
  width: 8px; height: 8px;
  border-right: 2px solid var(--muted);
  border-bottom: 2px solid var(--muted);
  transform: rotate(45deg);
  /* 100ms, down from 150ms. A chevron acknowledging a click is a micro-interaction, not
     travel, and micro is the tier for that. */
  transition: transform var(--motion-micro) var(--ease-standard);
}
details.disc[open] > summary::after { transform: rotate(-135deg); top: 22px; }
details.disc > summary:focus-visible { outline: 2px solid var(--accent-dim); outline-offset: -2px; }

.disc-title { font-weight: 620; font-size: 15px; line-height: 1.35; }
.disc-hint { font-size: 12.5px; color: var(--muted); }
details.disc.warn .disc-title { color: var(--warn); }

.disc-body { padding: 0 14px 13px; border-top: 1px solid var(--line); }
.disc-body p, .disc-body ul { color: var(--muted); font-size: 14px; margin: 10px 0 0; }
.disc-body ul { padding-left: 18px; }
.disc-body li { margin-bottom: 4px; }
.disc-body a { color: var(--accent); }

/* ---------------------------------------------- agreement */

.agreement {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px;
  margin-top: 18px;
}
.check { display: flex; gap: 11px; align-items: flex-start; margin-bottom: 14px; }
.check input { margin-top: 3px; width: 18px; height: 18px; flex: 0 0 auto; accent-color: var(--accent); }
.check span { font-size: 13.5px; line-height: 1.5; color: var(--text); }
.check a { color: var(--accent); }
.agreement .muted { margin: 8px 0 0; text-align: center; }

/* ---------------------------------------------- diagnostics */

pre.diag {
  font-family: var(--font-mono);
  font-size: 12.5px;
  color: var(--muted);
  white-space: pre-wrap;
  word-break: break-word;
  margin: 10px 0 0;
  background: var(--panel-2);
  border-radius: 6px;
  padding: 10px;
  overflow-x: auto;
}

/* ---------------------------------------------- footer and legal pages */

.foot {
  display: flex;
  /* Six links, one row, no wrap: at 360px the last of them overhung the viewport by 1.4px,
     which is enough to give the whole page a horizontal scrollbar. A footer is the one
     place where wrapping costs nothing. */
  flex-wrap: wrap;
  gap: 18px;
  justify-content: center;
  padding: 18px var(--gutter) 26px;
  font-size: 12.5px;
  border-top: 1px solid var(--line);
  margin-top: 30px;
}
.foot a { color: var(--muted); text-decoration: none; }
.foot a:hover { color: var(--accent); text-decoration: underline; }

.brand a { color: inherit; text-decoration: none; }

/* Centring, the ink colours, and the list indent. NOT the width, the padding or the type
   scale: those live in THE LEGAL PAGES near the foot of this file, and this rule used to
   restate them as `max-width: 720px` plus a literal padding and two literal heading
   sizes. Every one of those declarations lost to the later block and none of them had
   been the live value for some time, which is exactly the trap you would expect: the
   page was reported as "a 720px phone column on a desktop" from reading THIS line, when
   the number actually deciding the column was `--measure + 2 * --legal-pad` 1400 lines
   further down. A dead declaration that looks authoritative is worse than no
   declaration, so the dead ones are gone and the live ones are all that is left. */
main.legal { margin: 0 auto; }
main.legal p, main.legal li { color: var(--muted); }
main.legal ul { padding-left: 20px; }
main.legal li { margin-bottom: 6px; }
main.legal a { color: var(--accent); }

.fillme {
  background: var(--warn-wash-strong);
  border: 1px dashed var(--warn);
  border-radius: 4px;
  padding: 0 5px;
  color: var(--warn);
  font-size: 13px;
}

/* ---------------------------------------------- capability banner */

.banner {
  position: relative;
  margin-top: 14px;
  /* The right pad is the close button's 28px plus its 8px inset, so the heading and the
     prose wrap before they reach it rather than running underneath it. */
  padding: 12px 50px 12px 14px;
  background: var(--warn-wash);
  border: 1px solid var(--warn);
  border-radius: var(--radius);
  color: var(--text);
  font-size: 13.5px;
  line-height: 1.55;
}
.banner strong { display: block; color: var(--warn); margin-bottom: 4px; }
/* Informational variant: a limitation worth knowing, not a failure. */
.banner.note { background: var(--accent-wash); border-color: var(--accent-dim); }
.banner.note strong { color: var(--accent); }
.banner code { font-family: var(--font-mono); }
.banner-steps { margin: 8px 0 10px; padding-left: 20px; color: var(--muted); }
.banner-steps li { margin-bottom: 3px; }
.banner .muted { margin: 6px 0; }
.banner button { width: auto; padding: 7px 14px; font-size: 13px; margin-top: 4px; }
/* Comes after `.banner button` deliberately: same specificity, so the later rule wins
   and the close button keeps its own square shape. */
.banner-close {
  position: absolute;
  top: 8px;
  right: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  margin-top: 0;
  padding: 0;
  background: none;
  border: 1px solid transparent;
  border-radius: 7px;
  color: var(--muted);
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
}
.banner-close:hover,
.banner-close:focus-visible { color: var(--text); background: var(--panel-2); border-color: var(--line); }

/* ---- a long standing note, folded to its first sentence -------------------------------
 *
 * Two places on this site show the SAME paragraph out of describeLimit(): #receive-note
 * above the onboarding and home screens, and #compose-hint inside the connected screen's
 * composer. Both were measured at 390x844 before this block and both were the largest
 * object on their screen:
 *
 *   #receive-note   183px of an 844px viewport (21.7%), nine lines
 *   #compose-hint   160px inside a 342px composer, eight lines, and it pushed the
 *                   composer's own bottom edge 27px below the fold
 *
 * After, and these are the shipped numbers read back off a real browser by the geometry
 * checks in tests/browser.test.mjs rather than the estimate this block was drafted with:
 *
 *   #receive-note   106px of an 844px viewport (12.6%), three lines
 *   #compose-hint    98px inside a 280px composer, three lines, and the composer's bottom
 *                   edge is at y=685 against a scroller that ends at 811
 *
 * An earlier draft of this comment claimed 80px (9.5%) and 39px, which was two lines at
 * each site. The clamp resolves to THREE on a phone (see --note-fold-lines below), so
 * those figures never existed on any screen: corrected here rather than left as a number
 * a later reader would measure against and disbelieve.
 *
 * One skin, because it is one problem in two places, and a second skin for the second
 * place is how two things that must agree stop agreeing.
 *
 * The clamp is on an inner span rather than on the <summary> itself, and that is what
 * makes the control legible: `summary::after` is the "Read the rest" line, it is a block
 * OUTSIDE the clamped span, so it is never eaten by the clamp and never has to be an
 * absolutely positioned chevron fighting the close button for the top right corner. It is
 * part of the summary, so pressing it toggles.
 *
 * `display: -webkit-box` is deliberately the closed state and `display: block` the open
 * one: switching display is what turns the clamp off, so an engine that ignores
 * -webkit-line-clamp entirely simply shows the whole note and loses nothing. Failing open
 * is the only acceptable direction for a note about what the browser can and cannot do. */
.note-fold > summary {
  display: block;
  list-style: none;
  cursor: pointer;
}
.note-fold > summary::-webkit-details-marker { display: none; }
.note-fold > summary > .note-fold-text {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  /* Three on a phone, two from 900px up: see the two --note-fold-lines overrides. Three
     lines of a 358px column is the whole of "Files over 500 MB are saved by this browser's
     own download manager as they arrive, so there is no size limit", which is the sentence
     that answers the question. Two lines of a 980px column is the same sentence. */
  -webkit-line-clamp: var(--note-fold-lines, 3);
  overflow: hidden;
}
.note-fold[open] > summary > .note-fold-text {
  display: block;
  -webkit-line-clamp: none;
}
.note-fold > summary::after {
  content: "Read the rest";
  display: block;
  margin-top: 5px;
  font-size: 12px;
  color: var(--accent);
  text-decoration: underline;
  text-underline-offset: 2px;
}
.note-fold[open] > summary::after { content: "Show less"; }
/* The summary is the control, so it is the thing that has to show focus. Without this the
   ring lands on nothing visible, because the default marker was turned off above. */
.note-fold > summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 5px;
}
@media (min-width: 900px) {
  .note-fold { --note-fold-lines: 2; }
}
/* THE DISCLOSURE'S BODY. It did not exist when this block was first written: describeLimit()
 * returned one text node, all of it went into the summary, and the <details> had nothing
 * inside it but the control. That is not a cosmetic shortcut, it is an accessibility defect,
 * because a line clamp hides PIXELS AND NOT TEXT: the whole paragraph was in the accessibility
 * tree at all times and the press changed only what was painted. describeLimit() returns a
 * lede and a detail as of 2026-08-10, so the summary carries the answer, this carries the
 * rest, and `details` really does remove it while closed. The clamp above stays: the lede is
 * short enough that it never bites now, and leaving it means an engine that ignores
 * -webkit-line-clamp still fails open in exactly the same direction. */
.note-fold > .note-fold-body {
  margin: 6px 0 0;
  font-size: 13px;
  color: var(--muted);
}

/* #compose-hint carries the same paragraph inside the composer. It used to be the flex
   item itself; the fold is now, so the two declarations that placed it move up one level.
   `order: 9` keeps it last in the actions row and `flex: 1 1 100%` keeps it on a line of
   its own under Attach/Secret/Send. */
.hint-fold { flex: 1 1 100%; order: 9; margin-top: 2px; }
/* app.js does not write the hint until the gate connects, and for that instant the fold
   would be a "Read the rest" control over nothing. `:has()` is unguarded here on purpose:
   an engine without it paints an empty fold for one frame, which is the harmless
   direction, and this file already depends on `:has()` for the screen-scoped rules. */
.hint-fold:has(#compose-hint:empty) { display: none; }
/* The transient "Trimmed to 16,000 characters", which used to be written into #compose-hint
 * itself. One element carrying two unrelated messages meant a 65-character notice about what
 * just happened inherited the standing note's fold, and got a "Read the rest" control over
 * text that was entirely visible. A control that reveals nothing teaches the user that the
 * control reveals nothing, which costs the note where the control matters. Its own element
 * since 2026-08-10, outside the fold, on its own line under the actions row. `order: 10`
 * puts it after the fold, which is 9. */
.compose-trim { flex: 1 1 100%; order: 10; margin: 2px 0 0; }

.path-row { display: flex; gap: 8px; align-items: stretch; margin: 8px 0 4px; }
.path-row code {
  flex: 1;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 8px 10px;
  font-size: 13px;
  color: var(--accent);
  word-break: break-all;
  user-select: all;
}
.path-row button { flex: 0 0 auto; margin-top: 0; }

/* ---------------------------------------------- header flourish */

.bar { position: sticky; }
.bar::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -1px;
  height: 1px;
  background: linear-gradient(90deg, transparent, var(--accent), transparent);
  background-size: 220% 100%;
  opacity: 0.55;
}
/* The sweep is LANDING ONLY, and .lp-bar is the class that says so. It used to run on
   `.bar`, which is the same masthead in both documents, so a 9 second loop ran forever
   in a sticky bar at the top of the gate: permanent motion in peripheral vision while
   somebody reads the SAS words off the screen below and compares them out loud with
   another human. Peripheral looping motion during a security-critical reading task is
   the worst pairing on this site, and "did that just change?" is a question the SAS
   comparison cannot afford to raise about anything on the page.
   On the landing it stays, and it is reachable: #motion-toggle stops it. */
.lp-bar::after { animation: sweep 9s linear infinite; }
@keyframes sweep {
  0% { background-position: 120% 0; }
  100% { background-position: -120% 0; }
}

/* ---------------------------------------------- bridge, steps, support */

/* `.extras` used to set the padding of a page-level band here. That band is gone: its one
   child, the numbered steps, is a cell of the home screen's grid now and takes its spacing
   from the grid's own gap. */

.bridge { text-align: center; margin: 6px 0 20px; }
.bridge svg { width: 100%; max-width: 380px; height: auto; }
.bridge .node { fill: var(--panel-2); stroke: var(--line); stroke-width: 1.5; }
.bridge .screen-glow { fill: var(--accent); opacity: 0.22; }
.bridge .wire { stroke: var(--line); stroke-width: 2; }
/* The dashes are static now. They used to flow along the wire on a 2.6s infinite loop,
   which is WCAG SC 2.2.2 (Pause, Stop, Hide, level A) with no mechanism to stop it: the
   pause control is #motion-toggle and it is landing markup, while .bridge is gate markup,
   so there is no state in which a gated flow could both animate and be controllable. A
   dashed line already reads as a path with something travelling it; the loop was the
   part that could not be turned off. */
.bridge .flow {
  stroke: var(--accent);
  stroke-width: 2;
  stroke-dasharray: 10 16;
}
.bridge .lock rect { fill: var(--bg); stroke: var(--accent); stroke-width: 1.6; }
.bridge .lock path { stroke: var(--accent); stroke-width: 1.6; }
.bridge-cap { color: var(--muted); font-size: 13px; margin: 2px 0 0; }

.steps {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
  padding: 0;
  margin: 0 0 16px;
}
.step {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 11px 10px;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.step-n {
  font-size: 11px;
  font-weight: 700;
  color: var(--accent);
  letter-spacing: 0.1em;
}
.step b { font-size: 13.5px; }
.step span:last-child { font-size: 12px; color: var(--muted); line-height: 1.4; }

@media (max-width: 560px) {
  .steps { grid-template-columns: repeat(2, 1fr); }
}

/* ---------------------------------------------- the gate on a big screen

   Everything below is scoped to the gate document by `#gate-controls`, `#extras` and
   `main:has(> .screen)`, none of which exist in index.html. The landing shares the
   `.lp-action` and `.lp-actions` skins (its hero has a pair of cards in `#gate-links`)
   and must not be reached by any of it, which is why every rule below is written as
   `#gate-controls .lp-action` and never as `.lp-action`. `.steps` and `.step` are app
   skins that the landing does not use at all (it has `.lp-steps`), but they are scoped
   here too: the ban is on reaching a shared skin, not on whether it happens to be shared
   this week.

   Measured at 1920x1080 before this block, on the home screen:
     .bar            h=45  at y=0
     main            y=45  h=380      (the whole shell, 1728px wide)
     #gate-controls  y=117 h=304, two .lp-action cards 841px wide each
     #extras         y=516 h=115, four .step cards 418px wide each
     .foot           y=1016
   so the content finished at y=631 and 385px of a 1080px viewport was one empty band
   above the footer, while the cards themselves were twice the width of the forms inside
   them. The burned screen was the same shape with a bigger band: content ended at y=506
   and the gap to the footer was 510px.

   Two separate faults, fixed separately below: the content is too WIDE for what it
   contains, and the free height is all spent in one place instead of between the blocks.
   --------------------------------------------------------------------------------- */

/* Declared here rather than in the token block at the top because the rules that read it
   are all in this section: a token nobody else can reach is easier to reason about next
   to its users.

   980px, from the prose inside the cards rather than from the window. It used to make two
   equal 476px cards; the grid below splits it 1.15fr / 0.85fr instead, so the lead card is
   547px and the join card 405px. After the card's own 24px inset the join copy ("Paste the
   link, or type the eight words shown on the other device...") gets 357px, which at 13.5px
   is about 51 characters, and the two step columns beside it get 190px each, which is two
   comfortable lines of their 12px caption. Below 980px of available width the cap is inert,
   so no phone and no tablet sees this number at all. */
:root { --gate-col: 980px; }

#gate-controls {
  max-width: var(--gate-col);
  /* `main` is already centred (see `main, .banner, .foot`), so a cap is all that is needed
     to centre the grid inside it. Left/right only, and the top margin is zeroed rather
     than inherited: `.lp-actions` adds clamp(28px, 3vw, 52px) above 900px for the landing
     hero, where the cards follow a headline. Here they are the first thing on the screen
     and that 52px was half of the dead band this section exists to close. */
  margin-left: auto;
  margin-right: auto;
  margin-top: 0;
}

/* ================================================ THE HOME SCREEN: one composition
 *
 * The complaint, at 1920x1080, in numbers measured off the page before this block:
 *
 *   "Open a gate"    y 232..435, h=203      the two cards are top-aligned and unequal,
 *   "Join one"       y 232..559, h=327      so 124px of the left column is a void
 *   band above       bar ends y=45,         187px of a 1080px viewport with nothing in it
 *                    cards start y=232
 *   the four steps   y 678..775             a third band, 119px below the cards, tied to
 *                                           nothing above it
 *   three measures   cards  x 470..1450 (980px)
 *                    steps  x 470..1450 (980px)
 *                    banner x  96..1824 (1728px)
 *   so the least important block on the screen was also the widest one.
 *
 * After, same viewport (the numbers the geometry checks in tests/browser.test.mjs read
 * back, so they are the shipped values and not the sketch this block started from):
 *   "Open a gate"    y  95..634, h=539      one rectangle, the full height of the column
 *   "Join one"       y  95..422, h=327      beside it, and 539 = 327 + 28 + 184
 *   the four steps   y 450..634, h=184      the lower half of the RIGHT column, inside the
 *                                           grid rather than a band under it
 *   band above       50px, from 187px
 *   one measure      cards, steps, banner and footer all x 470..1450 (980px)
 *
 * The shape is the user's: one big rectangle to the left, everything else stacked to its
 * right. The left card is the wider of the two columns (1.15fr against 0.85fr, the same
 * order as the landing hero's 1.12/0.88) and it stretches over both rows, so the button
 * `margin-top: auto` already pushes to its foot lands at the bottom of the whole
 * composition instead of 125px up.
 * ================================================================================= */

/* Below 700px the lead card is shrink-wrapped, so there is no slack for the picture inside
   it to take and it would only push Create down a phone screen. Declared BEFORE the media
   query that turns it on, because the two rules have identical specificity and the later
   one wins: written the other way round the picture would be hidden at every width and the
   rectangle would be empty again on exactly the screens that need it filled. */
#gate-controls .lp-action-lead > .bridge { display: none; }

@media (min-width: 700px) {
  /* 700px, not 900px, because that is where `.lp-actions` itself becomes two columns.
     Picking a different number would have left a band between the two breakpoints where
     the cards are side by side and the steps are auto-placed into whichever cell is
     free, which is a layout nobody chose. */
  #gate-controls {
    grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
    grid-template-areas:
      "lead join"
      "lead steps";
    /* `.lp-actions` sets `align-items: start` from 700px so the landing's hero cards keep
       their own heights. Here the left card IS the composition and has to reach the foot
       of the column beside it, so stretch is put back. Scoped to the id, so the landing
       keeps `start`. */
    align-items: stretch;
  }
  #gate-controls > .lp-action-lead { grid-area: lead; }
  #gate-controls > .lp-action:not(.lp-action-lead) { grid-area: join; }
  #gate-controls > #extras { grid-area: steps; }

  /* The rectangle has to read as the thing you press. Its heading was the same
     clamp(17px, 1.15vw, 22px) as the card beside it, which made two equal titles on a
     screen whose whole point is that one of these is the primary action. */
  #gate-controls .lp-action-lead h2 { font-size: clamp(21px, 1.9vw, 34px); }
  #gate-controls .lp-action-lead > p { font-size: 14.5px; }

  /* And the button has to be the size of the decision. A 49px control at the foot of a
     539px rectangle is a link with a background; this is the one thing on the screen the
     visitor came to press. */
  #gate-controls .lp-action-lead > button {
    padding: 18px 16px;
    font-size: 16px;
  }

  /* The diagram takes the slack, and SPLITS it.
   *
   * Two `margin-top: auto` in the same flex column share the free space between them
   * rather than one taking all of it (the same mechanic the status-log section relies on
   * for #receive-note and .foot). The button already carries one, from `.lp-action >
   * button`. Adding a second here puts equal space above and below the picture, so the
   * card reads as heading block / picture / action rather than as a heading with 250px of
   * nothing under it. Measured at 1920x1080: 87px above, 87px below. */
  #gate-controls .lp-action-lead > .bridge {
    display: block;
    margin-top: auto;
    margin-bottom: 20px;
  }
}

/* #home-error is deliberately given no area. It is `hidden` in the resting state, and a
   hidden element is `display: none` and therefore not a grid item at all, so it creates no
   implicit row and no row gap. Naming a third row for it would have cost a permanent 28px
   of empty track under the composition for an error that is almost never on screen. When
   it does appear, `.lp-actions #home-error { grid-column: 1 / -1 }` spans it across an
   implicit row below both columns, which is where an error about either action belongs. */

/* The steps are a LEGEND, not a third action.
 *
 * They used to be four filled panels with a border each, which was correct when they were
 * a band of their own across the page and wrong the moment they moved into the right
 * column: card, card, card, card down one side reads as three peers plus a heading, not
 * as "here is what happens next". They are hairline-ruled rows now, no fill and no box,
 * and the only thing that carries the accent is the number. Nothing was removed: same
 * four steps, same words, same numbers. */
#gate-controls #extras { padding: 0; }
#gate-controls .steps {
  /* 2x2 at every width inside the grid. The shared rule is 4-across, which in a 405px
     column would be four 90px slivers. */
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0 22px;
  margin: 0;
}
#gate-controls .step {
  background: none;
  border: 0;
  border-top: 1px solid var(--line);
  border-radius: 0;
  padding: 11px 0 13px;
}

/* One measure for the whole screen.
 *
 * `#receive-note` and `.foot` are children of `.page` and take `var(--shell)`, which is
 * 1728px at 1920. The composition is 980. A 1728px slab under a 980px grid is the thing
 * that made the banner read as a rule drawn across the page rather than as the last
 * paragraph of it, and it is why nothing on the screen explained why the least important
 * block was the widest.
 *
 * Scoped to the home screen by `:has()`, because the consent screen genuinely wants the
 * shell: it is a ruled list of five disclosures and a diagram, and 980px would put it back
 * into the phone column that the onboarding work took it out of. On an engine without
 * `:has()` this simply does not apply and the page renders as it does today, which is the
 * safe direction to fail in. */
@supports selector(:has(*)) {
  body:has(> .page) > .page:has(> main > #screen-home:not([hidden])) > #receive-note,
  body:has(> .page) > .page:has(> main > #screen-home:not([hidden])) > .foot {
    max-width: var(--gate-col);
  }

  /* And the note joins the composition instead of floating above the footer.
   *
   * The band the owner measured: at 1920x1080 the grid ended at y=636 and the note began
   * at y=889, so 253px of empty page sat between a composition and the paragraph that
   * annotates it, and the note then hovered 44px above the footer attached to nothing.
   * The cause is one declaration: `#receive-note { order: 2; margin-top: auto }` in the
   * status-log section. `order` is what puts the note after `main` despite being authored
   * before it, and that part is right and stays. `margin-top: auto` is the part that
   * pinned it to the bottom, and on this screen it is wrong: a note about what this
   * browser can receive belongs directly under the two cards that offer to receive
   * something, at the same measure, 14px away, exactly like the 14px between the cards
   * themselves.
   *
   * The free height does not disappear, it moves: it is now one band above a
   * bottom-anchored footer, which is what the rest of this file already treats as the
   * resting shape of a short page (see the rejected `space-between` note on the grid).
   * Measured after: grid ends 636, note 650..712, footer 1016..1080, so the gap the owner
   * pointed at is 14px and the free height is 304px in one place at the foot of the page.
   *
   * Also the phone, and this is the same rule doing the same job: at 390x844 the auto
   * margin resolved to 0 because the page already overflowed, so the note was flush
   * against the steps at y=785 with 0px between them and then 44px before the footer. The
   * explicit 14px makes the rhythm 14 / 30 instead of 0 / 44.
   *
   * Scoped to home rather than written into the base rule, because the auto margin is
   * load bearing on the OTHER screen this note appears on. app.js shows it on onboarding
   * and home only; on onboarding the note is the last thing under a long consent screen
   * and bottom-anchoring it is what stops it floating when that screen is short. The
   * footer takes the auto margin back here, so exactly one item in the column carries it
   * and the free space still lands in one place rather than being split two ways. */
  body:has(> .page) > .page:has(> main > #screen-home:not([hidden])) > #receive-note {
    margin-top: 14px;
    /* The floor under the footer's auto margin, and it has to be here rather than left to
       `.foot { margin-top: 30px }`, because `auto` REPLACES that 30px and resolves to 0 on
       any screen with no free space to take. Without this line the phone traded a 44px gap
       for a 0px one and the footer's rule would touch the note's bottom edge: an auto
       margin is a way of spending surplus, never a way of guaranteeing a minimum. */
    margin-bottom: 30px;
  }
  body:has(> .page) > .page:has(> main > #screen-home:not([hidden])) > .foot {
    margin-top: auto;
  }
}

/* The free height, distributed instead of dumped.

   `.page` is a flex column whose LAST bottom-anchored item carries `margin-top: auto`
   (see the status log section: it is #receive-note when that note is on screen and .foot
   when it is not), which is correct for a SHORT screen and is what puts the footer on the
   bottom edge. What it cannot do is share: one auto margin takes ALL the free space, so
   on a tall window every spare pixel landed in a single band above the footer and the
   content was crushed into a strip under the header.

   Two auto margins in the same column split the free space in half: above the content and
   above the footer. That moves the content off the top edge on the screens that are a
   short pane in a tall window.

   The home screen is the exception now, and it is the reason this comment changed. It used
   to be a THREE way split, because #extras was a page-level row between `main` and the
   footer and carried an auto margin of its own. Both of those are gone: the steps are a
   cell of the grid, and an equal share above the content is exactly the 187px band the
   user was looking at. Home takes a bounded band instead (see `padding-top` below) and
   gives the rest to the footer.

   This stays a no-op everywhere it should be. On a document taller than the viewport
   there is no free space to divide, so it cannot push the footer off the end of long
   content, exactly as the single auto margin could not.

   Gated on BOTH axes on purpose. min-width 1200px keeps it away from a tablet, and
   min-height 800px keeps it away from 1366x768, where the measured gap above the footer
   was already only 64px on the home screen: there is nothing to distribute there and
   opening it up would only cost the page its fold. A phone fails both terms. */
@media (min-width: 1200px) and (min-height: 800px) {
  @supports selector(:has(*)) {
    body:has(> .page) > .page > main:has(> .screen) { margin-top: auto; }
    /* #screen-connected is the documented exception. There `main` is the thing that
       grows (it is flex: 1 1 auto, and .foot's margin-top is already forced back to 0
       for the same reason), so an auto margin above it would be arguing with the flex
       that is supposed to give the transcript the window. Written as an override rather
       than as a :not() on the selector above so that the reason can sit next to it. */
    body:has(> .page) > .page > main:has(> #screen-connected:not([hidden])) { margin-top: 0; }
    /* And home, for the opposite reason: it is a composition rather than a pane, and half
       the free height above it is a void, not breathing room. */
    body:has(> .page) > .page > main:has(> #screen-home:not([hidden])) { margin-top: 0; }
  }

  /* Spend the width the same way, and OUTSIDE the @supports above: none of this needs
     :has(), and an engine without it should still get the spacing even though it cannot
     get the height distribution.

     These are the phone numbers the gate has always used (14px between the cards, 11px/10px
     inside a step) being served to a 1920px screen, where they read as hairlines rather
     than as separations. Each is opened up, which keeps the existing rhythm rather than
     inventing a second one. */
  #gate-controls { gap: 28px; }
  /* The grid keeps its NATURAL height, and that is a decision rather than an omission.
   *
   * Tried and rejected: `min-height: min(62vh, 660px)` with `align-content: space-between`,
   * to spend the 253px that is left between the composition and the capability banner at
   * 1920x1080. It does close that gap (the grid runs y=98..758 and 131px is left), but it
   * buys it by opening a 149px hole between "Join one" and the four steps, and the steps
   * then float in the middle of the right column attached to nothing. That is the original
   * complaint about the steps, reproduced inside the fix for it. Measured both ways and
   * kept the shorter grid: quiet page above a bottom-anchored footer is not a defect, and
   * a legend detached from the card it belongs to is. */
  /* The inset is taken from the card's own heading, which is clamp(17px, 1.15vw, 22px)
     and therefore 22px here, rounded up to the 24 the rest of this file steps in: a
     panel whose inset matches its title reads as a panel, where the shared clamp's
     14-to-22 was drawn for a card inside the landing hero at 1440. */
  #gate-controls .lp-action { padding: 24px; }
  #gate-controls .steps { gap: 0 24px; }
  #gate-controls .step { padding: 14px 0 16px; gap: 3px; }
}

/* The band above the composition, bounded rather than shared.
 *
 * Measured at 1920x1080: the masthead ends at y=45 and the cards now start at y=95, so the
 * band is 50px. It was 187px, made of an equal-thirds auto margin (111px), main's own
 * 20px of padding and `.lp-actions`' 52px top margin, none of which knew about the other
 * two. `vh` rather than a constant so a 2560x1440 display gets a proportionally larger
 * opening and a 1280x720 laptop does not lose its fold to one; the clamp floor keeps it
 * sane on a short window. The `max-width: 899.98px` rule further down this file sets 12px for
 * a phone and comes later, so it still wins there. */
main:has(> #screen-home:not([hidden])) { padding-top: clamp(12px, 4.5vh, 52px); }

/* ---------------------------------------------- icons */

/* The sprite is never painted; <use> clones out of it regardless. */
.icon-sprite { display: none; }

.ico {
  width: 1em;
  height: 1em;
  /* The glyph sits on the text baseline rather than the line box, so a mark next to a
     word lines up with the word instead of hanging below it. */
  vertical-align: -0.125em;
  flex: none;
}

/* ---------------------------------------------- support */

/* This was a collapsed <details> that read as a footnote. It is a section now, in the
   same shape as the landing's own sections: eyebrow, headline, lede, then a grid. */
.support { margin: 30px 0 6px; }
.support .lp-lede { margin-bottom: 18px; }

.support-grid {
  display: grid;
  gap: 14px;
  grid-template-columns: 1fr;
}
@media (min-width: 700px) {
  .support-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
/* Four across only when a card can still hold a wrapped Monero address without becoming
   a column of single characters. Below that the coins keep the full row. */
@media (min-width: 1200px) {
  .support-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

.support-card {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px;
  text-decoration: none;
  color: var(--text);
}
a.support-card:hover { border-color: var(--accent-dim); }
.support-mark {
  font-size: 22px;
  line-height: 1;
  color: var(--accent);
  margin-bottom: 2px;
}
.support-name { font-weight: 650; font-size: 15px; }
.support-note { color: var(--muted); font-size: 13px; }

/* The Ko-fi card is the lead action, so it is the one that carries the accent fill.
   Selector must out-specify `.disc-body a` and `.support-card`, either of which would
   otherwise colour this accent-on-accent and render the label invisible. */
.disc-body a.kofi, a.kofi.support-card, a.kofi {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
  font-weight: 650;
  text-decoration: none;
}
a.kofi .support-mark, a.kofi .support-note { color: var(--on-accent); }
a.kofi:hover { filter: brightness(1.08); border-color: var(--accent); }

/* The footer mark sits inline with its label. */
.foot-gh { display: inline-flex; align-items: center; gap: 6px; }

.coin {
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 11px 12px;
  margin-bottom: 10px;
}
/* Inside the support grid the card already draws the panel, so the coin does not draw
   a second one inside it. */
.support-grid .coin { margin-bottom: 0; padding: 14px; }
.support-grid .coin .coin-addr, .support-grid .coin .coin-actions { width: 100%; }
.coin-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 6px; }
.coin-name { font-weight: 620; font-size: 14px; }
.coin-tick {
  font-size: 11px;
  color: var(--accent);
  border: 1px solid var(--accent-dim);
  border-radius: var(--radius-pill);
  padding: 1px 7px;
}
.coin-addr {
  display: block;
  font-family: var(--font-mono);
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--text);
  word-break: break-all;
  background: var(--bg);
  border-radius: 6px;
  padding: 8px;
  margin-bottom: 8px;
}
.coin-actions { display: flex; gap: 8px; }
.coin-actions button { flex: 1; padding: 8px 10px; font-size: 13px; }
/* ---------------------------------------------- QR lightbox
   Replaces the panel that used to open inside the card. The code is scanned by holding
   a phone to the screen, so it gets the middle of the viewport and a dimmed page behind
   it instead of a slot in a four column grid. */
.qr-modal {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  opacity: 0;
  /* linear, not ease: this is a flat dark layer covering the whole viewport, and there is
     no shape in it for a curve to describe. */
  transition: opacity var(--motion-small) linear;
}
.qr-modal.qr-open { opacity: 1; }

.qr-modal-scrim {
  position: absolute;
  inset: 0;
  /* Grey, not black: the page stays legible behind the code, which is what tells you
     the site is still there and you are looking at something on top of it. */
  background: rgba(12, 15, 19, 0.66);
}

.qr-modal-panel {
  position: relative;
  /* Half again as wide as it was. A donation QR is read by a phone camera held at arm's
     length across a room, and at 260px the modules were small enough that a phone had to
     be brought right up to the screen. The min() keeps it inside a narrow viewport. */
  width: min(540px, 100%);
  max-height: 100%;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 16px;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
  transform: translateY(8px) scale(0.98);
  /* Was 0.18s cubic-bezier(0.19, 1, 0.22, 1). That curve is expo-out: at 180ms it is
     about 98% finished inside the first 60ms, so the easing was invisible and the panel
     read as a snap rather than as an arrival. The curve is not wrong, the pairing was:
     it earns its keep over 620ms on the flipper and nowhere else on this site. */
  transition: transform var(--motion-medium) var(--ease-enter);
}
.qr-modal.qr-open .qr-modal-panel { transform: none; }

.qr-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.qr-modal-title { font-size: 15px; font-weight: 650; margin: 0; }
.qr-modal-close {
  width: 32px;
  height: 32px;
  flex: none;
  padding: 0;
  font-size: 20px;
  line-height: 1;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--panel-2);
  color: var(--text);
  cursor: pointer;
}
.qr-modal-close:hover { border-color: var(--accent); color: var(--accent); }

.qr-modal-paper {
  display: flex;
  justify-content: center;
  background: var(--qr-paper);
  border-radius: 8px;
  padding: 12px;
  margin: 12px 0;
}
/* pixelated, not smooth: a scaled QR with interpolated edges is measurably harder for
   a phone camera to lock onto. */
.qr-modal-paper canvas { width: 100%; max-width: 390px; height: auto; image-rendering: pixelated; }

.qr-modal-addr {
  display: block;
  font-family: var(--font-mono);
  font-size: 11.5px;
  line-height: 1.5;
  word-break: break-all;
  color: var(--text);
  background: var(--bg);
  border-radius: 6px;
  padding: 8px;
  margin-bottom: 8px;
}

@media (prefers-reduced-motion: reduce) {
  /* The PANEL loses its travel: no translate, no scale, no transition to run them over.
     The SCRIM keeps its 150ms fade, which is a change from the `transition: none` this
     block used to carry for both. A full-viewport dark layer that appears between two
     frames is harsher than the 150ms it takes to arrive, and an opacity ramp is not a
     vestibular trigger: nothing in it moves across the visual field. */
  .qr-modal-panel { transition: none; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .lp-bar::after { animation: none; }
}

/* The light palette. Every token the dark one defines is restated here, including the
   semantic ones: --warn and --ok used to be left alone on the theory that they only ever
   appeared on a dark panel, and #ffc857 on a white card is 1.7:1. A tag, a chip, a warn
   heading and every 'warn' line in the status log were all unreadable in light mode. */
@media (prefers-color-scheme: light) {
  :root {
    --bg: #f6f8fa; --panel: #ffffff; --panel-2: #eef2f6; --line: #d3dbe3;
    --text: #101418; --muted: #55616d; --accent: #8a5410; --accent-dim: #e3c391; --live: #0b7a6b;
    --text-strong: #000000;
    --warn: #8a6100; --ok: #16704a; --danger: #b3261e;
    --line-warn: #d0a83a; --line-live: #7fbdb4; --line-danger: #e0a49c; --line-ok: #4aa863;
    --warn-wash: rgba(180, 130, 0, 0.10);
    --warn-wash-strong: rgba(180, 130, 0, 0.16);
    --accent-wash: rgba(138, 84, 16, 0.06);
    --on-accent: #ffffff;
    --accent-text: #7a490e;
    --scrim: rgba(246, 248, 250, 0.97);
    --scrim-veil: rgba(246, 248, 250, 0.90);
    --shadow-lift: 0 24px 60px -42px rgba(16, 20, 24, 0.35);
  }
}

/* ---------------------------------------------- post-sever transcript */

#transcript-holder { margin: 18px 0 4px; }
#transcript-holder h2 { color: var(--text); }
.messages.transcript {
  max-height: 50dvh;
  background: var(--panel-2);
  opacity: 0.95;
}
#clear-transcript { margin-top: 12px; }

/* ---------------------------------------------------------------------------
   Desktop layout for the connected screen.

   The conversation takes the space; the things you consult rather than read (the
   verification code, connection details, the burn control) sit in a rail beside it.
   The transcript fills the column height at every width, so there is ONE scrollbar
   instead of a small box inside a big page with dead space underneath it.
   --------------------------------------------------------------------------- */
/* The thread fills the column at EVERY width, not only on a desktop grid. It used to
   sit in a 46vh box with whatever was left over painted empty underneath: at 768x1024
   that was 301px of nothing below the composer. A flex column pinned to the viewport
   height, with the transcript as the only growing row, means the space goes to the
   conversation and there is exactly one scrollbar on the screen. */
#screen-connected {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

/* Fill the height by LAYOUT rather than by arithmetic. This used to be
   `min-height: calc(100vh - 132px - var(--log-h))`, and the 132 was a guess at the
   header plus main's padding: measured, it was 149 at 2560x1440, so the composer sat
   33px below the bottom of the window on the widest screen and the page grew a
   scrollbar it did not need. `.page` is already a viewport-tall flex column whose
   height excludes the status log, so making `main` a growing row of it and the screen
   a growing row of `main` gets the same result with no constant to be wrong.

   `flex: 1 1 auto` without `min-height: 0`: the screen fills spare space when there is
   any, and is still at least its own min-content tall when there is not, so a short
   window scrolls the page instead of squashing the composer under the footer. */
@supports selector(:has(*)) {
  body:has(> .page) > .page > main:has(> #screen-connected:not([hidden])) {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
  }
  main:has(> #screen-connected:not([hidden])) > #screen-connected { flex: 1 1 auto; }
}
/* ONE gap, at every width, and the container's `gap` is the only thing that sets it.
 *
 * This reset used to live inside `@media (max-width: 699px)`, so it fixed the phone and
 * left every wider screen carrying whatever margin each child had brought from a layout it
 * no longer has: `details.disc` 10 below, `details.disc.compact` 14 above, `button.danger`
 * 20 above. Those stack ON TOP of the container gap rather than replacing it, so the
 * distances between consecutive blocks measured 10 / 10 / 10 / 24 / 34 / 40px at 768x1024
 * and 14 / 28 / 38px at 1440x900. Six blocks, four different gaps, none of them chosen.
 * Zeroed rather than tuned, because a gap and a margin adding to a third number is how the
 * four appeared in the first place. */
#screen-connected > *,
#screen-connected > .conn-rail > * { margin-top: 0; margin-bottom: 0; }
#screen-connected > .route-line { margin: 0; flex: 0 0 auto; order: 0; }
/* The rail's three children are laid out as siblings of the transcript on a narrow
   screen, so the verification code sits above the conversation and the burn control
   sits below everything, which is the same reading order the rail has vertically. */
.conn-rail { display: contents; }
.conn-rail > .sas-box { flex: 0 0 auto; order: 1; }
#screen-connected > #messages { order: 2; }
#screen-connected > #chat-form { flex: 0 0 auto; order: 3; }
/* Connection details is a child of the screen now rather than of the rail, and keeps the
   slot it had on a phone: after the composer, before the burn control. */
.conn-rail > #conn-disc { flex: 0 0 auto; order: 4; }
/* Games had NO order at all, so it defaulted to 0 and joined the route line at the very
   top of the column: measured at 390x844 it painted at y 160..230, above the verification
   code, above the transcript and above the composer. Its own comment says it is "the least
   important thing on the screen and must not compete with the transcript for attention",
   which is the opposite of where an unset `order` put it. It sits with the other thing you
   consult rather than read now, immediately after Connection details. */
.conn-rail > #games-disc { flex: 0 0 auto; order: 5; }
/* The other half of that move. An invitation arriving at a shut drawer opens it
   (gameui.js announceInvite) and then scrolls the row carrying Play and No thanks into
   view, because at order 5 the drawer opens well down a scrolling column: measured at
   360x640 the row landed at y 694 in a 640px viewport, 54px below the fold, with the
   page still at scrollTop 0.
   `.bar` is `position: sticky; top: 0` inside `.page`, which is the scroller here, so a
   scroll that ignored it would park the row under the masthead and the answer would be
   just as unreachable as it was below the fold. --bar-h is the masthead's height and is
   defined in this file; the rule lives here rather than in games.css for that reason,
   since games.css is deliberately scoped to board colours and defines no shell token.
   The +12px is a breathing gap, not a measurement. */
#games-disc .game-status { scroll-margin-top: calc(var(--bar-h) + 12px); }
.conn-rail > #sever { flex: 0 0 auto; order: 6; }
#screen-connected > #messages {
  flex: 1 1 auto;
  /* min-height:0 is what lets a flex item actually shrink, so the inner overflow works
     instead of the item growing past its container. */
  min-height: 160px;
  max-height: none;
  margin-bottom: 0;
  overflow-y: auto;
}

/* THE TRANSCRIPT IS THE SCROLLER ABOVE 699px TOO, added 2026-08-10.
 *
 * `max-height: none` above is not a rounding error, it is the whole bug. `main` is a flex
 * item of `.page` (the scroller) with flex-basis auto, so it is sized by its CONTENT: the
 * transcript grew with every row that arrived and pushed the composer down a page that then
 * scrolled, and `overflow-y: auto` on #messages could never fire because the element never
 * ran out of room. The phone has capped this since the day the phone layout was written; the
 * desktop never did, and only looked right because the suite's transcript happened to end
 * near the fold.
 *
 * Measured at 1440x900 in a live gate, three messages and five received files: the composer's
 * bottom edge was at y=1122 against a 900px window with 491px of page scroll under it. What
 * exposed it was three lines of scan caution on each received file row, which moved a real
 * gate's composer from y=884 to y=956. 884 was never healthy either: it left 16px of margin,
 * so ANY future row would have done the same thing. This makes the composer's position a
 * function of the WINDOW rather than of how much has been said.
 *
 * The 400px is the rest of the column at this width, measured rather than guessed: the route
 * line and the gap above the transcript are 106px, the gap below it is 14px, and #chat-form
 * is 233px with the folded hint in it. Rounded up so the composer lands just inside the fold
 * (900 - 400 = 500px of transcript puts its bottom edge at y=853) and expressed against dvh
 * so it holds at every window height instead of only at the one the tests measure: at 700px
 * the transcript is 300px, at 1400px it is 1000px, and the composer sits 47px above the fold
 * in all three. dvh, not vh, for the same reason the phone block gives.
 *
 * The 240px floor is for a window short enough that the arithmetic goes negative. Below it
 * the composer does go under the fold, which is the honest outcome: a 500px-tall desktop
 * window cannot hold a transcript, a composer and a keyboard at once, and a floor of zero
 * would answer that by deleting the conversation.
 *
 * Two bands, because "the rest of the column" is not one number. Above 1024px the rail is
 * beside the transcript and takes the verification code with it; between 700 and 1023 there
 * is no rail, so the code box and Burn are IN the column and the same window has 120px less
 * to give. Measured at 820x900 with the 400px constant: the composer landed at y=941, still
 * past the fold. With 520 it lands at 821. */
@media (min-width: 700px) and (max-width: 1023.98px) {
  #screen-connected > #messages { max-height: max(240px, calc(100dvh - 520px)); }
}
@media (min-width: 1024px) {
  #screen-connected > #messages { max-height: max(240px, calc(100dvh - 400px)); }
}

/* A phone has no rail and no spare height. Nothing is removed, only tightened: the
   composer has to be fully on screen with the transcript above it. */
@media (max-width: 699.98px) {
  /* ONE gap, the home screen's gap.
   *
   * Measured at 390x844 in a connected gate before this block, top to bottom, the space
   * between consecutive blocks was 22 / 15 / 11 / 8 / 22 / 18px. The screen's own `gap`
   * was 8 the whole way down; everything else was a margin some child brought with it
   * from a layout it no longer has (`details.disc.compact` 14 above and 10 below,
   * `.messages` 10 below, `#sever` 10 above). Six different distances is what "the bottom
   * items are scattered" looks like measured, and none of the six was chosen.
   *
   * 14px, because that is the distance between the two cards and the steps on the home
   * screen at this width, and the standard the owner is comparing this screen against is
   * the front page. The margins are zeroed rather than tuned: a gap and a margin adding
   * up to a third number is how the six appeared in the first place. */
  #screen-connected { gap: 14px; }
  #screen-connected > #messages { min-height: 120px; }
  /* The transcript is BOUNDED on a phone, so the composer's position stops being a function
   * of what the other device sent.
   *
   * The base rule sets `max-height: none` on the transcript, which is right on a desktop
   * where the screen is a grid whose transcript row is `minmax(0, 1fr)` and therefore
   * bounded by the window. On a phone there is no such row: `main` and `#screen-connected`
   * are `flex: 1 1 auto` with no `min-height: 0`, so each is at least its own min-content
   * tall, and min-content includes every message bubble. Nothing bounded the transcript at
   * all, so it simply grew. Measured at 390x844 in a real gate, one batch of seven files
   * offered by the peer and not yet answered:
   *
   *   #messages   128px -> 443px      the batch row alone is 306px of it
   *   #chat-form  y 392..670 -> 707..985, against a scroller that ends at y=811
   *
   * So one peer pressing "send 7 files" took the composer off the screen of the person who
   * had pressed nothing. That is the same defect as the capability banner two screens up,
   * arriving over the network instead of out of the markup.
   *
   * A cap and not `min-height: 0`, which was tried first and is the wrong tool here. Letting
   * `main` shrink below its content does keep the composer on screen, but the content it no
   * longer has room for does not disappear: it overflows main's box and paints straight
   * through `.foot`, which is the next item in `.page`'s flex column. Photographed at
   * 390x844: "FAQ Terms Acceptable use Privacy Support GitHub" printed across the middle of
   * the Connection details card. A fix that trades a composer below the fold for a footer
   * on top of a card is not a fix.
   *
   * 25dvh is 211px here, which leaves the composer's bottom edge at y=774 against a fold of
   * 811 with the full seven-file row on screen, and the transcript scrolls its own content
   * as a transcript should. 28dvh was measured first and left only 11px of that margin: the
   * verification box above the transcript is 84px or 105px depending on whether its two
   * words wrap, and a headroom smaller than the things above it can move by is not headroom.
   * dvh, not vh: on a phone `vh` is the LARGE viewport, the one
   * measured with the address bar hidden, so a vh cap is quietly too tall on exactly the
   * devices this block exists for, and this file already measures the window in dvh
   * (`body:has(> .page) { height: 100dvh }`). The 160px floor of the clamp keeps a short
   * phone from capping the transcript below the 120px the line above already guarantees;
   * the 340px ceiling keeps a tall one from handing the whole window back to the problem. */
  #screen-connected > #messages { max-height: clamp(160px, 25dvh, 340px); }
  /* The one deliberate exception, and it is deliberate for the same reason the desktop
     rail pushes this control to its own foot with `margin-top: auto`: Burn is destructive
     and irreversible, and a destructive control sitting on the same rhythm as a routine
     one is a mis-tap waiting to happen. 14 + 10 = 24px, and it is the only number on this
     screen that is not 14. */
  #screen-connected > .conn-rail > #sever { margin-top: 10px; }
}

/* The same exception, for the band that has no rail either. Between 700 and 1023px the
   rail is still `display: contents` and Burn is a sibling of the drawers, but this rule was
   scoped to `max-width: 699px` and did not reach it, so measured at 768x1024 the gap above
   the destructive control was 0 and it sat flush against the Games drawer. */
@media (min-width: 700px) and (max-width: 1023.98px) {
  #screen-connected > .conn-rail > #sever { margin-top: 14px; }
}

/* A SHORT phone, which is a different problem from a narrow one.
 *
 * The block above was measured and tuned at 390x844 and it holds there: the composer's
 * bottom edge lands at y=754 against a fold of 811. At 360x640 the same rules put it at
 * y=740 against a fold of 607, so Send and Attach were 133px below the screen with the
 * page at rest and the person had to scroll to reach the button they came for. Nothing
 * about the width caused that; the window is 204px shorter and the layout spends a fixed
 * number of pixels above the composer regardless.
 *
 * Three things give the height back, cheapest first. The folded capability note is 98px
 * of a 278px composer and is the one block on the screen that is pure explanation, so it
 * goes: it is a fold nobody has opened yet, not information that was on screen. The
 * textarea drops to two lines, which is still a message box and not a search field. The
 * transcript's cap comes down with the window, since 25dvh of 640 is 160px and the floor
 * of the clamp above was already holding it there.
 *
 * 700px, not 640: at 700 the same arithmetic leaves about 40px of headroom, and headroom
 * smaller than the amount the verification box moves by when its two words wrap is not
 * headroom. Better to apply this one band early than to have it arrive one device late. */
@media (max-width: 699.98px) and (max-height: 700px) {
  #screen-connected > #chat-form .hint-fold { display: none; }
  #screen-connected > #chat-form textarea { min-height: 48px; }
  #screen-connected > #messages { max-height: clamp(110px, 20dvh, 200px); min-height: 110px; }
}

@media (min-width: 1024px) {
  main:has(> #screen-connected:not([hidden])) { padding-bottom: 12px; }

  #screen-connected {
    display: grid;
    grid-template-columns: minmax(0, 1fr) clamp(300px, 22vw, 400px);
    /* Three rows now: the route line, the transcript, the composer. The two disclosures
       used to be a fourth and a fifth here and have moved into the rail.
     *
     * The transcript row stays `minmax(0, 1fr)`, and that was tried the other way on
     * 2026-08-11 and reverted the same day. The audit's complaint was real as far as it
     * went: at 1920x1080 with two file rows and nothing else, the last row ended at y=375
     * and the box ended at y=531, so 156px of the panel was empty. Sizing the row to its
     * content with `minmax(0, auto)` plus `align-content: start` did remove that, and it
     * bought a much worse thing: the transcript then measured 160px at every width from
     * 1024 to 2560, and the dead space moved out of the panel and onto the page, growing
     * from 343px under the composer at 1024 to 983px at 2560. An empty panel that grows
     * with the window is what a chat transcript is supposed to look like; a gate that
     * stops halfway down a large screen with a thousand pixels of nothing beneath it is
     * not. tests/browser.test.mjs asserts both halves of this, and it caught the trade. */
    grid-template-rows: auto minmax(0, 1fr) auto;
    gap: 14px clamp(22px, 2.4vw, 46px);
    align-items: start;
  }

  #screen-connected > .route-line { grid-column: 1; grid-row: 1; }
  /* `align-items: start` above means a grid item does not stretch to its row, so the
     transcript takes the row 1fr gave it only by asking for it. Without this the box sits
     at its content height inside a track that is taller, which is the same dead strip by
     another route. */
  #screen-connected > #messages   { grid-column: 1; grid-row: 2; height: 100%; }
  #screen-connected > #chat-form  { grid-column: 1; grid-row: 3; }

  /* The rail. The verification code sits at the top because reading it aloud is the one
     security check this interface asks a person to make; the two disclosures follow,
     because they are the other things you consult rather than read; Burn is pushed to the
     foot by `margin-top: auto`, away from all three, because it is destructive and
     irreversible and a destructive control should never be next to a routine one.
     Row 2, not row 1: the route line is the left column's own header and the rail has no
     counterpart to it, so spanning row 1 started the rail 45px above the transcript and
     nothing in either column shared a top edge. Measured on the reported screenshot: the
     verification card's top was y=107 against the transcript's y=152. Ending at `-1`
     rather than a span count, so adding a row to the left column cannot silently leave the
     rail one row short of the bottom. */
  .conn-rail {
    display: flex;
    flex-direction: column;
    gap: 14px;
    grid-column: 2;
    grid-row: 2 / -1;
    height: 100%;
    min-height: 0;
  }
  .conn-rail > * { width: 100%; margin: 0; }
  .conn-rail > #sever { margin-top: auto; }

  /* The rail spans every row, so whatever is inside it sizes the LEFT column's tracks.
     Measured after the move, with a live battleships match open: two stacked 10x10 boards
     made the rail 1040px tall, the grid rows became 207 / 630 / 411, and the composer went
     from y583..818 to y935..1170 against a fold of 1047. That is the same defect the phone
     transcript cap exists to prevent, arriving from the other side of the screen: the peer
     accepts a game and the composer leaves the screen of the person who pressed nothing.
     Chess (370px), tic tac toe and the card shelf all fit with room to spare; battleships
     is the one board that does not, and capping the body rather than the rail is what keeps
     the verification code from scrolling away, since reading it is the one security check
     this interface asks for. The board scrolls inside its own drawer instead. */
  .conn-rail > #games-disc .disc-body {
    max-height: calc(100dvh - var(--bar-h) - 300px);
    overflow-y: auto;
  }

  /* More room means a received image can be worth looking at. */
  #screen-connected .msg img { max-height: 420px; }

  /* A wide composer that is one line tall reads as an afterthought. */
  #screen-connected #chat-input { min-height: 96px; }
}

/* Fallback for engines without :has(). They keep the flex column rather than getting a
   broken grid, which is the safe direction to fail in. */
@supports not selector(:has(*)) {
  @media (min-width: 1024px) {
    #screen-connected { display: flex; }
  }
}

/* While a gate is open the footer is navigation nobody is going to use, and every pixel
   it takes is a pixel the conversation does not get. It stays (the AGPL source link
   lives there) and it gets less room. */
body:has(#screen-connected:not([hidden])) .foot {
  margin-top: 0;
  padding-top: 10px;
  padding-bottom: 12px;
}

/* ---- the verification code -------------------------------------------------------
   Legible in tabular monospace, because reading it aloud is the whole check, and QUIET,
   because it is consulted once and then never again. It went from 15px grey text on a
   badge line to a 46px number in a filled accent panel, which overshot: at that size it
   carried the same visual weight as the conversation and read as the main event on a
   screen where the main event is the thread. Halved, on a plain border rather than a
   filled panel, with the digits and the words on one line. Still the largest thing in
   the rail, still nothing hidden. */
.sas-box {
  background: transparent;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 9px 12px 10px;
}
.sas-box .rail-k {
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0;
  /* Never the thing that wraps: if the rail gets narrow the digits and the words break
     to their own lines and the caption stays with the first of them. */
  flex: 0 0 auto;
}
/* Baseline alignment, not centre: the words are a reading of the digits, so they should
   sit on the same line the digits sit on. Wrapping is allowed because a narrow rail plus
   a long word pair would otherwise clip the thing a person is meant to read out. */
.sas-line {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 10px;
  margin: 0;
}
.sas-v { margin: 0; }
#sas {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: clamp(22px, 1.5vw, 26px);
  line-height: 1.15;
  letter-spacing: 0.13em;
  font-weight: 700;
  color: var(--text);
  display: block;
}
.sas-words {
  margin: 0;
  font-size: 13.5px;
  line-height: 1.3;
  color: var(--muted);
}
.sas-box .rail-note {
  margin: 7px 0 0;
  color: var(--muted);
  font-size: 12px;
  line-height: 1.45;
  max-width: var(--measure);
}
/* On a phone there is no rail: the code sits above the thread, where every pixel it
   takes is a pixel of conversation. The instruction stays: it is the one thing a person
   is asked to do, and hiding it to save 30px would be hiding the point. */
@media (max-width: 699.98px) {
  .sas-box { padding: 8px 11px 9px; }
  #sas { font-size: 22px; letter-spacing: 0.11em; }
  .sas-words { font-size: 13px; }
  .sas-box .rail-note { font-size: 11.5px; margin-top: 6px; }
}

/* ---------------------------------------------------------------------------
   Landing content on the home screen.

   The home screen carries the pitch as well as the controls, so it is the one
   screen that wants more than the 640px column the rest of the app is built
   around. Everything here is prefixed `lp-` because the app already owns
   `.card`, `.steps`, `.step`, `.code`, `.lede` and `.tag`, and a landing rule
   that reached those would repaint the extras strip and the connected screen.

   No new palette: the mockup's amber is dropped in favour of the app's own
   tokens, so light mode is inherited rather than re-implemented.
   --------------------------------------------------------------------------- */

.lp-eyebrow {
  font-family: var(--font-mono);
  font-size: 11.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0;
}

/* ---------------------------------------------- hero */

/* Phone order: headline, the two actions, then everything that supports them. The gate
   is what the page is for, so nothing optional is allowed above the controls. */
.lp-hero { display: flex; flex-direction: column; gap: 16px; }
.lp-hero-head { order: 1; }
.lp-actions { order: 2; }
.lp-hero-gate { order: 3; }
.lp-hero-tail { order: 4; }
.lp-hero-brow { order: 5; }

.lp-h1 {
  /* Tied to the VIEWPORT, and the cap is high enough that it is not reached until
     2000px. Before this the headline was 46px on a 1280px laptop and 46px on a 2560px
     display: the type did not know how big the screen was, which is most of what makes
     a desktop page look like a phone page that has been stretched. */
  font-size: clamp(25px, 3.2vw, 64px);
  line-height: 1.08;
  letter-spacing: -0.03em;
  font-weight: 800;
  margin: 6px 0 0;
  text-wrap: balance;
}

/* An inline-block baselines on its bottom edge, which lifts the whole box out of
   line with the word beside it. Centre the two on the cross axis instead. */
.lp-for-line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* The font-size is here, not only on the children, so this em resolves against the
     size of the words either side of the gap. Inherited at body size it worked out to
     about 5px next to a 27px word, which read as "ForFriends" once the flip face lost
     the padding that had been quietly widening it. */
  font-size: clamp(19px, 1.9vw, 38px);
  gap: 0 0.3em;
  margin-top: 14px;
}
.lp-for {
  font-size: clamp(19px, 1.9vw, 38px);
  font-weight: 700;
  letter-spacing: -0.02em;
}

/* The flipping box. A gate opens, shows one thing, closes: the same gesture.
   Width and height are set from landing.js, measured off the widest word so the
   headline does not jump on every flip. */
.lp-flipper {
  display: inline-block;
  /* Stated, not left to `perspective`. A non-none perspective does make this a containing
     block for the absolutely positioned box, but that is a side effect of a 3D property,
     and the layout should not quietly break if the tumble is ever swapped for a fade. */
  position: relative;
  perspective: 800px;
  vertical-align: baseline;
  /* No panel and no border. The box is sized to the LONGEST word so the headline does
     not jump, which meant a visible frame sat empty to the right of every short word.
     The clip stays: it is what hides the face rotating out, not decoration.

     clip-path, NOT overflow: hidden. An inline-block whose overflow is not visible has
     its baseline forced to the bottom margin edge, so the whole flipper sat a descender
     too high and the rotating word rode above the "For" beside it. clip-path clips the
     same rectangle and leaves the baseline alone. */
  clip-path: inset(0);
}
/* The strut, and the only thing in normal flow in here.
   Every face is absolutely positioned, and an inline-block with no in-flow line box also
   falls back to its bottom margin edge for a baseline. A zero-width space carrying the
   faces' own font size and the box's line height (set by landing.js) puts a real line box
   in there, so the flipper's baseline IS the word's baseline. */
.lp-flipper::before {
  content: "\200B";
  display: inline-block;
  width: 0;
  font-size: clamp(19px, 1.9vw, 38px);
  font-weight: 700;
  letter-spacing: -0.02em;
}
.lp-flip-box {
  /* Out of flow, so it does not get pushed onto a second line by the strut above. */
  position: absolute;
  left: 0;
  top: 0;
  display: block;
  transform-style: preserve-3d;
  /* EXEMPT from the motion tokens, deliberately, and the only exemption in the file.
     This is a 90 degree tumble of a large hero word: the one expressive gesture on the
     site, and the one place where an expo-out curve is describing something real, a
     physical object with mass overshooting into its stop. 620ms is long enough for that
     curve to be legible, which is exactly what --motion-medium at 250ms would not be.
     Every other easeOutExpo on the site has been retired for that reason.
     Perpetual, so it is answerable to SC 2.2.2: #motion-toggle stops it. */
  transition: transform 0.62s cubic-bezier(0.19, 1, 0.22, 1);
  /* The ONLY will-change in this stylesheet. One element, one layer, held for the life of
     the page because it really does transform every 2.4 seconds. MDN: "It should not be
     used to anticipate performance problems." Nothing else here gets one, and in
     particular not .lp-section (5+ per page) or .msg (up to MAX_MESSAGES). */
  will-change: transform;
}
/* One line of text centred by its own line-height. Flex centring inside a box
   that was itself mis-measured only hid the sizing bug; this way the height IS
   the line box. */
.lp-flip-face {
  position: absolute;
  left: 0;
  top: 0;
  display: block;
  backface-visibility: hidden;
  color: var(--accent);
  white-space: nowrap;
  /* No inset now that there is no frame to sit inside: the 0.14em used to hold the word
     off the border, and with the border gone it read as a second gap after "For", on top
     of the 0.32em the line already has. landing.js measures with the same padding, so
     these two have to change together or the box is measured wider than the word. */
  padding: 0;
  /* Left, not centre. The box is as wide as the LONGEST word so the headline does not
     jump on every flip, so centring pushed the short words away from "For" and read as
     a gap: "For        Friends". Aligning left keeps every word starting at the same
     place, hard against the word it completes, and lets the spare width fall where
     nothing is looking. */
  text-align: left;
  font-size: clamp(19px, 1.9vw, 38px);
  font-weight: 700;
  letter-spacing: -0.02em;
}

.lp-sub {
  color: var(--muted);
  font-size: 16.5px;
  max-width: 48ch;
  margin: 0;
}

.lp-assurances {
  margin: 16px 0 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: 6px 18px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--muted);
}
.lp-assurances li::before { content: "- "; color: var(--accent); }

/* ---- the two actions --------------------------------------------------------------
   Equal in function, unequal in weight. They used to be a pair of flat grey cards a
   screen further down, under a small "Start here" label, on the page whose entire
   purpose they are. Only the lead carries the accent line: an accent on both would
   say the same thing twice and therefore say nothing. */
.lp-actions {
  display: grid;
  grid-template-columns: 1fr;
  gap: 14px;
  margin-top: 8px;
}
@media (min-width: 700px) {
  .lp-actions { grid-template-columns: 1fr 1fr; align-items: start; }
}
@media (min-width: 900px) {
  .lp-actions { margin-top: clamp(28px, 3vw, 52px); }
}
.lp-action {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: clamp(14px, 1.2vw, 22px);
  display: flex;
  flex-direction: column;
}
.lp-action-lead { border-color: var(--accent-dim); }
.lp-action h2 {
  font-size: clamp(17px, 1.15vw, 22px);
  letter-spacing: -0.01em;
  font-weight: 650;
  margin: 0;
}
.lp-action > p { margin: 6px 0 12px; line-height: 1.45; }
.lp-action label { margin-bottom: 4px; }
.lp-action select,
.lp-action input { margin-bottom: 8px; }
/* Push the button to the bottom so the two cards' actions line up whatever the copy
   above them costs. */
.lp-action > button { margin-top: auto; }

/* The landing's two calls to action are ANCHORS, not buttons: the form they lead to
   lives in the other document now, and something that navigates should be a link so
   middle-click, "open in new tab" and the status bar preview all work. They borrow
   the button skin; they are not buttons. */
.lp-action > a.primary,
.lp-action > a.secondary {
  display: block;
  margin-top: auto;
  padding: 11px 16px;
  border: 1px solid transparent;
  border-radius: var(--radius);
  font-weight: 600;
  text-align: center;
  text-decoration: none;
}
.lp-action > a.primary { background: var(--accent); color: var(--on-accent); }
.lp-action > a.secondary { background: var(--panel-2); color: var(--text); border-color: var(--line); }
.lp-actions #home-error { grid-column: 1 / -1; display: block; margin: 0; }

/* Expiry and room password: one line of plain text saying what will happen, and a
   change link. app.js writes the line from the live values of #ttl-select and
   #room-password, so the sentence cannot drift from what Create will actually do. */
.gate-opts { margin: 0 0 12px; }
.gate-opts-line {
  margin: 0;
  font-size: 13.5px;
  color: var(--muted);
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 10px;
}
#gate-opts-toggle { font-size: 13.5px; }
.gate-opts-panel { padding-top: 10px; }
.gate-opts-panel > *:last-child { margin-bottom: 0; }

/* Phone. The whole point of this block is that "Create gate" and "Join gate" are
   both inside the first 844px of a 390px-wide viewport, measured, not assumed. It
   buys that back from spacing rather than from content: nothing here is hidden,
   only tightened. */
@media (max-width: 899.98px) {
  main:has(> #screen-home:not([hidden])) { padding-top: 12px; }
  .lp-hero { gap: 12px; }
  .lp-h1 { font-size: clamp(24px, 5.2vw, 52px); margin-top: 2px; }
  .lp-for-line { margin-top: 8px; }
  .lp-action { padding: 12px; }
  .lp-action > p { margin-bottom: 9px; }
  .lp-action > button { padding: 10px 16px; }
}

@media (min-width: 900px) {
  .lp-hero {
    display: grid;
    grid-template-columns: minmax(0, 1.12fr) minmax(0, 0.88fr);
    grid-template-areas:
      "brow gate"
      "head gate"
      "tail gate"
      "acts acts";
    /* The gate column spans the first three rows and is the taller of the two, so its
       overflow has to land somewhere. Without this it is shared out between the
       rows and opens a hole under the headline; `1fr` on the third row sends all of
       it below the supporting copy instead. */
    grid-template-rows: auto auto 1fr auto;
    column-gap: clamp(36px, 4vw, 88px);
    row-gap: 0;
    align-items: start;
    margin-top: 12px;
  }
  .lp-hero-brow { grid-area: brow; }
  .lp-hero-head { grid-area: head; }
  .lp-hero-tail { grid-area: tail; margin-top: 26px; }
  /* Centred in its three-row span. Left as `start` it sat at the top and left a
     300px hole under itself, because the preview is shorter than the copy beside it. */
  .lp-hero-gate { grid-area: gate; align-self: center; }
  .lp-actions { grid-area: acts; }
  .lp-sub { font-size: clamp(16.5px, 0.95vw, 21px); }
}

/* ---------------------------------------------- landing sections */

.lp-section {
  padding: 44px 0 0;
  border-top: 1px solid var(--line);
  margin-top: 40px;
}
.lp-section + .lp-section { margin-top: 44px; }

/* The suggestion box and the pledge share one row. The box is a short form and the
   pledge is a single paragraph, so at desktop widths each was a wide band with the
   right half of the page empty beside it. */
.lp-pair {
  display: grid;
  gap: 40px;
  align-items: start;
  border-top: 1px solid var(--line);
  margin-top: 40px;
  padding: 44px 0 0;
}
.lp-pair > .lp-section { border-top: 0; margin-top: 0; padding-top: 0; }

@media (min-width: 900px) {
  .lp-pair { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 0 44px; }
  /* A deployment that does not accept suggestions ships the box [hidden] (landing.js
     clears it only when /api/config says so), leaving the pledge alone in the row. It
     has to read as a normal full-width section then, not a half column with dead
     space beside it. */
  .lp-pair:has(> #suggest-section[hidden]) { grid-template-columns: minmax(0, 1fr); }
}

.lp-h2 {
  font-size: clamp(21px, 2.05vw, 40px);
  line-height: 1.2;
  letter-spacing: -0.02em;
  font-weight: 700;
  margin: 10px 0 0;
  text-wrap: balance;
}
.lp-lede { color: var(--muted); max-width: 62ch; margin: 12px 0 0; font-size: 15px; }

/* Numbered because it genuinely is a sequence. */
.lp-steps {
  display: grid;
  gap: 24px;
  margin-top: 30px;
  grid-template-columns: 1fr;
  counter-reset: lpstep;
}
@media (min-width: 760px) { .lp-steps { grid-template-columns: repeat(3, 1fr); } }
.lp-step { border-top: 2px solid var(--line); padding-top: 14px; counter-increment: lpstep; }
.lp-step::before {
  content: counter(lpstep, decimal-leading-zero);
  font-family: var(--font-mono);
  font-size: 11.5px;
  color: var(--accent);
  letter-spacing: 0.1em;
}
.lp-step h3 { margin: 7px 0 5px; font-size: 16px; letter-spacing: -0.01em; font-weight: 650; }
.lp-step p { margin: 0; color: var(--muted); font-size: 14.5px; }

/* ---------------------------------------------- funding */

.lp-slots { display: grid; gap: 16px; margin-top: 28px; grid-template-columns: 1fr; }
@media (min-width: 820px) { .lp-slots { grid-template-columns: 1fr 1fr; } }
.lp-slot {
  border: 1px dashed var(--line);
  border-radius: var(--radius);
  padding: 16px 18px;
  background: var(--panel);
}
.lp-slot-free { border-style: solid; border-color: var(--accent-dim); }
.lp-slot-label {
  font-family: var(--font-mono);
  font-size: 10.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}
.lp-list { margin: 12px 0 0; padding-left: 18px; color: var(--muted); font-size: 14.5px; }
.lp-list li { margin-bottom: 7px; }
.lp-list li:last-child { margin-bottom: 0; }
.lp-list strong { color: var(--text); }

/* ---------------------------------------------- roadmap */

.lp-road { display: grid; gap: 12px; margin-top: 28px; grid-template-columns: 1fr; }
@media (min-width: 800px) { .lp-road { grid-template-columns: 1fr 1fr; } }
.lp-card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 15px 17px;
}
.lp-card h3 { margin: 8px 0 5px; font-size: 15.5px; letter-spacing: -0.01em; font-weight: 650; }
.lp-card p { margin: 0; color: var(--muted); font-size: 14.5px; }
.lp-tag {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  padding: 2px 8px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  color: var(--muted);
}
.lp-tag-live { color: var(--ok); border-color: var(--line-ok); }

/* ---------------------------------------------- pledge */

.lp-pledge blockquote {
  margin: 22px 0 0;
  padding: 18px 20px;
  border-left: 3px solid var(--accent);
  border-radius: 0 var(--radius) var(--radius) 0;
  background: var(--panel);
  font-size: 16.5px;
  max-width: 62ch;
}


@media (prefers-reduced-motion: reduce) {
  /* The tumble used to degrade to a 300ms crossfade, which still changed the word every
     2.4 seconds forever: a reduced-motion user got a perpetual loop with the amplitude
     turned down, not a stop. One word, pinned, no timer. landing.js cancels the interval
     for the same reason and #lp-preview has always done exactly this. */
  .lp-flip-box { transition: none; }
  /* The hero bar reaches its final scaleX in one frame rather than growing into it.
     A bar filling up IS motion, and it is the only translate/scale on this page that is
     not covered by --reveal-rise or --press-scale. */
  .lp-preview-bar > i { transition: none; }
}

/* The three light-mode patches that used to live here are gone: --warn, --ok and
   --accent-text are tokens now, so .lp-tag-live and .lp-slot-label pick up
   legible light values from the palette instead of from one-off rules that had to be
   remembered every time a warn or ok colour was used somewhere new. The teal that used
   to sit on .lp-slot-label went with them: teal means live, it is not a second accent. */
.lp-slot-free .lp-slot-label { color: var(--accent-text); }

/* Engines without :has() never widen `main`, so the hero would be a two column grid
   squeezed into a 640px column. They keep the phone layout at every width instead,
   which is narrow but correct: the same direction the connected screen fails in. */
@supports not selector(:has(*)) {
  @media (min-width: 900px) {
    .lp-hero {
      display: flex;
      flex-direction: column;
      gap: 16px;
    }
    .lp-hero-tail { margin-top: 0; }
  }
}

/* ------------------------------------------- hero gate preview and CTAs

   The hero's right column is a picture of a gate mid-transfer, matching the approved
   direction. It is decorative: nothing here is interactive, and the whole block carries
   a single aria-label so assistive tech gets one description instead of a pile of
   meaningless numbers. The working controls live in #gate-controls below. */

.lp-preview {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 18px;
  box-shadow: var(--shadow-lift);
}
.lp-preview-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 14px;
}
.lp-preview-label {
  font-family: var(--font-mono);
  font-size: 11.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
}
.lp-preview-code {
  font-family: var(--font-mono);
  font-size: clamp(14px, 3.2vw, 19px);
  letter-spacing: 0.06em;
  color: var(--text);
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 13px 14px;
  /* A long code must never widen the page. It used to scroll sideways inside its own box,
     which was fine for 24 characters and hides most of a 50-character word code behind an
     edge nobody thinks to drag. Wrapping on the hyphens shows the whole thing instead. */
  overflow-x: auto;
  white-space: normal;
  word-break: normal;
  overflow-wrap: anywhere;
}
.lp-preview-meter { margin-top: 14px; }
.lp-preview-row {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.lp-preview-bar {
  height: 4px;
  background: var(--panel-2);
  border-radius: var(--radius-pill);
  margin: 7px 0 12px;
  overflow: hidden;
}
/* Scaled, not sized. This used to be `width: 62%` while landing.js took the width back to
   100% and drove the bar by transform: scaleX, so the stylesheet and the script disagreed
   about which property owned the fill and the script's first act was to undo this rule.
   One mechanism now: the CSS states the same frame the markup describes, and the script
   moves the number it was already moving. A width animation relayouts everything under
   the bar on every tick; a scaleX is a composite. */
.lp-preview-bar > i {
  display: block;
  height: 100%;
  width: 100%;
  transform-origin: left center;
  transform: scaleX(0.62);
  /* linear, because a progress bar is reporting a rate. An eased bar claims the transfer
     sped up and slowed down when only the easing did. */
  transition: transform var(--motion-medium) linear;
  background: var(--accent);
}

/* ---- the conversation inside the preview ----------------------------------------

   landing.js runs this panel as a loop: connecting, paired, three messages, a file
   crossing, then a fresh gate. Every message is in the flow from the first paint and
   the script only moves its opacity, which is what keeps the panel one fixed height
   while a conversation arrives inside it. Nothing here sets a height, and nothing
   here should: the height is the sum of three messages that are always present. */

.lp-preview-chat {
  margin-top: 14px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* The script parks the typing indicator over the slot of the message that is about
     to land, so this has to be the containing block for it. The script sets this
     property too, for the same reason it takes over the width of the bar fill: a
     decorative loop must not be able to break because a stylesheet rule moved. */
  position: relative;
}

/* Prose, not data, so these keep the sans stack. The mono in this panel means "this
   is a value you might read out": the gate code, the byte counts, the timer. */
.lp-msg {
  margin: 0;
  max-width: 82%;
  padding: 7px 11px;
  border-radius: 12px;
  font-size: 13px;
  line-height: 1.35;
  color: var(--text);
  background: var(--panel-2);
  border: 1px solid var(--line);
}
.lp-msg-them {
  align-self: flex-start;
  border-bottom-left-radius: 4px;
}
.lp-msg-you {
  align-self: flex-end;
  border-bottom-right-radius: 4px;
  background: var(--accent-wash);
  border-color: var(--accent-dim);
}

/* What stands where the conversation will be, before anybody has joined. The script
   centres it over the block and fades it out the moment the gate is paired, so it is
   never on screen at the same time as a message. Muted, and quieter than a message:
   it is a state, not something either device said. */
.lp-msg-wait {
  margin: 0;
  text-align: center;
  font-size: 13px;
  color: var(--muted);
}

/* The typing indicator is `hidden` in the markup and stays hidden for good if the
   script never runs: three dots that never resolve are worse than no dots. The global
   `[hidden] { display: none !important; }` at the top of this file is what keeps it
   hidden; no author rule here can beat that. */
.lp-msg-typing { padding: 9px 11px; }

.lp-dot {
  display: inline-block;
  width: 4px;
  height: 4px;
  margin-right: 4px;
  border-radius: 50%;
  background: var(--muted);
  /* Driven from landing.js, one write per dot per step. No keyframes: the dots are
     part of the same script as the messages they precede, and two clocks running the
     one indicator is how a typing dot ends up still bouncing after the message has
     landed. */
  opacity: 0.3;
}
.lp-dot:last-child { margin-right: 0; }

/* Sponsor slot, landing document only.
   A reserved box with a fixed minimum height, so a creative that arrives after the
   page has painted cannot reflow the section under a reader's cursor. Hidden outright
   when nothing is configured: an empty deployment gets no grey rectangle standing
   where an ad would be. The [hidden] rule has to restate display, because the base
   rule sets flex and that would otherwise beat the UA sheet. */
.ad-slot {
  width: min(100%, 728px);
  margin: clamp(28px, 4vw, 56px) auto 0;
  min-height: 90px;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--panel);
}
.ad-slot[hidden] { display: none; }
.ad-slot > * { max-width: 100%; }

/* Landing on this section from the CTA should not tuck its heading under the sticky
   header, and a focused Create button should not sit hard against the viewport edge. */
#gate-controls,
#gate-links { scroll-margin-top: 72px; }

/* ===========================================================================
   THE DESIGN SYSTEM
   ===========================================================================

   The landing hero was the only part of the product designed rather than
   assembled, so it is now the language for all of it. Four moves, applied
   everywhere:

     1. TYPE IS THE STRUCTURE. A section opens with a monospace uppercase
        eyebrow, then a heavy display heading with negative tracking, then a
        muted lede at a comfortable measure. `.eyebrow` / `.wg-h1` / `.wg-h2` /
        `.lede`. `.lp-*` are the hero's originals and share the same values.
     2. AMBER IS THE ACCENT. Teal means "connected" and nothing else. Warn, ok
        and danger stay semantic and are never used decoratively.
     3. DATA IS MONOSPACE with tabular figures: gate codes, byte counts,
        timers, verification codes, ports, badges. Prose never is.
     4. CARDS ARE PANEL + ONE PIXEL OF LINE + THE SHARED RADIUS, and space is
        the main tool. Only the hero preview carries a shadow.
   =========================================================================== */

.eyebrow,
.lp-eyebrow {
  font-family: var(--font-mono);
  font-size: 11.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0;
}

/* Display headings. The hero's own .lp-h1 is capped at 46px because it shares a
   row with the gate preview; a screen headline runs longer, so it caps lower and
   stays on two lines instead of four. */
.wg-h1 {
  /* Capped in em, not ch: a `ch` is a character of the BODY font, so a 22ch cap on a
     38px headline is 350px and every word lands on its own line. */
  max-width: 17em;
  font-size: clamp(23px, 2.7vw, 54px);
  line-height: 1.1;
  letter-spacing: -0.03em;
  font-weight: 800;
  margin: 10px 0 0;
  text-wrap: balance;
}
.wg-h3 {
  font-size: 16.5px;
  letter-spacing: -0.01em;
  font-weight: 650;
  margin: 6px 0 0;
}

/* One lede rule for the whole product. It used to carry a negative top margin to
   pull itself under a 20px h1; the eyebrow/headline/lede triad sets its own
   rhythm, so it does not need to fight the block above it any more. */
.lede {
  color: var(--muted);
  font-size: 16px;
  max-width: 60ch;
  margin: 12px 0 0;
}

/* Prose never runs wider than a comfortable measure, however wide the screen. */
.disc-body p,
.disc-body ul,
.banner .muted { max-width: var(--measure); }
/* A policy page caps at its own measure. Below 1100px --measure-legal IS --measure, so
   this is the same rule it has always been everywhere except a desktop. */
main.legal p,
main.legal li { max-width: var(--measure-legal); }

/* Data, everywhere it appears. */
.badge,
.step-n,
.lp-slot-label,
.lp-preview-label {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}
.badge {
  text-transform: uppercase;
  letter-spacing: 0.09em;
  font-size: 11px;
  font-weight: 600;
}
#sas,
.ttl,
.code,
.coin-addr,
pre.diag,
.file-title,
.file-status,
.lp-preview-row,
.secret-value { font-variant-numeric: tabular-nums; }
/* A transfer status that is a warning. This used to be written as an inline
   line.style.color on every rowStatus() call, which on a data channel firing many times a
   second is one style invalidation per chunk, on a row inside a scrolling container. The
   colour is the same token; only the mechanism changed. `.warn` on its own is styled
   inside .log and nowhere else, so this needs its own selector rather than inheriting one. */
.file-status.warn { color: var(--warn); }
/* The session timer counts down in place; a proportional font makes it jitter. */
.ttl { font-family: var(--font-mono); }
progress { accent-color: var(--accent); }

/* A short screen (a prompt, a result, a waiting state) is a single column of
   content, not a full-width layout. Centred so it does not hang off the left of a
   wide window, and capped at a measure that stays readable. */
.pane { max-width: min(100%, 640px); margin: 0 auto; }
.pane > .eyebrow { margin-bottom: 2px; }
.pane .wg-h1 { margin-bottom: 4px; }
.pane > .lede { margin-bottom: 22px; }

/* Screens open with the triad, so the old bare-h1 top margin would double it up. */
.screen > .pane,
.ob-grid { padding-top: 6px; }

/* ---------------------------------------------------------------------------
   ONBOARDING

   Measured before: at 1900x1000 `main` was 640px with 630px of dead margin on
   each side, five collapsed cards filled the viewport, and the whole consent gate
   was a phone column on a widescreen.

   Now: the headline and the disclosures take the main column, the agreement is a
   sticky rail beside them, and the disclosures ship OPEN and split into two
   columns at 1440px. More of the disclosure is visible on a wide screen than
   before, not less, and the primary action is above the fold without collapsing
   anything to get it there.
   --------------------------------------------------------------------------- */

.ob-head { max-width: 46ch; }
.ob-notes-brow { margin: 26px 0 10px; }
.ob-agree { margin-top: 20px; }

/* The agreement is the one card on the page that is an ACTION, so it is the one
   card on the page at all. Everything else here is type and rules. */
#screen-onboarding .agreement {
  margin-top: 0;
  border-color: var(--accent-dim);
  padding: 16px 16px 14px;
}
#screen-onboarding .agreement .eyebrow { margin-bottom: 12px; }
#screen-onboarding .agreement .check span { font-size: 13.5px; }
#screen-onboarding .agreement .muted { text-align: left; }

/* ---- the five disclosures, as a ruled list ----------------------------------------
   They were five identical cards, which put the one that actually matters (everyone in
   a gate sees everyone else's IP address) at exactly the same volume as the rest, and
   which cost this screen five of its eight panels. They are the same five <details>
   with the same five bodies of text: what is gone is the panel around each one.

   Still <details>, because a phone cannot show five open disclosures without burying
   the agreement, and app.js closes them below 1024px. Above that they are all open and
   the list reads as a numbered list, which is what it is. */
.ob-notes { counter-reset: wgdisc; }
#screen-onboarding .ob-notes details.disc {
  background: transparent;
  border: 0;
  border-top: 1px solid var(--hairline);
  border-radius: 0;
  margin: 0;
  overflow: visible;
  counter-increment: wgdisc;
}
#screen-onboarding .ob-notes details.disc:last-of-type { border-bottom: 1px solid var(--hairline); }

#screen-onboarding .ob-notes details.disc > summary {
  display: grid;
  grid-template-columns: 2.4em minmax(0, 1fr);
  column-gap: 14px;
  padding: 15px 30px 14px 0;
}
/* The mark. Numbered, except for the severe one, which breaks the sequence with "!"
   so it can be found without being read. The counter still increments through it, so
   the list stays 01 02 ! 04 05 and nothing looks lost. */
#screen-onboarding .ob-notes details.disc > summary::before {
  content: counter(wgdisc, decimal-leading-zero);
  grid-column: 1;
  grid-row: 1 / span 2;
  align-self: start;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 12px;
  letter-spacing: 0.1em;
  line-height: 1.9;
  color: var(--muted);
}
#screen-onboarding .ob-notes details.disc.warn > summary::before {
  content: "!";
  font-size: 17px;
  font-weight: 700;
  line-height: 1.35;
  color: var(--danger);
}
#screen-onboarding .ob-notes details.disc.warn .disc-title { color: var(--danger); }
#screen-onboarding .ob-notes .disc-title { grid-column: 2; grid-row: 1; }
#screen-onboarding .ob-notes .disc-hint { grid-column: 2; grid-row: 2; }
/* The chevron belongs to the row, not to a card edge. */
#screen-onboarding .ob-notes details.disc > summary::after { right: 4px; top: 21px; }
#screen-onboarding .ob-notes details.disc[open] > summary::after { top: 24px; }
#screen-onboarding .disc-title { font-size: 15.5px; letter-spacing: -0.01em; }
#screen-onboarding .ob-notes .disc-body {
  border-top: 0;
  padding: 0 30px 16px calc(2.4em + 14px);
}
#screen-onboarding .ob-notes .disc-body > *:first-child { margin-top: 0; }
.ob-assurances { margin-top: 20px; }

/* The close of the consent screen: the diagram, then the agreement, both centred in
   their own segment under the ruled list.

   There is no rail. The agreement was a sticky second column, which put the button above
   the fold but left a column of dead page beneath it for the whole height of the
   disclosures, and let you agree while the reading was still off to one side. The list
   now takes the full width it was already designed for at 1360px, and the agreement is
   the last thing on the screen, which is the order the words are actually in. */
.ob-close {
  border-top: 1px solid var(--line);
  margin-top: 28px;
  padding-top: 26px;
}
.ob-close .bridge { margin: 0 0 22px; }
.ob-agree {
  /* Not the full shell. An agreement is a paragraph you have to read, so it keeps a
     measure like any other paragraph, and centres in the space instead of stretching
     to 2160px on a wide monitor. */
  max-width: 62ch;
  margin: 0 auto;
}

@media (min-width: 1024px) {
  .ob-head { max-width: none; }
  .ob-head .lede { max-width: 52ch; }
  .ob-notes-brow { margin-top: 30px; }
}

/* Wide enough for the row to become a definition: the title on the left, the prose it
   introduces beside it. That is what the extra width is FOR. Below this the two stack,
   which is the same list with the same words in the same order. Only [open] rows get
   the grid: a closed <details> has nothing in its second column. */
@media (min-width: 1360px) {
  #screen-onboarding .ob-notes details.disc[open] {
    display: grid;
    grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr);
    column-gap: clamp(24px, 2.6vw, 56px);
    align-items: start;
  }
  #screen-onboarding .ob-notes details.disc[open] > summary {
    grid-column: 1;
    grid-row: 1;
    /* The chevron is absolutely positioned against its nearest positioned ancestor. While
       that was the summary it resolved `right: 4px` against the RIGHT EDGE OF THE LEFT
       COLUMN, which put it in the middle of the row, touching neither the title it belongs
       to nor the body it reveals. Moving the containing block to the whole row puts it at
       the row's right edge, which is where a disclosure marker is looked for. */
    position: static;
    /* The 30px reserve on the one-column layout held that gap open. In two columns the
       chevron is no longer inside this box, so the reserve is just a hole. */
    padding-right: 0;
  }
  #screen-onboarding .ob-notes details.disc[open] { position: relative; }
  #screen-onboarding .ob-notes details.disc[open] > .disc-body {
    grid-column: 2;
    grid-row: 1;
    /* Right reserve, because the chevron now hangs at the row's right edge and this is
       the box under it. The prose is capped at --measure and would not reach it today,
       but that is a coincidence of two independent numbers, not a guarantee. */
    padding: 15px 30px 16px 0;
  }
}

/* ---------------------------------------------------------------------------
   WAITING, PASSWORD, SEVERED, FAILED

   Same triad, same cards. The waiting screen gets the QR and the code side by
   side once there is room, because a 320px QR above a code block wastes half the
   width of a laptop and pushes Cancel off the bottom.
   --------------------------------------------------------------------------- */

#screen-waiting .qr-wrap { padding: 16px; }
#screen-waiting .code-block { border-color: var(--accent-dim); }
#screen-severed .messages.transcript { max-height: 40dvh; }
#transcript-holder .eyebrow { margin-top: 26px; }

@media (min-width: 900px) {
  #screen-waiting .pane { max-width: min(100%, 860px); }
  #share-shown {
    display: grid;
    grid-template-columns: 300px minmax(0, 1fr);
    column-gap: 26px;
    align-items: start;
  }
  #share-shown > .qr-wrap { grid-column: 1; grid-row: 1 / span 3; margin-bottom: 0; }
  #share-shown > .code-block,
  #share-shown > .row,
  #share-shown > .muted { grid-column: 2; }

  #screen-severed .pane { max-width: min(100%, 820px); }
}

/* ---------------------------------------------------------------------------
   CONNECTED

   Finish only. The two column layout, the transcript filling the column height
   and the burn control never being buried are all deliberate and are left alone.
   --------------------------------------------------------------------------- */

#screen-connected .route-line { padding-bottom: 2px; }
#screen-connected #messages { background: var(--panel); }
.msg .who { letter-spacing: 0.01em; font-weight: 600; }
.file-title { letter-spacing: -0.005em; }
.chip { font-family: var(--font-mono); }

/* ---------------------------------------------------------------------------
   THE LEGAL PAGES

   Same language: eyebrow, display heading, lede, hairline-ruled sections, prose
   at a measure. Presentation only. Not one word of any obligation, disclosure or
   policy changed, because those were audited claim by claim against the code.
   --------------------------------------------------------------------------- */

/* The column is the MEASURE plus its padding, not a round number that happens to be
   bigger than it. At 1000px the container was centred but the prose inside it was capped
   at --measure and left-aligned, so the text ended 450px short of the container's right
   edge: the page read as shoved to the left, and every h2 rule was nearly twice the width
   of the text it ruled off. Sizing the container to the thing it contains makes the rules,
   the headings and the prose all one width, and centres the assembly for real. */
main.legal {
  width: min(var(--shell), calc(var(--measure-legal) + 2 * var(--legal-pad)));
  max-width: 100%;
  padding: 26px var(--legal-pad) 40px;
}
/* With a table of contents the container has to hold the rail and the gap as well, or
   the rail is carved out of the reading column and the prose comes out NARROWER on the
   wider screen. Written as one expression whose extra terms are 0 below the breakpoint,
   so there is no second width to keep in step with the first.

   `.has-toc` is set by legal.js, and only after it has actually built the nav. That is
   the whole no-JavaScript story: without the script the class never lands, the container
   stays the width of the prose plus its padding, and the page is the same centred
   document it has always been rather than a column shoved to the left of a 316px hole
   where a rail was supposed to be. */
main.legal.has-toc {
  width: min(var(--shell), calc(var(--measure-legal) + var(--legal-rail) + var(--legal-gap)
             + 2 * var(--legal-pad)));
}
/* These are `main.legal .x` rather than `main.legal > .x` because the document is wrapped
   in .legal-body now, so that the prose and the rail can be two grid items. */
main.legal .eyebrow { margin-bottom: 2px; }
main.legal h1 {
  font-size: clamp(26px, 2.6vw, 50px);
  line-height: 1.08;
  letter-spacing: -0.03em;
  font-weight: 800;
  margin: 8px 0 0;
  text-wrap: balance;
}
/* The one-line intro under a policy title reads as a lede, not as fine print. */
main.legal .doc-meta {
  color: var(--muted);
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  margin: 14px 0 0;
  max-width: var(--measure-legal);
}
/* A hairline above every section is the same rule the landing sections use, and
   it is what turns a wall of policy into something you can scan. */
main.legal h2 {
  font-size: clamp(18px, 1.55vw, 30px);
  line-height: 1.25;
  letter-spacing: -0.02em;
  font-weight: 700;
  color: var(--text);
  border-top: 1px solid var(--line);
  margin: 42px 0 0;
  padding-top: 24px;
  /* Every h2 carries an id now, so a fragment link is a real way to arrive at one. The
     masthead is sticky at top:0, and without this the heading you asked for lands
     underneath it and the page looks like it jumped to the wrong place. */
  scroll-margin-top: calc(var(--bar-h) + 16px);
}
main.legal h2 + p { margin-top: 12px; }
main.legal p, main.legal li { font-size: 15px; line-height: 1.7; }
main.legal strong { color: var(--text); }
main.legal code {
  font-family: var(--font-mono);
  font-size: 13.5px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 1px 5px;
  /* An inline code span is a URL, an env var name or a CSP directive, none of which
     have spaces to break at. Every other code-ish rule in this file already carries a
     break rule; this one did not, and was one longer token away from a page that
     scrolls sideways on a phone. */
  overflow-wrap: anywhere;
}
main.legal pre.diag {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px 16px;
  margin: 16px 0 0;
  max-width: var(--measure-legal);
}
/* The link row at the foot of a policy page is navigation, so it gets the footer
   treatment the app's own pages have rather than looking like a stray sentence. */
main.legal .doc-foot {
  border-top: 1px solid var(--line);
  margin-top: 46px;
  padding-top: 18px;
  font-family: var(--font-mono);
  font-size: 12px;
  letter-spacing: 0.04em;
  color: var(--muted);
}
main.legal .doc-foot a { color: var(--muted); text-decoration: none; }
main.legal .doc-foot a:hover { color: var(--accent); text-decoration: underline; }

/* ---------------------------------------------------------------------------
   THE TABLE OF CONTENTS RAIL

   The rail is display:none by default and only ever turned on inside the desktop
   media query below. Written that way round on purpose: the colours and the type
   are declared unconditionally, so both palettes and the contrast sweep see them,
   and exactly one property decides whether it is on screen. A rail that was styled
   only inside a media query would be a block of rules nothing checks at the width
   the checks actually run at.
   --------------------------------------------------------------------------- */
.legal-toc { display: none; }
.legal-toc-title {
  font-family: var(--font-mono);
  font-size: 11.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
  margin: 0 0 10px;
}
.legal-toc-list {
  list-style: none;
  margin: 0;
  padding: 0;
  /* The rule down the left is the spine the current-section marker slides along. It is
     --line, the token for an edge that is meant to be seen, not --hairline. */
  border-left: 1px solid var(--line);
}
.legal-toc-link {
  display: block;
  /* -1px so the 2px marker sits ON the spine rather than beside it, covering the 1px
     rule exactly when a section is current. */
  margin-left: -1px;
  padding: 6px 0 6px 13px;
  border-left: 2px solid transparent;
  color: var(--muted);
  font-size: 13px;
  line-height: 1.4;
  text-decoration: none;
  transition: color var(--motion-micro) linear, border-color var(--motion-micro) linear;
}
.legal-toc-link:hover,
.legal-toc-link:focus-visible { color: var(--text); border-left-color: var(--line); }
/* The section you are actually reading. --accent-text rather than --accent because this
   is 13px: the light palette's amber is 3.99:1 on a panel and short of AA at label
   sizes, which is the whole reason --accent-text exists. The 2px spine marker is
   decoration and may stay the full --accent in both themes. */
.legal-toc-link[aria-current] {
  color: var(--accent-text);
  border-left-color: var(--accent);
  font-weight: 600;
}

@media (min-width: 1100px) {
  main.legal { padding-top: 40px; }
  /* Two columns, and the rail is the first grid item because it is also the first thing
     in the DOM: a table of contents you can only reach after tabbing through the whole
     policy is a table of contents for nobody. */
  main.legal.has-toc {
    display: grid;
    grid-template-columns: var(--legal-rail) minmax(0, 1fr);
    column-gap: var(--legal-gap);
    /* start, not stretch. A stretched grid item is exactly as tall as its grid area and
       therefore has nowhere to travel, which is the usual way a sticky rail ends up not
       sticking. Content-height item, full-height area: it sticks. */
    align-items: start;
  }
  .legal-toc {
    display: block;
    position: sticky;
    top: calc(var(--bar-h) + 22px);
    /* The FAQ has eighteen sections. Without a ceiling the rail is taller than the
       window, its foot is unreachable, and sticky pins the top of something you cannot
       see the end of. It scrolls inside itself instead, and the overscroll rule keeps
       that scroll from handing off to the page at the ends. */
    max-height: calc(100vh - var(--bar-h) - 44px);
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  /* The prose column. min-width:0 on the grid track above is what lets a long unbroken
     token in a code span shrink the column rather than push the grid wider than its
     container. */
  main.legal.has-toc > .legal-body { min-width: 0; }
  /* Bigger type is most of what a desktop should buy a page of prose. 16px with a slacker
     line height, and the eyebrow drops back to being an 11.5px label: `main.legal p` is
     (0,1,2) and beats `.eyebrow` at (0,1,0), so the label that opens every policy has
     been rendering at the prose size. Corrected here rather than in the shared rule
     because below 1100px nothing about these pages is allowed to move. */
  main.legal p, main.legal li { font-size: 16px; line-height: 1.72; }
  main.legal .eyebrow { font-size: 11.5px; }
}

@media (prefers-reduced-motion: reduce) {
  /* The only thing on these pages that moves under its own steam. The marker still moves
     between sections, because that is the layout following the reading position rather
     than an animation, but it arrives instead of easing. */
  .legal-toc-link { transition: none; }
}

/* A phone reads a legal page in a column 360px wide, and every nested indent comes out
   of the same budget. The list bullet indent stacked on the container padding and left
   list items with the shortest measure on the site. */
@media (max-width: 420px) {
  main.legal ul { padding-left: 17px; }
  main.legal p, main.legal li { font-size: 15.5px; line-height: 1.65; }
}

/* ---------------------------------------------------------------------------
   HOME

   1080px was right when the alternative was 640, and it is still close: the
   headline caps at 46px, the ledes cap at 62ch and the card grids are two-up, so
   past about 1200px extra width only buys longer lines inside the cards. It
   breathes to 1200 on a genuinely wide screen and stops there. The dead margin at
   1900px goes from 410px a side to 350px, which is deliberate: this is a reading
   page with a picture on it, not a dashboard.
   --------------------------------------------------------------------------- */

/* The receive note is a standing fact about the browser, not an alarm. On a phone it is
   eight lines of it, which is no way to open a consent gate, so below 900px it sits after
   the screen instead of before it: same page, same words, out of the way of the headline.
   `order` on the page's flex column moves it without moving it in the DOM, so the reading
   order for a screen reader is unchanged on a wide screen where it is genuinely first. */
@media (max-width: 899.98px) {
  #receive-note { font-size: 13px; }
  /* The triad costs vertical space, and on a phone every pixel above the agreement is a
     pixel of scrolling before you can act. Tightened, not removed: the structure is what
     makes the screen legible, the spacing is what makes it long. */
  .ob-grid { padding-top: 0; }
  #screen-onboarding .wg-h1 { margin-top: 6px; }
  #screen-onboarding .lede { font-size: 15px; margin-top: 8px; }
  .ob-notes-brow { margin: 16px 0 8px; }
  .ob-agree { margin-top: 14px; }
  #screen-onboarding .agreement .eyebrow { margin-bottom: 9px; }
  #screen-onboarding details.disc { margin-bottom: 8px; }
}

/* The receive note is one sentence of standing fact. It gets one sentence of room: a
   heading on its own line above the hero would announce a browser limitation as if it
   were the page's headline. */
/* The right pad still has to clear the close button, so it is written out rather than
   inherited: a bare `padding: 10px 14px` here put the last words of the sentence under
   the dismiss control. */
#receive-note { font-size: 13px; padding: 10px 50px 10px 14px; }
/* One gutter, at every width.
 *
 * `.banner` takes `width: var(--shell)`, which is `main`'s BORDER box, and the note is a
 * filled, bordered, rounded card. Measured at 390x844: the cards inside `main` sat at
 * x 16..374 and this one sat at x 0..390, so its border ran off both edges of the phone
 * and its corner radii were clipped by the screen while every other block on the screen
 * was inset. That is the same defect the 1728px slab was at 1920, one level down and
 * never fixed, because the cap that fixed it there is 980px and inert on a phone.
 *
 * `--shell` minus two gutters is exactly `main`'s CONTENT box (1696 at 1920, 358 at 390),
 * which is the line every card in the page is drawn to. An id, so it beats the
 * `.page > .banner` width without needing a longer selector. The other two banners keep
 * the full shell: they are page-level alarms shown before any composition exists. */
#receive-note { width: calc(var(--shell) - 2 * var(--gutter)); }
#receive-note strong { display: inline; margin-right: 7px; }


/* Engines without :has() never widen `main`, so the onboarding rail and the two-up
   disclosures would be squeezed into a 640px column: a 372px rail beside a 268px column
   of prose. They keep the phone stack at every width instead, which is narrow but
   correct, and is the direction the hero and the connected screen already fail in. */
@supports not selector(:has(*)) {
  @media (min-width: 1024px) {
    .ob-grid { display: block; }
    .ob-agree { position: static; margin-top: 20px; }
  }
  @media (min-width: 900px) {
    #share-shown { display: block; }
  }
}

@media (prefers-reduced-motion: reduce) {
  /* The disclosures are the centre of the consent screen now, so their marker is the
     one piece of motion a person on this screen cannot avoid seeing. */
  details.disc > summary::after { transition: none; }
}

/* ---------------------------------------------- the ask, after a gate that worked
   Geometry deliberately duplicated from .qr-modal on the landing rather than shared.
   The two documents are separate on purpose (separate CSPs, separate scripts) and the
   QR lightbox is landing-only markup, so folding them into one set of selectors would
   couple a change to the gate's end screen to a change on the donation page. Same
   stylesheet, same tokens, two small blocks. */

.wg-modal {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  opacity: 0;
  /* Same retune as .qr-modal, and the duplication between the two blocks stays for the
     reason the heading above gives. app.js reads this number out of --motion-small rather
     than holding its own copy, so the JS timeout and the fade cannot drift apart. */
  transition: opacity var(--motion-small) linear;
}
/* The UA sheet's [hidden] rule loses to any author rule carrying an explicit display,
   and the flex above is exactly that. Without this line the modal is on screen from
   the moment the page loads. */
.wg-modal[hidden] { display: none; }
.wg-modal.wg-modal-open { opacity: 1; }

.wg-modal-scrim {
  position: absolute;
  inset: 0;
  background: rgba(12, 15, 19, 0.66);
}

.wg-modal-panel {
  position: relative;
  width: min(430px, 100%);
  padding: 18px 20px 20px;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5);
  transform: translateY(8px) scale(0.98);
  transition: transform var(--motion-medium) var(--ease-enter);
}
.wg-modal-open .wg-modal-panel { transform: none; }

.wg-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.wg-modal-title { margin: 0 0 2px; font-size: 19px; letter-spacing: -0.01em; }
.wg-modal-close {
  flex: 0 0 auto;
  width: 30px;
  height: 30px;
  margin: -4px -6px 0 0;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: 1px solid transparent;
  border-radius: 8px;
  color: var(--muted);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
}
.wg-modal-close:hover,
.wg-modal-close:focus-visible { color: var(--text); background: var(--panel-2); border-color: var(--line); }

.donate-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 12px; }
.donate-link {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 12px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 9px;
  color: var(--text);
  font-size: 13.5px;
  text-decoration: none;
}
.donate-link:hover,
.donate-link:focus-visible { border-color: var(--accent-dim); color: var(--accent); }
.donate-link .ico { width: 15px; height: 15px; }

/* The three lead phrases in #net-modal carry the whole point of the panel: what will be
   exposed, what to do about it, and what the page cannot check for you. They sat at
   --muted, inherited from the paragraph, so the instruction was drawn in exactly the same
   ink as the sentence explaining it and nothing in the panel read as louder than anything
   else. They get --text-strong, which is a step above body copy rather than equal to it.
   Scoped to this modal on purpose: <strong> elsewhere is emphasis inside a sentence, and
   promoting all of it would flatten the same hierarchy this is trying to restore. */
#net-modal .wg-modal-panel p strong { color: var(--text-strong); font-weight: 700; }

/* Two buttons on one row on a desktop, stacked on a narrow phone, and the primary is
   first in the DOM so the keyboard reaches it first either way. */
.net-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0 10px; }
.net-actions button { flex: 1 1 150px; width: auto; margin-top: 0; }

@media (prefers-reduced-motion: reduce) {
  /* Panel loses the travel, scrim keeps the fade. See the .qr-modal-panel block for why
     the scrim is the one thing in a modal that should still arrive over time. */
  .wg-modal-panel { transition: none; transform: none; }
}

/* ---------------------------------------------- the camera scanner
 *
 * Lives in the join column on the home screen. The preview is not decoration: a scanner
 * with no preview is a camera the user cannot aim, and it is the only honest signal that
 * the camera is currently on. That is why the panel is sized to be clearly visible rather
 * than tucked into a corner.
 *
 * The panel and the button are mutually exclusive, and app.js flips both together, so the
 * layout never has to reserve room for two things at once. */

.scan-panel {
  margin-top: 12px;
  padding: 12px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

.scan-video {
  display: block;
  width: 100%;
  /* Not 16/9: a phone held upright gives a portrait stream, and a landscape box would
     letterbox it down to a strip too small to aim with. 4/3 is the compromise that keeps
     both orientations usable, and object-fit: cover fills whichever one arrives. */
  aspect-ratio: 4 / 3;
  max-height: 46dvh;
  object-fit: cover;
  background: #000;
  border-radius: var(--radius-sm);
  /* The picture is a mirror of what the camera sees, NOT flipped. A front camera preview
     is usually mirrored so people can aim at themselves; here the subject is a QR code on
     another screen, and a mirrored code is one a person cannot check by eye. */
  transform: none;
}

.scan-panel p { margin: 8px 0; }
.scan-panel button { margin-top: 4px; }

/* ---------------------------------------------- the suggestion box
 *
 * The last section on the landing, and deliberately the plainest thing on the page. It is
 * asking for a favour, so it gets no accent border, no card, no illustration: a textarea,
 * a button, and a sentence saying what happens to what you type. */

.lp-suggest-form {
  margin-top: 20px;
  max-width: 62ch;
}

.lp-suggest-form textarea {
  width: 100%;
  /* The gate's composer documents an 8.5-second layout stall from a single unbroken
     64 KiB token. This box caps at 600 characters, so it cannot reach that, but the same
     wrapping rules apply for the same reason: somebody will paste a URL in here. */
  overflow-wrap: anywhere;
  resize: vertical;
  min-height: 92px;
  padding: 10px 12px;
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  font: inherit;
  font-size: 15px;
  line-height: 1.5;
}

.lp-suggest-form textarea:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: var(--accent-dim);
}

.lp-suggest-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-top: 10px;
}

/* The button sizes to its text on a wide screen and fills the row on a phone, where a
   half-width button next to an empty counter reads as unfinished. */
.lp-suggest-row button { width: auto; margin-top: 0; flex: 0 0 auto; }

@media (max-width: 520px) {
  .lp-suggest-row { flex-direction: column-reverse; align-items: stretch; }
  .lp-suggest-row button { width: 100%; }
  /* Below the button once it is full width, so the eye lands on the action, not on the
     count of characters remaining. */
  .lp-suggest-row .small { text-align: center; }
}

/* role="status" with aria-live, so this is announced. It must therefore never be empty
   for layout reasons: it collapses when it has nothing to say. */
#suggest-said:empty { display: none; }

/* ================================================================= motion
 *
 * Everything that MOVES, in one place, so the set is countable. Two properties only:
 * transform and opacity, both of which the compositor can run without asking the main
 * thread for a layout. Colour appears on hover and press of small controls, and one
 * filter in the reduced-motion branch. Nothing below touches height, width, top, left,
 * margin, padding, flex-basis, box-shadow or a blur.
 *
 * `animation`, not `transition`, for anything that APPEARS. An element leaving
 * display:none restarts its CSS animations by itself, whereas a transition needs a
 * previous computed value to move away from: that is why revealModal() in app.js does
 * `void modal.offsetHeight`, a deliberate forced synchronous layout inside a click
 * handler. It is correct there and documented there, and it is not copied here. A
 * @keyframes needs no flush at all.
 *
 * Deliberately NOT used: `interpolate-size`/`calc-size()` (Chrome only, not Baseline),
 * `animation-timeline: view()` (Firefox does not ship it, so a third of visitors would
 * silently get nothing), and `@starting-style` (usable, but threading a discrete display
 * transition past the load-bearing `[hidden] { display: none !important }` above is not
 * worth it for two modals that already work). */

@keyframes wg-rise    { from { opacity: 0; transform: translateY(var(--reveal-rise)); } to { opacity: 1; transform: none; } }
@keyframes wg-rise-sm { from { opacity: 0; transform: translateY(calc(var(--reveal-rise) / 2)); } to { opacity: 1; transform: none; } }
@keyframes wg-leave   { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(calc(var(--reveal-rise) / -2)); } }
@keyframes wg-dim     { from { opacity: 1; } to { opacity: 0.6; } }
/* A drawer opening and shutting. Both are scaleY about the top edge plus opacity, so both
   composite; neither touches height, and the panel's own height still changes in a single
   frame on purpose. See the .is-opening / .is-closing rules below for why that is the
   trade this file makes. */
@keyframes wg-unfold  { from { opacity: 0; transform: scaleY(var(--fold-scale)); } to { opacity: 1; transform: none; } }
@keyframes wg-fold    { from { opacity: 1; transform: none; } to { opacity: 0; transform: scaleY(var(--fold-scale)); } }

/* The scroll reveal on the landing, and the whole of its failure model.
 *
 * The opacity:0 start state is scoped to `html.js-reveal`, a class ONLY landing.js adds
 * and only AFTER the IntersectionObserver exists. A dead script, a module the CSP
 * refused, a parse error, or a browser without IntersectionObserver therefore leaves
 * every section painted. The failure mode of the reveal is "no reveal", never "no
 * content", and that ordering is the entire reason the class exists rather than the
 * rule simply being written on .u-reveal. */
html.js-reveal .u-reveal { opacity: 0; }
html.js-reveal .u-reveal.is-in { animation: wg-rise var(--motion-medium) var(--ease-enter) both; }

/* A whole screen changing, on the gate. Enter only: the outgoing screen is hidden in the
   SAME frame. Fading one out and then the next in costs 450ms before the user sees the
   screen they asked for, which does not read as polish, it reads as latency. */
.screen[data-enter] { animation: wg-rise-sm var(--motion-large) var(--ease-enter) both; }

/* 4px of rise, half the landing's 8px, and smaller on purpose: #messages auto-scrolls on
   every append, so the arrival has to be distinguishable from that scroll rather than
   compete with it. The general rule is that larger elements travel shorter relative
   distances, or the move reads as a slam. */
.msg.is-new { animation: wg-rise-sm var(--motion-small) var(--ease-enter) both; }
.roster .who-chip.is-new { animation: wg-rise-sm var(--motion-small) var(--ease-enter) both; }
/* Leaving is not an entrance played backwards. A participant going away is a fade to the
   .away opacity this lands on, and nothing moves. */
.roster .who-chip.is-dimming { animation: wg-dim var(--motion-small) var(--ease-standard) both; }

/* Keyed off a class the toggle listener sets, NOT off [open]. The onboarding screen ships
   its disclosures OPEN, so an [open] selector would animate all five of them every time
   show('onboarding') runs fitDisclosures(). Open only; closing is instant, because a
   panel the user has decided to be rid of should be gone. */
details.disc.is-opening > .disc-body { animation: wg-rise-sm var(--motion-small) var(--ease-enter) both; }

/* The gate's own drawers are furniture, not an accordion. Connection details and Games sit
   in the rail for the life of a gate and get opened and shut over and over, and the 4px
   fade that suits a panel somebody reads once on the way in read as a snap there. They
   unfold from the summary's edge instead, over the large duration, and unlike anywhere
   else on this site they animate CLOSED too: a drawer that takes 300ms to open and
   disappears in one frame reads as a fault rather than as speed. app.js holds the panel
   open for the length of wg-fold and only then sets `open = false`, with a timer as the
   backstop so a browser that never fires animationend cannot strand a drawer open.

   scaleY and opacity, and the panel's HEIGHT still changes in one frame. That is the
   deliberate half of this: animating height would relayout the whole connected screen on
   every frame of every open, over a live transfer and a running board, which is exactly
   what the motion budget in tests/motion.test.mjs refuses. `details.disc` is
   `overflow: hidden`, so the fold is clipped to the panel while it runs. */
#screen-connected details.disc.is-opening > .disc-body {
  transform-origin: top center;
  animation: wg-unfold var(--motion-large) var(--ease-enter) both;
}
#screen-connected details.disc.is-closing > .disc-body {
  transform-origin: top center;
  animation: wg-fold var(--motion-small) var(--ease-exit) both;
}

/* An invitation is the one thing in this panel that arrives without being asked for, and
   it arrives into a drawer that may have just opened itself. Same 4px rise as a message
   landing in the transcript, because it is the same event: something the other device
   sent. Set by gameui.js on the row it appends, once per invitation, so a re-render for
   an unrelated notice does not replay it. */
#game-area .game-row.is-arriving { animation: wg-rise-sm var(--motion-small) var(--ease-enter) both; }

/* Dismissing a banner. Opacity and a 4px lift, and explicitly NOT a height collapse:
   these banners sit above a screen that may be running a live transfer, and relayouting
   the page under it is a real cost paid for a decoration. */
.banner.is-leaving { animation: wg-leave var(--motion-small) var(--ease-exit) both; }

/* Hover on the landing's cards: colour and border only, no lift, no translate. .bar-ico's
   comment settled this years ago ("a bar that reflows under the cursor is worse than one
   that does nothing") and it applies with more force to a grid of nine cards. */
.lp-card,
.lp-slot,
.donate-link,
a.support-card {
  transition: color var(--motion-micro) linear, border-color var(--motion-micro) linear;
}

/* The hero's two calls to action are anchors, not buttons, so the press feedback on
   `button` above does not reach them. */
.lp-action > a.primary,
.lp-action > a.secondary {
  transition: transform var(--motion-micro) var(--ease-standard),
              border-color var(--motion-micro) linear;
}
.lp-action > a.primary:active,
.lp-action > a.secondary:active { transform: scale(var(--press-scale)); }

/* The SC 2.2.2 control. Quiet: it is a standing preference, not an action anybody came
   here to take. --accent-text rather than --accent because this is 13px, and the light
   palette's amber is under AA at label sizes, which is why that token exists. */
#motion-toggle {
  font-size: 13px;
  color: var(--accent-text);
  margin-top: 10px;
}

/* Motion paused. One attribute on <html>, covering every perpetual loop the landing has:
   the tumbling hero word and the header sweep. The two setInterval loops in landing.js
   read the same attribute, because a CSS rule cannot stop a timer from re-writing a
   transform every 2.4 seconds. */
html[data-motion="off"] .lp-flip-box { transition: none; }
html[data-motion="off"] .lp-bar::after { animation: none; }

@media (prefers-reduced-motion: reduce) {
  /* Three tokens, and they collapse every translate and every scale in the file at once.
     Every fade keeps running. This is the whole reduced-motion policy: WebKit's own
     guidance is to "only remove the animations you know to be vestibular triggers...
     removing it prematurely only succeeds in making your site unnecessarily boring", and
     an opacity ramp moves nothing across the visual field.

     There is deliberately NO `* { animation: none }` here, and there must never be. It
     would take the section reveal's animation while leaving `html.js-reveal .u-reveal`
     at opacity 0, and the content would be permanently invisible to exactly the people
     this query is for. The reveal, the screen change and the message arrival all keep
     their fades; .msg is the most valuable of the three, because it is the only cue that
     a message landed and the list scrolls out from under it. */
  :root {
    --reveal-rise: 0px;
    --press-scale: 1;
    /* A drawer still fades open and shut; it just stops folding to get there. */
    --fold-scale: 1;
    /* Same trade for the phone sheet: it fades in where it will sit instead of travelling. */
    --sheet-travel: 0%;
  }
  /* --press-scale: 1 leaves the press with nothing to say, so it says it in luminance
     instead. Apple's HIG calls this substitution out by name. */
  button:active:not(:disabled) { filter: brightness(0.92); }
}

/* ---- the games drawer as a phone bottom sheet ---------------------------------------
   On a phone there is no rail: `.conn-rail { display: contents }` drops its children into
   the single column and Games lands under the composer as an inline drawer. Opening it
   there pushes the composer and the transcript around and the board arrives below the
   fold, which is the opposite of what a drawer is for on a screen this size. Every phone
   app that shows a panel over a conversation uses the same shape instead, so this does
   too: a sheet that rises from the bottom edge over a scrim, with a grab handle, dismissed
   by the scrim, by Escape, or by dragging it back down. app.js wires those three; a sheet
   whose only exit is the summary it covers is a sheet people get stuck inside.

   Two id selectors on purpose. `#screen-connected details.disc.is-opening > .disc-body` is
   (1,3,1) and would otherwise keep the desktop unfold animation on something that is now a
   sheet. This is (2,2,0) and wins on the id count alone. */
@media (max-width: 1023.98px) {
  #screen-connected #games-disc[open]::before {
    content: "";
    position: fixed;
    inset: 0;
    z-index: 59;
    background: var(--scrim-veil);
  }

  #screen-connected #games-disc[open] > .disc-body {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 60;
    max-height: 85dvh;
    overflow-y: auto;
    /* Without this, flicking past the end of a board scrolls the transcript underneath. */
    overscroll-behavior: contain;
    padding: 0 14px calc(14px + env(safe-area-inset-bottom, 0px));
    background: var(--panel);
    border: 1px solid var(--line);
    border-bottom: 0;
    border-radius: 16px 16px 0 0;
    animation: wg-sheet-up var(--motion-large) var(--ease-enter) both;
  }

  /* The grab handle. A sheet with no handle is a sheet nobody thinks to drag. */
  #screen-connected #games-disc[open] > .disc-body::before {
    content: "";
    display: block;
    width: 40px;
    height: 4px;
    margin: 10px auto 12px;
    border-radius: 2px;
    background: var(--line);
  }

  #screen-connected #games-disc.is-closing > .disc-body {
    animation: wg-sheet-down var(--motion-small) var(--ease-exit) both;
  }

  /* The page behind a sheet must not scroll under it. `.page` is the scroll container
     here, not the document, which is why this is not `body { overflow: hidden }`. */
  body:has(#games-disc[open]) > .page { overflow: hidden; }
}

@keyframes wg-sheet-up   { from { opacity: 0; transform: translateY(var(--sheet-travel)); } to { opacity: 1; transform: none; } }
@keyframes wg-sheet-down { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateY(var(--sheet-travel)); } }
