/* ==========================================================================
   Tarn VPN — public site design system

   A tarn is a glacial mountain lake with no inflow and no outflow. Nothing
   enters it, nothing leaves it. The palette is drawn from that place rather
   than from the category: granite for the ground, glacier turquoise for the
   water, one warm ochre for lichen. Glacier is rock flour suspended in
   meltwater — it is genuinely that colour, and deliberately NOT the electric
   cyan every competitor reaches for.

   Lichen (--warn) appears at most once per screen. It is the only warm colour
   here and it stops meaning anything the moment it is used twice.

   NOTE ON DRIFT: the previous palette was copied verbatim from the admin
   console's theme so app, console and site could not diverge. This rebrand
   ENDS that deliberately — the site is Tarn, the console stays Privio. If the
   console is ever rebranded too, these values are the source to copy from.

   The filename is still privio.css. It is not visitor-facing and four tests
   read it by that path; renaming is a tidy-up for its own commit, not
   something to bundle into a redesign.

   Self-hosted Archivo and IBM Plex Mono; self-hosted everything. This
   stylesheet makes zero third-party requests. A VPN site that loads a font
   from a CDN is announcing each visitor's IP to that CDN, which contradicts
   the product. CSP enforces it: font-src 'self'.
   ========================================================================== */

/* Archivo is a variable face: one file carries 400 through 800, so the
   Extrabold wordmark costs no extra request. */
@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url("../fonts/archivo-latin.92895abae7c5.woff2") format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url("../fonts/archivo-latin-ext.288d2a1c9f04.woff2") format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* Mono carries the technical voice — eyebrows, labels, figures, table
   headers. Static, so 400 and 500 are separate files. */
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/plexmono-400-latin.814fe1615d4d.woff2") format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/plexmono-400-latin-ext.17a5b2a15bf5.woff2") format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../fonts/plexmono-500-latin.6708425c446b.woff2") format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'IBM Plex Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../fonts/plexmono-500-latin-ext.216ee1a35b06.woff2") format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* --------------------------------------------------------------------------
   1. TOKENS
   -------------------------------------------------------------------------- */
:root {
  color-scheme: dark;

  --page:        #0E1519;   /* granite-900 */
  --surface:     #141E23;   /* granite-800 */
  --surface-2:   #111A1F;
  --surface-3:   #1D282E;   /* granite-700 */

  --ink:         #EAF1EF;   /* ice */
  --muted:       #93A5AC;
  --faint:       #6E838C;   /* slate-400 */

  --border:        #1D282E; /* granite-700 */
  --border-strong: #2B3A42; /* granite-600 */

  --accent:      #8FD3C9;   /* glacier */
  --accent-2:    #B7E5DD;   /* hover: brighter, not darker -- see note */
  --accent-bg:   rgba(143, 211, 201, 0.14);
  --accent-on:   #0E1519;   /* text on a glacier fill */

  --ok:       #8FD3C9;  --ok-bg:   rgba(143, 211, 201, 0.14);
  --warn:     #C4873C;  --warn-bg: rgba(196, 135, 60, 0.15);  /* lichen: once per screen */
  --bad:      #E0685E;  --bad-bg:  rgba(224, 104, 94, 0.15);
  --degraded: #A97534;
  --off:      #4A5C65;  --off-bg:  rgba(74, 92, 101, 0.18);   /* the mark at rest */

  /* Selection accent — never a state */
  --gold: #C4873C; /* lichen; unused today, kept as the selection accent */

  --term-bg: #080F12;
  --map-land: #1E2A36;
  --header-bg: rgba(14, 21, 25, 0.86);
  --shadow: rgba(0, 0, 0, .34);

  --radius:    10px;
  --radius-sm: 8px;
  --mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  --sans: 'Archivo', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;

  /* LAYOUT WIDTH -- fluid, no breakpoints.
     --maxw is a CEILING, not the width. The container is
     `min(100% - 2*gutter, maxw)`, so it grows with the viewport and stops
     when lines would get too long. 1080 was a hard cap, which left 420px of
     dead gutter each side on a 1920 monitor and used 56% of the screen.
     --gutter scales too: 20px on a phone, up to 64px on a wide desktop, so
     the edges breathe on large screens without stealing space on small ones.
     Widening this is safe for readability because prose is capped separately
     and always has been -- see --measure-lead / --measure-body below, which
     bound the TEXT in ch. The container only ever governs grids, cards and the
     map, which genuinely want the room. */
  --maxw: 1440px;
  --gutter: clamp(20px, 4vw, 64px);
  --gap: 20px;

  /* Type scale. Everything below is expressed against these, so the whole site
     can be tuned from one place instead of hunting individual rules.
     Stepped down one notch from the original: the first draft was sized for a
     landing page seen once, and this is a site people read. */
  --fs-body:  15px;
  --fs-h1:    clamp(1.75rem, 3.6vw, 2.6rem);
  --fs-h2:    clamp(1.3rem,  2.2vw, 1.75rem);
  --fs-h3:    1.02rem;
  --fs-lead:  clamp(.98rem,  1.3vw, 1.08rem);
  --lh-body:  1.6;

  /* Reading measure.
     `ch` is the advance width of "0", and in Inter that glyph is a good deal
     wider than the average character -- measured here, 1ch = 10.9px while the
     average character is 8.91px. So `max-width: 64ch` does NOT give 64
     characters, it gives 78, and the whole site was reading about 22% too wide.
     These values are calibrated against the MEASURED average, not the ch unit.
     If you "correct" them upward, re-measure first. */
  --measure-lead:  52ch;   /* ~63 characters */
  --measure-prose: 60ch;   /* ~73 characters */
}

/* --------------------------------------------------------------------------
   1b. LIGHT THEME

   Dark is the brand default and stays in :root above. Light is an override, in
   two places that must be kept identical:

     :root[data-theme="light"]              an explicit choice, wins over the OS
     prefers-color-scheme + :not([dark])    the OS preference, when unchosen

   Plain CSS has no way to share one declaration block between a bare selector
   and one inside a media query, so it is written twice. Both blocks are
   generated from a single source and a test asserts they still match -- two
   palettes that drift silently is exactly the failure this project has had
   before with duplicated rules.

   Every value here was contrast-checked against its background rather than
   picked by eye. The trap is glacier: it is the brand's primary accent and it
   manages 1.4:1 on a light page, so light uses deep water in its place.
   -------------------------------------------------------------------------- */
:root[data-theme="light"] {
  color-scheme: light;

  --page:        #EFF4F2;   /* ice, warmed slightly so white cards read as raised */
  --surface:     #FFFFFF;
  --surface-2:   #F5F9F7;
  --surface-3:   #E4EDEA;

  --ink:         #122126;
  --muted:       #44585F;
  --faint:       #5C7078;

  --border:        #D2DEDB;
  --border-strong: #B6C8C4;

  /* Glacier itself is far too pale on a light ground -- 1.4:1 against the page.
     Light uses deep water instead, which is the same colour further down. */
  --accent:      #1F6E69;
  --accent-2:    #14524E;
  --accent-bg:   rgba(31, 110, 105, 0.10);
  --accent-on:   #FFFFFF;

  --ok:       #1F6E69;  --ok-bg:   rgba(31, 110, 105, 0.10);
  --warn:     #8A5A1E;  --warn-bg: rgba(138, 90, 30, 0.12);
  --bad:      #A83A2E;  --bad-bg:  rgba(168, 58, 46, 0.10);
  --degraded: #8A5A1E;
  --off:      #6C8189;  --off-bg:  rgba(108, 129, 137, 0.14);

  --gold: #8A5A1E;

  /* The terminal stays dark in both themes. It is a terminal. */
  --term-bg: #0E1519;
  --map-land: #C6D6D1;
  --header-bg: rgba(239, 244, 242, 0.88);
  --shadow: rgba(18, 33, 38, .14);
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    color-scheme: light;

    --page:        #EFF4F2;   /* ice, warmed slightly so white cards read as raised */
    --surface:     #FFFFFF;
    --surface-2:   #F5F9F7;
    --surface-3:   #E4EDEA;

    --ink:         #122126;
    --muted:       #44585F;
    --faint:       #5C7078;

    --border:        #D2DEDB;
    --border-strong: #B6C8C4;

    /* Glacier itself is far too pale on a light ground -- 1.4:1 against the page.
       Light uses deep water instead, which is the same colour further down. */
    --accent:      #1F6E69;
    --accent-2:    #14524E;
    --accent-bg:   rgba(31, 110, 105, 0.10);
    --accent-on:   #FFFFFF;

    --ok:       #1F6E69;  --ok-bg:   rgba(31, 110, 105, 0.10);
    --warn:     #8A5A1E;  --warn-bg: rgba(138, 90, 30, 0.12);
    --bad:      #A83A2E;  --bad-bg:  rgba(168, 58, 46, 0.10);
    --degraded: #8A5A1E;
    --off:      #6C8189;  --off-bg:  rgba(108, 129, 137, 0.14);

    --gold: #8A5A1E;

    /* The terminal stays dark in both themes. It is a terminal. */
    --term-bg: #0E1519;
    --map-land: #C6D6D1;
    --header-bg: rgba(239, 244, 242, 0.88);
    --shadow: rgba(18, 33, 38, .14);
  }
}

/* The mark inverts rather than recolours. On a light ground the ice basin would
   vanish into the page, so it becomes granite and the inner ring steps down to
   the identity sheet's light-lockup value. */
:root[data-theme="light"] .mark__ring-3 { stroke: #7FC6BC; }
:root[data-theme="light"] .mark__core   { fill: #0E1519; }
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) .mark__ring-3 { stroke: #7FC6BC; }
  :root:not([data-theme="dark"]) .mark__core   { fill: #0E1519; }
}

/* --------------------------------------------------------------------------
   2. RESET
   -------------------------------------------------------------------------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

body {
  margin: 0;
  background: var(--page);
  color: var(--ink);
  font-family: var(--sans);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  /* Contour lines, drawn in CSS so they cost no request and scale to any
     viewport. This is the mark's own motif at page scale: closed elevation
     rings around still water. Kept very low contrast -- it should read as
     texture, not as pattern, and must never compete with the map on /servers. */
  background-image:
    radial-gradient(ellipse 80% 50% at 50% -10%, rgba(143, 211, 201, 0.07), transparent 70%),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='168' height='168' viewBox='0 0 168 168'%3E%3Cg fill='none' stroke='%2317222840' stroke-width='1'%3E%3Cpath d='M84 14c34 0 62 24 62 58s-27 68-65 68-57-32-57-66 26-60 60-60z'/%3E%3Cpath d='M84 40c22 0 41 17 41 39s-18 44-42 44-38-20-38-42 17-41 39-41z'/%3E%3Cpath d='M84 66c11 0 20 8 20 19s-8 20-20 20-19-9-19-20 8-19 19-19z'/%3E%3C/g%3E%3C/svg%3E");
  background-repeat: no-repeat, repeat;
  background-attachment: scroll, fixed;
}

/* iOS Safari has never handled a fixed background attachment well: it repaints
   the layer on every scroll frame, and on older versions drops the tile
   entirely. The tile is texture, so pinning it buys nothing worth that. */
@media (max-width: 768px) {
  body { background-attachment: scroll, scroll; }
}

img, svg { max-width: 100%; height: auto; display: block; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-2); text-decoration: underline; }

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 3px;
}

h1, h2, h3, h4 { line-height: 1.22; margin: 0 0 .45em; font-weight: 650; letter-spacing: -0.02em; }
h1 { font-size: var(--fs-h1); }
h2 { font-size: var(--fs-h2); }
h3 { font-size: var(--fs-h3); }
p  { margin: 0 0 .9rem; }

/* Long unbroken tokens -- account ids, licence keys, crypto addresses, txids --
   have no spaces to break at, so on a narrow screen they push straight out of
   their container. `anywhere` lets the browser break them mid-token, which is
   correct for machine strings and is why they are in a monospace face to begin
   with. Measured: a 32-char account id needed 308px inside a 285px card. */
code, pre, .mono {
  font-family: var(--mono);
  overflow-wrap: anywhere;
}

/* Skip link — keyboard users should not have to tab the whole nav */
.skip {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--accent); color: var(--accent-on);
  padding: 10px 16px; border-radius: 0 0 var(--radius-sm) 0; font-weight: 600;
}
.skip:focus { left: 0; }

/* --------------------------------------------------------------------------
   3. LAYOUT
   -------------------------------------------------------------------------- */
.wrap { width: min(100% - 2 * var(--gutter), var(--maxw)); margin-inline: auto; }
.section { padding-block: clamp(36px, 5.5vw, 68px); }
.section--tight { padding-block: clamp(24px, 3.5vw, 40px); }

.grid { display: grid; gap: var(--gap); }
/* Each grid caps at the column count in its name, and collapses below it when
   the columns would get too narrow. No media queries and no breakpoints: the
   minimum column width is derived from the CONTAINER, so it stays correct at
   any width the container ever takes.

   The `max(floor, (100% - gaps)/N)` is what does it. Above the floor the
   container's own N-th share wins, so exactly N fit and no more. Below it the
   fixed floor wins and auto-fit drops to fewer columns, exactly as before.

   The floors are the original values, so narrow-screen behaviour is unchanged.
   Only the upper bound is new -- without it, widening the page turned
   .grid--2 into three columns (four cards rendering as 3 + 1 orphan) and
   .grid--3 into five (six cards as 5 + 1, and a three-card row stretched
   across five slots). A grid called --2 showing three columns is a bug
   whatever the screen size; it was simply unreachable while the page was
   capped at 1080.

   The outer `min(100%, ...)` is a separate, older bug. A bare minmax(420px, 1fr)
   creates a 420px track even when the container is only 320px wide, so on a
   360px phone -- one of the commonest Android widths -- the cards ran 80px past
   the screen and the whole page scrolled sideways. Clamping the track minimum
   to 100% of the container means it can never demand more room than it has. */
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(min(100%, max(420px, (100% - 1 * var(--gap)) / 2)), 1fr)); }
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(min(100%, max(260px, (100% - 2 * var(--gap)) / 3)), 1fr)); }
.grid--4 { grid-template-columns: repeat(auto-fit, minmax(min(100%, max(200px, (100% - 3 * var(--gap)) / 4)), 1fr)); }

.stack > * + * { margin-top: 1rem; }
.center { text-align: center; }
.muted { color: var(--muted); }
.faint { color: var(--faint); }

/* --------------------------------------------------------------------------
   4. TYPE UTILITIES — the app's technical, uppercase, letter-spaced voice
   -------------------------------------------------------------------------- */
.eyebrow {
  font-family: var(--mono);
  font-size: .75rem;
  letter-spacing: .18em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: .75rem;
}
.label {
  font-family: var(--mono);
  font-size: .7rem;
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--faint);
}
.lead { font-size: var(--fs-lead); color: var(--muted); max-width: var(--measure-lead); }

/* --------------------------------------------------------------------------
   5. HEADER / NAV
   -------------------------------------------------------------------------- */
.site-header {
  position: sticky; top: 0; z-index: 50;
  background: var(--header-bg);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--border);
}
.nav { display: flex; align-items: center; gap: 18px; height: 60px; }
.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 700; }
.brand:hover { text-decoration: none; color: var(--ink); }
/* No border-radius: the contour is not a disc and clipping it eats the outer
   ring, which is the one that has to survive. */
.brand__mark { width: 34px; height: 34px; flex: none; }
/* Extrabold, but NOT at the identity sheet's -4% tracking.
   That figure is drawn for the 132px hero lockup. At header size it closes the
   gap between the r and the n until "Tarn" reads as "Tam" — the classic rn/m
   collision, and it is worse in a name nobody has seen before, because there is
   no familiar word for the eye to fall back on. Neutral tracking here, and the
   -4% is reserved for display sizes where the letterforms have room. */
.brand__name { font-size: 1.08rem; font-weight: 800; letter-spacing: 0; }
.brand__sub {
  display: block; font-family: var(--mono); font-size: .58rem;
  letter-spacing: .16em; text-transform: uppercase; color: var(--faint); font-weight: 400;
}
.nav__links { display: flex; gap: 4px; margin-left: auto; align-items: center; }
.nav__links a {
  color: var(--muted); padding: 8px 12px; border-radius: var(--radius-sm);
  font-size: .92rem; font-weight: 500;
}
.nav__links a:hover { color: var(--ink); background: var(--surface-2); text-decoration: none; }
.nav__links a.is-active { color: var(--accent); }

/* `.nav__links a` is specificity 0,1,1 and `.btn--primary` only 0,1,0, so the
   nav link rule was winning and painting the call-to-action button's label
   muted grey on teal -- unreadable. These restore the button's own colours at
   matching specificity. */
.nav__links a.btn--primary { color: var(--accent-on); background: var(--accent); }
.nav__links a.btn--primary:hover { color: var(--accent-on); background: var(--accent-2); }
.nav__links a.btn--ghost { color: var(--ink); }
.nav__links a.btn--ghost:hover { color: var(--ink); background: var(--surface-2); }
.nav__toggle { display: none; }

@media (max-width: 900px) {
  .nav__links {
    position: fixed; inset: 60px 0 auto 0;
    background: var(--surface); border-bottom: 1px solid var(--border);
    flex-direction: column; align-items: stretch; padding: 12px; gap: 2px;
    max-height: calc(100vh - 60px); overflow-y: auto;
  }
  .nav__links[hidden] { display: none; }
  .nav__toggle {
    display: inline-flex; margin-left: auto; align-items: center; justify-content: center;
    width: 42px; height: 42px; background: var(--surface-2);
    border: 1px solid var(--border); border-radius: var(--radius-sm);
    color: var(--ink); cursor: pointer;
  }
}

/* --------------------------------------------------------------------------
   6. BUTTONS
   -------------------------------------------------------------------------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 11px 20px; border-radius: var(--radius-sm);
  font-weight: 600; font-size: .9rem; line-height: 1;
  border: 1px solid transparent; cursor: pointer;
  transition: background .15s ease, border-color .15s ease, color .15s ease;
  text-decoration: none;
}
.btn:hover { text-decoration: none; }
.btn--primary { background: var(--accent); color: var(--accent-on); }
.btn--primary:hover { background: var(--accent-2); color: var(--accent-on); }
.btn--ghost { background: transparent; color: var(--ink); border-color: var(--border-strong); }
.btn--ghost:hover { background: var(--surface-2); color: var(--ink); border-color: var(--accent); }
.btn--danger { background: transparent; color: var(--bad); border-color: var(--bad); }
.btn--danger:hover { background: var(--bad-bg); color: var(--bad); }
.btn--sm { padding: 8px 14px; font-size: .85rem; }

/* Touch targets. A 34px-tall button is comfortable with a mouse and a
   coin-toss with a thumb; 44px is the accepted minimum.
   Gated on EITHER a coarse pointer OR a narrow viewport, not on the pointer
   alone: hybrid laptops and some tablets report `fine` while still being
   touched, and a narrow window is a good proxy for a thumb regardless. */
@media (pointer: coarse), (max-width: 900px) {
  .btn { min-height: 44px; }
  .btn--sm { min-height: 40px; }
  .nav__links a { min-height: 44px; display: flex; align-items: center; }
  .footer__cols a { display: inline-block; padding-block: 4px; }
}
.btn--block { width: 100%; }
.btn[disabled] { opacity: .5; cursor: not-allowed; }

/* --------------------------------------------------------------------------
   7. CARDS / PANELS
   -------------------------------------------------------------------------- */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 20px;
}
.card--hover { transition: border-color .15s ease, transform .15s ease; }
.card--hover:hover { border-color: var(--accent); transform: translateY(-2px); }
.card__icon {
  width: 40px; height: 40px; margin-bottom: 14px;
  color: var(--accent);
}
.card h3 { margin-bottom: .35rem; }
.card p:last-child { margin-bottom: 0; }

/* --------------------------------------------------------------------------
   8. BADGES / STATES
   -------------------------------------------------------------------------- */
.badge {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--mono); font-size: .7rem; letter-spacing: .1em; text-transform: uppercase;
  padding: 4px 10px; border-radius: 999px; border: 1px solid transparent;
}
.badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.badge--ok   { color: var(--ok);   background: var(--ok-bg);   border-color: rgba(0,191,165,.35); }
.badge--warn { color: var(--warn); background: var(--warn-bg); border-color: rgba(214,161,29,.35); }
.badge--bad  { color: var(--bad);  background: var(--bad-bg);  border-color: rgba(255,68,68,.35); }
.badge--off  { color: var(--off);  background: var(--off-bg);  border-color: rgba(94,103,117,.35); }

/* --------------------------------------------------------------------------
   9. FORMS
   -------------------------------------------------------------------------- */
/* .loc-request__row and .field-hint are the servers-page request form. They share
   these rules rather than carrying their own: written as a separate set they were
   simply never written at all, so the form rendered at browser defaults and the
   submit button sat flush against the field above it. */
.field, .loc-request__row { margin-bottom: 18px; }
.field > label, .loc-request__row > label {
  display: block; margin-bottom: 6px;
  font-family: var(--mono); font-size: .7rem; letter-spacing: .14em;
  text-transform: uppercase; color: var(--faint);
}
/* The label's (optional) marker is inside the uppercased mono label, where the
   global .muted colour alone leaves it shouting as loudly as the field name. */
.loc-request__row > label .muted { text-transform: none; letter-spacing: .02em; }
/* Styled by exclusion rather than by listing types. The list form silently
   missed input[type=search] — the location filter on /servers/ rendered as a
   21px browser default next to controls twice its height — and would have missed
   the next type added just as quietly. Only the types that are genuinely not
   text boxes are opted out. */
.input,
input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]):not([type="file"]):not([type="range"]),
select, textarea {
  width: 100%; padding: 12px 14px;
  background: var(--surface-2); color: var(--ink);
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  font-family: inherit; font-size: .95rem;
}
/* Safari gives type=search its own pill shape and ignores height and vertical
   padding until appearance is cleared, so without this the filter is still short
   on iPhone and Mac.

   Note the select is NOT in this list. Its arrow is drawn by the platform and is
   the only thing distinguishing it from a text box; clearing appearance here as
   well leaves the country picker looking exactly like the field below it, with
   nothing to say it opens a list. */
.input,
input:not([type="checkbox"]):not([type="radio"]):not([type="submit"]):not([type="button"]):not([type="file"]):not([type="range"]),
textarea {
  -webkit-appearance: none; appearance: none;
}
input[type="search"]::-webkit-search-decoration,
input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }
.input:focus, input:focus, select:focus, textarea:focus {
  border-color: var(--accent); outline: none;
  box-shadow: 0 0 0 3px var(--accent-bg);
}
.help, .field-hint { font-size: .82rem; color: var(--faint); margin-top: 6px; margin-bottom: 0; }

/* The submit sits after the last row's 18px, which reads as "part of the last
   field" rather than as the end of the form. */
.loc-request > button[type="submit"] { margin-top: 10px; }
.errorlist {
  list-style: none; margin: 6px 0 0; padding: 0;
  color: var(--bad); font-size: .85rem;
}
.alert {
  padding: 12px 16px; border-radius: var(--radius-sm);
  border: 1px solid var(--border); margin-bottom: 18px; font-size: .92rem;
}
.alert--error   { color: var(--bad);  background: var(--bad-bg);  border-color: rgba(255,68,68,.35); }
.alert--success { color: var(--ok);   background: var(--ok-bg);   border-color: rgba(0,191,165,.35); }
.alert--warn    { color: var(--warn); background: var(--warn-bg); border-color: rgba(214,161,29,.35); }
.alert--info    { color: var(--muted); background: var(--surface-2); }

/* The "not yet online" banner on a planned location page. It is the one thing on
   that page a visitor must not miss — it says the city is not in a subscription —
   and it was rendering as an ordinary paragraph, indistinguishable from the sales
   copy above it. Lichen, per §token note, is a once-per-screen colour; this page
   spends it here. */
.notice {
  margin: 20px 0; padding: 14px 16px;
  border: 1px solid var(--border); border-left: 3px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-2); color: var(--muted);
  font-size: .92rem; line-height: 1.6;
}
.notice strong { color: var(--ink); }
.notice--planned {
  border-color: rgba(196, 135, 60, .3); border-left-color: var(--warn);
  background: var(--warn-bg);
}
.notice--planned strong { color: var(--warn); }

/* --------------------------------------------------------------------------
   10. TABLES
   -------------------------------------------------------------------------- */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
table.tbl { width: 100%; border-collapse: collapse; font-size: .88rem; }
table.tbl th {
  text-align: left; padding: 10px 14px;
  font-family: var(--mono); font-size: .68rem; letter-spacing: .12em;
  text-transform: uppercase; color: var(--faint);
  border-bottom: 1px solid var(--border); white-space: nowrap;
}
table.tbl td { padding: 12px 14px; border-bottom: 1px solid var(--border); }
table.tbl tr:last-child td { border-bottom: none; }

/* On a phone a two-column label/value table reads better stacked than scrolled
   sideways: the <th> column alone can eat half a 375px viewport. Below 560px
   the key/value tables in the portal and settings become blocks. */
@media (max-width: 560px) {
  table.tbl.tbl--kv,
  table.tbl.tbl--kv tbody,
  table.tbl.tbl--kv tr,
  table.tbl.tbl--kv th,
  table.tbl.tbl--kv td { display: block; width: 100%; }
  table.tbl.tbl--kv th {
    border-bottom: none; padding-bottom: 2px;
  }
  table.tbl.tbl--kv td {
    padding-top: 0; padding-left: 14px; overflow-wrap: anywhere;
  }
}

/* --------------------------------------------------------------------------
   11. FOOTER
   -------------------------------------------------------------------------- */
.site-footer {
  border-top: 1px solid var(--border);
  background: var(--surface);
  padding-block: 36px 24px;
  margin-top: 48px;
}
.footer__cols { display: grid; gap: 32px; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
.footer__cols h4 {
  font-family: var(--mono); font-size: .7rem; letter-spacing: .14em;
  text-transform: uppercase; color: var(--faint); margin-bottom: 12px;
}
.footer__cols ul { list-style: none; margin: 0; padding: 0; }
.footer__cols li { margin-bottom: 8px; }
.footer__cols a { color: var(--muted); font-size: .9rem; }
.footer__cols a:hover { color: var(--accent); }
.footer__bar {
  margin-top: 36px; padding-top: 20px; border-top: 1px solid var(--border);
  display: flex; flex-wrap: wrap; gap: 12px; justify-content: space-between;
  color: var(--faint); font-size: .85rem;
}

/* --------------------------------------------------------------------------
   12. PORTAL SHELL
   -------------------------------------------------------------------------- */
.portal { display: grid; grid-template-columns: 232px 1fr; gap: 32px; align-items: start; }
@media (max-width: 860px) { .portal { grid-template-columns: 1fr; } }

.portal__nav { position: sticky; top: 80px; }
.portal__nav ul { list-style: none; margin: 0; padding: 0; }
.portal__nav a {
  display: block; padding: 10px 14px; border-radius: var(--radius-sm);
  color: var(--muted); font-size: .92rem; font-weight: 500;
}
.portal__nav a:hover { background: var(--surface-2); color: var(--ink); text-decoration: none; }
.portal__nav a.is-active { background: var(--accent-bg); color: var(--accent); }

.stat {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 18px 20px;
}
.stat__label {
  font-family: var(--mono); font-size: .68rem; letter-spacing: .14em;
  text-transform: uppercase; color: var(--faint); margin-bottom: 6px;
}
.stat__value { font-size: 1.35rem; font-weight: 650; line-height: 1.1; }
.stat__value--accent { color: var(--accent); }

/* --------------------------------------------------------------------------
   13. CHECKOUT / PAYMENT
   -------------------------------------------------------------------------- */
.pay-grid { display: grid; grid-template-columns: 260px 1fr; gap: 32px; align-items: start; }
@media (max-width: 720px) { .pay-grid { grid-template-columns: 1fr; } }

.qr {
  background: #FFFFFF; padding: 14px; border-radius: var(--radius);
  width: 100%; max-width: 260px;
}
.copyrow {
  display: flex; gap: 8px; align-items: stretch;
  background: var(--term-bg); border: 1px solid var(--border);
  border-radius: var(--radius-sm); padding: 4px 4px 4px 14px;
}
@media (max-width: 520px) {
  /* A crypto address plus a Copy button will not both fit across 375px.
     Stack them so the address gets the full width and the button stays a
     full-size tap target. */
  .copyrow { flex-direction: column; align-items: stretch; padding: 12px; gap: 10px; }
  .copyrow code { text-align: left; }
}
.copyrow code {
  flex: 1; min-width: 0; align-self: center;
  font-size: .88rem; color: var(--ink);
  overflow-wrap: anywhere;
}
.countdown { font-family: var(--mono); color: var(--warn); font-variant-numeric: tabular-nums; }

/* --------------------------------------------------------------------------
   14. PRICING
   -------------------------------------------------------------------------- */
.plan { display: flex; flex-direction: column; position: relative; }
.plan--featured { border-color: var(--accent); }
.plan--featured::after {
  content: "Best value";
  position: absolute; top: -11px; left: 50%; transform: translateX(-50%);
  background: var(--accent); color: var(--accent-on);
  font-family: var(--mono); font-size: .62rem; letter-spacing: .12em;
  text-transform: uppercase; padding: 3px 10px; border-radius: 999px; white-space: nowrap;
}
.plan__price { font-size: 2rem; font-weight: 700; line-height: 1; margin: 8px 0 2px; }
.plan__price sup { font-size: 1rem; font-weight: 500; top: -1rem; }
.plan__per { color: var(--faint); font-size: .85rem; margin-bottom: 18px; }
.plan ul { list-style: none; margin: 0 0 22px; padding: 0; flex: 1; }
.plan li { padding: 7px 0 7px 26px; position: relative; font-size: .92rem; color: var(--muted); }
.plan li::before {
  content: ""; position: absolute; left: 0; top: 14px;
  width: 12px; height: 6px; border-left: 2px solid var(--accent);
  border-bottom: 2px solid var(--accent); transform: rotate(-45deg);
}

/* --------------------------------------------------------------------------
   15. MISC
   -------------------------------------------------------------------------- */
.hero { padding-block: clamp(40px, 7vw, 84px) clamp(28px, 4vw, 52px); }
.hero__cta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
.hero__note { margin-top: 16px; font-size: .85rem; color: var(--faint); }

.term {
  background: var(--term-bg); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 18px;
  font-family: var(--mono); font-size: .85rem; color: var(--muted);
  overflow-x: auto;
}
.term .c-ok { color: var(--ok); }
.term .c-faint { color: var(--faint); }

hr.rule { border: none; border-top: 1px solid var(--border); margin: 32px 0; }

.prose { max-width: var(--measure-prose); }
.prose h2 { margin-top: 2em; }
.prose ul, .prose ol { padding-left: 1.3em; color: var(--muted); }
.prose li { margin-bottom: .4em; }


/* --------------------------------------------------------------------------
   16. NETWORK PAGE — world map, location grid, filter
   -------------------------------------------------------------------------- */

/* The map is decorative scaffolding for the markers, so the land sits well
   back and only the cities carry the accent. Colours come from tokens, which
   is the whole reason the SVG is inlined rather than served as a file. */
.worldmap-wrap {
  margin: 28px 0 8px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  padding: 14px;
  overflow-x: auto;
  /* Anchors .wm-popup, which privio.js positions inside this box. */
  position: relative;
}
/* 720 rather than 520: at 520 the gap between Singapore and Kuala Lumpur — and
   between Vienna and Prague — is under 5 CSS px, which makes tapping the one you
   meant impossible. The wrapper already scrolls horizontally, so the cost is
   more sideways scroll on a phone, not a broken layout. */
.worldmap { width: 100%; height: auto; min-width: 720px; display: block; }
.wm-land   { fill: var(--map-land); }
.wm-halos  { fill: var(--accent); opacity: .13; }
.wm-cities { fill: var(--accent); }
.wm-city   { transition: r .15s ease; }
.wm-city:hover { r: 6.5; }
/* :hover gives desktop feedback that a touch screen never receives. This is the
   same treatment for the tapped marker, applied by privio.js. */
.wm-city.is-active { r: 6.5; }

/* Tap popup. Created by privio.js and positioned with CSSOM writes — an inline
   style attribute would be dropped, because style-src carries no 'unsafe-inline'. */
.wm-popup {
  position: absolute;
  z-index: 2;
  transform: translate(-50%, -100%);
  margin-top: -14px;
  padding: 6px 10px;
  border-radius: 6px;
  /* Accent ground, --accent-on as text (8.2:1). --ink is the near-white
     TEXT colour on this dark theme and --bg does not exist, so the first
     attempt rendered near-white on near-white and was invisible. --accent-on
     is the file's existing token for text on accent; teal also ties the bubble
     to the markers, which are the same colour. */
  background: var(--accent);
  color: var(--accent-on);
  font-weight: 600;
  font-size: .82rem;
  line-height: 1.3;
  white-space: nowrap;
  pointer-events: none;
  box-shadow: 0 4px 14px var(--shadow);
  opacity: 0;
  transition: opacity .12s ease;
}
.wm-popup.is-open { opacity: 1; }
.wm-popup::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 100%;
  margin-left: -5px;
  border: 5px solid transparent;
  border-top-color: var(--accent);
}

@media (prefers-reduced-motion: reduce) {
  .wm-city { transition: none; }
  .wm-popup { transition: none; }
}

/* Filter — revealed by privio.js, so it never appears as a dead control. */
.loc-filter { margin: 28px 0 4px; max-width: 380px; }
.loc-filter label {
  display: block; margin-bottom: 6px;
  font-family: var(--mono); font-size: .7rem; letter-spacing: .14em;
  text-transform: uppercase; color: var(--faint);
}
.loc-filter__count { margin: 8px 0 0; font-size: .82rem; color: var(--faint); min-height: 1.2em; }

.loc-region { margin-top: 36px; }
.loc-region > h2 { margin-bottom: 14px; }
.loc-region[hidden] { display: none; }

/* Two decisions here, both deliberate.
   auto-FILL, not auto-fit: auto-fit collapses the empty tracks on a short final
   row, so the remaining cards stretch to fill the width and the row looks
   broken. auto-fill keeps the tracks, so every card stays the same size and a
   short row simply ends early -- which reads as normal.
   200px minimum: at the 1040px content width that yields 4 columns, so the
   two 7-card regions break [4,3] (one empty cell) rather than the [5,2] a
   narrower track would give (three empty cells). */
.loc-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 12px;
}

.loc-card {
  display: grid;
  grid-template-columns: auto 1fr;
  grid-template-areas: "flag city" "flag country";
  column-gap: 12px;
  align-items: center;
  padding: 14px 16px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: inherit;
  transition: border-color .15s ease, transform .15s ease;
}
.loc-card:hover {
  border-color: var(--accent); text-decoration: none;
  color: inherit; transform: translateY(-2px);
}
.loc-card[hidden] { display: none; }
.loc-card__flag    { grid-area: flag; font-size: 1.55rem; line-height: 1; }
.loc-card__city    { grid-area: city; font-weight: 600; color: var(--ink); }
.loc-card__country { grid-area: country; font-size: .82rem; color: var(--muted); }

.loc-empty { margin-top: 28px; color: var(--muted); }
.loc-empty[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) { .loc-card:hover { transform: none; } }


/* --------------------------------------------------------------------------
   17. PORTAL — stat row and the first-run path
   -------------------------------------------------------------------------- */

/* Explicit three-up. The shared grid--3 has a 260px track minimum, which only
   fits two tracks in the portal's 776px content column -- so the third stat
   dropped to its own row and stretched to full width. */
.stat-row {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 16px;
  margin-top: 20px;
}
@media (max-width: 720px) { .stat-row { grid-template-columns: 1fr; } }

.steps { list-style: none; margin: 24px 0 0; padding: 0; display: grid; gap: 12px; }
.steps__item {
  display: grid; grid-template-columns: auto 1fr; gap: 16px; align-items: start;
  padding: 18px 20px;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius);
}
.steps__item h3 { margin: 0 0 4px; font-size: 1rem; }
.steps__item p  { margin: 0 0 12px; font-size: .9rem; }
.steps__item p:last-child { margin-bottom: 0; }

.steps__n {
  display: grid; place-items: center;
  width: 30px; height: 30px; border-radius: 50%;
  font-family: var(--mono); font-size: .82rem; font-weight: 600;
  background: var(--surface-2); color: var(--faint);
  border: 1px solid var(--border);
}
/* State is carried by the border and the numeral together, not colour alone --
   the step you are on has to be findable without relying on hue. */
.steps__item.is-current { border-color: var(--accent); }
.steps__item.is-current .steps__n {
  background: var(--accent); color: var(--accent-on); border-color: var(--accent);
}
.steps__item.is-done .steps__n {
  background: var(--accent-bg); color: var(--accent); border-color: var(--accent);
}


/* --------------------------------------------------------------------------
   18. PRICING — saving badge and the shared-feature row
   -------------------------------------------------------------------------- */

/* The discount is the argument for the longer terms, so it gets weight. Uses
   the accent as TEXT on the surface rather than a fill, so it does not compete
   with the "Best value" badge sitting directly above it. */
.plan__saving {
  font-family: var(--mono); font-size: .74rem; letter-spacing: .1em;
  text-transform: uppercase; color: var(--accent);
  margin: 0 0 16px;
}
.plan__saving--none { color: var(--faint); }

/* Four of five bullets used to repeat on all three cards, which made the eye
   scan identical rows to find the one difference. Shared features are stated
   once here; the cards keep only what actually varies. */
.feature-row {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 8px 20px;
}
.feature-row li {
  position: relative; padding-left: 24px; color: var(--muted); font-size: .9rem;
}
.feature-row li::before {
  content: ""; position: absolute; left: 2px; top: .55em;
  width: 11px; height: 5px;
  border-left: 2px solid var(--accent); border-bottom: 2px solid var(--accent);
  transform: rotate(-45deg);
}


/* --------------------------------------------------------------------------
   19. HERO PANEL — the right half of the fold
   -------------------------------------------------------------------------- */
.hero__grid {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, .85fr);
  gap: clamp(24px, 5vw, 64px);
  align-items: center;
}
@media (max-width: 900px) {
  .hero__grid { grid-template-columns: 1fr; }
  .hero__panel { max-width: 420px; }
}

.hero__panel {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 22px 24px;
}
.kv { margin: 12px 0 0; display: grid; grid-template-columns: 1fr auto; gap: 0; }
.kv dt, .kv dd {
  margin: 0; padding: 9px 0; font-size: .88rem;
  border-bottom: 1px solid var(--border);
}
.kv dt { color: var(--muted); }
.kv dd { text-align: right; font-family: var(--mono); font-size: .8rem; }
.kv dt:last-of-type, .kv dd:last-of-type { border-bottom: none; }
/* "not collected" is the selling point, so it reads as a positive state, not
   as something missing. */
.kv__none { color: var(--accent); }
.kv__yes  { color: var(--ink); }

/* Guides are a reading list, not a set of destinations -- styling them as
   cards implied a link that was not there. */
.guide-list { list-style: none; margin: 24px 0 0; padding: 0; display: grid; gap: 10px; }
.guide-list li {
  padding: 14px 18px; background: var(--surface);
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  color: var(--muted); font-size: .92rem;
}
.guide-list strong { color: var(--ink); }

/* The no-logs "what exists" list -- the honest half of the ledger. Sentences,
   not key/values: what exists deserves explanation. */
.ledger { list-style: none; margin: 12px 0 0; padding: 0; }
.ledger li { padding: 10px 0; border-bottom: 1px solid var(--border);
  font-size: .92rem; color: var(--muted); line-height: 1.55; }
.ledger li:last-child { border-bottom: none; }
.ledger strong { color: var(--ink); font-weight: 600; }

/* --------------------------------------------------------------------------
   20. UTILITIES — generated from the inline styles that used to be here

   The CSP sets `style-src 'self' 'nonce-...'` with no 'unsafe-inline'. A
   nonce authorises an ELEMENT, not an attribute, so `style="..."` is blocked
   outright -- and silently: the markup is served, the browser drops the
   declaration, and the page just renders with the spacing missing. All 82
   inline styles on this site were being discarded in production.

   These classes are generated one-to-one from those declarations, so the
   result is pixel-identical to what was intended. A test now fails if a
   `style=` attribute reappears in a template.
   -------------------------------------------------------------------------- */
.u-mw-560px { max-width:560px; }
.u-mt-28px { margin-top:28px; }
.u-fs-11rem { font-size:1.1rem; }
.u-c-varmuted_pl-12em_mb-22px { color:var(--muted); padding-left:1.2em; margin-bottom:22px; }
.u-mt-18px { margin-top:18px; }
.u-mw-620px { max-width:620px; }
.u-mt-8px { margin-top:8px; }
.u-fs-105rem_ls-04em { font-size:1.05rem; letter-spacing:.04em; }
.u-fs-9rem { font-size:.9rem; }
.u-mt-20px { margin-top:20px; }
.u-m-6px08px { margin:6px 0 8px; }
.u-fs-9rem_m-0 { font-size:.9rem; margin:0; }
.u-mw-460px { max-width:460px; }
.u-mt-24px { margin-top:24px; }
.u-fs-9rem_m-14px00 { font-size:.9rem; margin:14px 0 0; }
.u-mb-10px { margin-bottom:10px; }
.u-c-varmuted_pl-12em_m-0 { color:var(--muted); padding-left:1.2em; margin:0; }
.u-m-14px00 { margin:14px 0 0; }
.u-mw-660px { max-width:660px; }
.u-mt-20px_fs-9rem { margin-top:20px; font-size:.9rem; }
.u-mw-860px { max-width:860px; }
.u-mt-24px_fs-9rem { margin-top:24px; font-size:.9rem; }
.u-m-18px0 { margin:18px 0; }
.u-m-32px0 { margin:32px 0; }
.u-mt-36px { margin-top:36px; }
.u-mi-auto { margin-inline:auto; }
.u-mt-40px_ta-left { margin-top:40px; text-align:left; }
.u-jc-center { justify-content:center; }
.u-fs-85rem_m-4px00 { font-size:.85rem; margin:4px 0 0; }
.u-fs-12rem { font-size:1.2rem; }
.u-mt-44px { margin-top:44px; }
.u-mt-48px { margin-top:48px; }
.u-td-none_c-inherit { text-decoration:none; color:inherit; }
.u-mb-2rem { margin-bottom:.2rem; }
.u-m-0_fs-9rem { margin:0; font-size:.9rem; }
.u-mt-44px_ta-left { margin-top:44px; text-align:left; }
.u-mt-24px_ta-left { margin-top:24px; text-align:left; }
.u-mb-12px { margin-bottom:12px; }
.u-mt-12px { margin-top:12px; }
.u-m-0 { margin:0; }
.u-fs-105rem_mt-0 { font-size:1.05rem; margin-top:0; }
.u-mt-40px { margin-top:40px; }
.u-mt-0_fs-115rem { margin-top:0; font-size:1.15rem; }
.u-mt-14px { margin-top:14px; }
.u-mt-16px { margin-top:16px; }
.u-mt-32px { margin-top:32px; }


/* --------------------------------------------------------------------------
   21. MOTION

   Three rules govern everything here.

   1. Nothing is hidden unless JavaScript is running. Every reveal is scoped to
      `html.js-motion`, a class privio.js sets on itself. Without JS -- or if the
      script fails -- the selectors never match and the page renders fully
      visible. The usual `opacity:0 then add a class` pattern gets this exactly
      backwards and leaves a blank page for anyone without JS, including
      crawlers.
   2. Reduced motion means NO motion, not less. The block at the end removes
      the transitions rather than shortening them.
   3. Only `transform` and `opacity` are animated. Both are composited, so none
      of this touches layout or forces a reflow while scrolling.
   -------------------------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {

  /* --- entrance on first paint: hero only ---------------------------------
     The hero is above the fold on every viewport, so it animates on load
     rather than on scroll. Six steps, 60ms apart, done in under half a
     second -- enough to feel deliberate, not enough to delay reading. */
  .js-motion .hero__grid > div > * ,
  .js-motion .hero__panel {
    animation: pv-rise .5s cubic-bezier(.2, .7, .3, 1) both;
  }
  .js-motion .hero__grid > div > *:nth-child(1) { animation-delay: .02s; }
  .js-motion .hero__grid > div > *:nth-child(2) { animation-delay: .08s; }
  .js-motion .hero__grid > div > *:nth-child(3) { animation-delay: .14s; }
  .js-motion .hero__grid > div > *:nth-child(4) { animation-delay: .20s; }
  .js-motion .hero__grid > div > *:nth-child(5) { animation-delay: .26s; }
  .js-motion .hero__panel                       { animation-delay: .18s; }

  @keyframes pv-rise {
    from { opacity: 0; transform: translate3d(0, 14px, 0); }
    to   { opacity: 1; transform: none; }
  }

  /* --- scroll reveal -------------------------------------------------------
     Applied by privio.js to sections below the fold. The transition lives on
     the element permanently; only the start state is toggled, so nothing has
     to be re-parsed when it fires. */
  .js-motion [data-reveal] {
    opacity: 0;
    transform: translate3d(0, 16px, 0);
    transition: opacity .55s cubic-bezier(.2, .7, .3, 1),
                transform .55s cubic-bezier(.2, .7, .3, 1);
  }
  .js-motion [data-reveal].is-revealed {
    opacity: 1;
    transform: none;
  }
  /* Revealed on init because it was already near the fold -- it must appear
     instantly, not fade in after the user is already looking at it. */
  .js-motion [data-reveal].is-instant,
  .js-motion [data-reveal].is-instant .card,
  .js-motion [data-reveal].is-instant .loc-card,
  .js-motion [data-reveal].is-instant .stat {
    transition: none !important;
    transition-delay: 0s !important;
  }
  /* Children stagger behind their parent. Capped at 6 -- past that the last
     card arrives late enough to read as a fault rather than a flourish.
     Descendant, not child: the cards sit inside .wrap > .grid, so a `>`
     combinator here matches nothing and the stagger silently never applies. */
  .js-motion [data-reveal] .card,
  .js-motion [data-reveal] .loc-card,
  .js-motion [data-reveal] .stat,
  .js-motion [data-reveal] .proof__item {
    transition-delay: calc(var(--pv-i, 0) * 45ms);
  }

  /* --- the world map -------------------------------------------------------
     The land fades in as a field, then the cities arrive. Markers carry their
     own index so they land roughly west-to-east, which reads as the network
     coming up rather than thirty dots blinking at once. */
  .js-motion .worldmap .wm-land { opacity: 0; transition: opacity .8s ease .1s; }
  .js-motion .worldmap.is-revealed .wm-land { opacity: 1; }

  .js-motion .worldmap .wm-city,
  .js-motion .worldmap .wm-halo {
    opacity: 0;
    transform-box: fill-box;
    transform-origin: center;
    transform: scale(.4);
    transition: opacity .4s ease, transform .4s cubic-bezier(.2, 1.4, .4, 1);
    transition-delay: calc(.35s + var(--pv-i, 0) * 28ms);
  }
  .js-motion .worldmap.is-revealed .wm-city,
  .js-motion .worldmap.is-revealed .wm-halo {
    opacity: 1;
    transform: scale(1);
  }
  /* A slow breath on the halos once they have arrived. Deliberately long and
     low-contrast: this sits behind content people are reading. */
  .js-motion .worldmap.is-revealed .wm-halo {
    animation: pv-breathe 4.5s ease-in-out 1.6s infinite;
  }
  @keyframes pv-breathe {
    0%, 100% { opacity: .10; }
    50%      { opacity: .26; }
  }

  /* --- small interactions -------------------------------------------------- */
  .btn { transition: background .15s ease, border-color .15s ease,
                     color .15s ease, transform .12s ease; }
  .btn:active { transform: translateY(1px); }

  .nav__links a { transition: color .15s ease, background .15s ease; }

  /* The licence key is shown once and never again; a brief highlight makes
     sure the eye lands on it before anything else on the page. */
  .js-motion .copyrow code { animation: pv-flash 1.6s ease-out .3s 1; }
  @keyframes pv-flash {
    0%   { background: var(--accent-bg); }
    100% { background: transparent; }
  }
}

/* Reduced motion: remove it, do not merely speed it up. Anything left running
   at 0.001s still fires, which is exactly what someone with vestibular
   sensitivity asked not to happen. */
@media (prefers-reduced-motion: reduce) {
  .js-motion [data-reveal],
  .js-motion .worldmap .wm-land,
  .js-motion .worldmap .wm-city,
  .js-motion .worldmap .wm-halo {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
    animation: none !important;
  }
}


/* --------------------------------------------------------------------------
   22. THE MARK AS STATE

   The rings are the connect indicator, not decoration: still and grey at rest,
   lichen breathing through the handshake, glacier pushing outward while the
   tunnel is up. Same geometry throughout, so someone learns the brand and the
   interface in one gesture.

   Only transform and opacity are animated, matching §21 — both are compositor
   properties, so this never costs a layout pass.
   -------------------------------------------------------------------------- */
.mark { display: block; }
.mark--sm { width: 34px; height: 34px; }
.mark--lg { width: 104px; height: 104px; }

.mark__ring { transform-origin: 32px 32px; }

@keyframes tarn-push {
  0%   { transform: scale(.62); opacity: 0; }
  22%  { opacity: 1; }
  100% { transform: scale(1.06); opacity: 0; }
}
@keyframes tarn-breathe {
  0%, 100% { opacity: .5; }
  50%      { opacity: 1; }
}

.mark--live .mark__ring   { animation: tarn-push 2.6s cubic-bezier(.3, .6, .35, 1) infinite; }
.mark--live .mark__ring-2 { animation-delay: .86s; }
.mark--live .mark__ring-3 { animation-delay: 1.72s; }

.mark--connecting .mark__ring   { animation: tarn-breathe 1.3s ease-in-out infinite; }
.mark--connecting .mark__ring-2 { animation-delay: .35s; }

/* Someone who has asked for less movement still needs to read the state, so
   the rings resolve to their resting position rather than disappearing. */
@media (prefers-reduced-motion: reduce) {
  .mark--live .mark__ring,
  .mark--connecting .mark__ring {
    animation: none;
    transition: none;
    opacity: 1;
    transform: none;
  }
}

/* The contour ground, restated for light. The stroke is a data URI so it cannot
   take a custom property; the whole image has to be swapped. Same three paths,
   lighter ink, and the glow drops to deep water.

   The light tile must be reached the same way the light TOKENS are, three-state:
   explicit choice, or the system preference guarded against an explicit dark.
   This block used to select on `:root:not([data-theme="dark"])` alone, with no
   media query — which matches whenever no theme has been chosen, whatever the
   system says. A visitor in system dark, the default for most, therefore got the
   dark palette with the LIGHT contour: opaque #DEE8E4 rings, near-white, drawn
   at full strength across a near-black page and straight through the body copy.
   The tile is meant to sit two RGB steps off its background. */
:root[data-theme="light"] body {
  background-image:
    radial-gradient(ellipse 80% 50% at 50% -10%, rgba(31, 110, 105, 0.05), transparent 70%),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='168' height='168' viewBox='0 0 168 168'%3E%3Cg fill='none' stroke='%23DEE8E4' stroke-width='1'%3E%3Cpath d='M84 14c34 0 62 24 62 58s-27 68-65 68-57-32-57-66 26-60 60-60z'/%3E%3Cpath d='M84 40c22 0 41 17 41 39s-18 44-42 44-38-20-38-42 17-41 39-41z'/%3E%3Cpath d='M84 66c11 0 20 8 20 19s-8 20-20 20-19-9-19-20 8-19 19-19z'/%3E%3C/g%3E%3C/svg%3E");
}
/* Identical to the block above, reached by system preference instead of by an
   explicit choice — the same pairing the light tokens use. Kept byte-identical
   so the two cannot drift; LightContourGround in the tests asserts that. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) body {
    background-image:
      radial-gradient(ellipse 80% 50% at 50% -10%, rgba(31, 110, 105, 0.05), transparent 70%),
      url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='168' height='168' viewBox='0 0 168 168'%3E%3Cg fill='none' stroke='%23DEE8E4' stroke-width='1'%3E%3Cpath d='M84 14c34 0 62 24 62 58s-27 68-65 68-57-32-57-66 26-60 60-60z'/%3E%3Cpath d='M84 40c22 0 41 17 41 39s-18 44-42 44-38-20-38-42 17-41 39-41z'/%3E%3Cpath d='M84 66c11 0 20 8 20 19s-8 20-20 20-19-9-19-20 8-19 19-19z'/%3E%3C/g%3E%3C/svg%3E");
  }
}
:root[data-theme="dark"] body {
  background-image:
    radial-gradient(ellipse 80% 50% at 50% -10%, rgba(143, 211, 201, 0.07), transparent 70%),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='168' height='168' viewBox='0 0 168 168'%3E%3Cg fill='none' stroke='%2317222840' stroke-width='1'%3E%3Cpath d='M84 14c34 0 62 24 62 58s-27 68-65 68-57-32-57-66 26-60 60-60z'/%3E%3Cpath d='M84 40c22 0 41 17 41 39s-18 44-42 44-38-20-38-42 17-41 39-41z'/%3E%3Cpath d='M84 66c11 0 20 8 20 19s-8 20-20 20-19-9-19-20 8-19 19-19z'/%3E%3C/g%3E%3C/svg%3E");
}

/* --------------------------------------------------------------------------
   24. THEME TOGGLE

   Two glyphs in one button, one shown per theme. Doing it in CSS rather than
   swapping innerHTML keeps the control correct even before the script runs.
   -------------------------------------------------------------------------- */
.theme-toggle {
  display: grid; place-items: center;
  width: 38px; height: 38px; flex: none;
  margin-left: 6px;
  background: none; border: 1px solid var(--border); border-radius: var(--radius-sm);
  color: var(--muted); cursor: pointer;
  transition: color .15s ease, border-color .15s ease;
}
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
.theme-toggle svg { width: 17px; height: 17px; }
.theme-toggle .ico-sun  { display: none; }
.theme-toggle .ico-moon { display: block; }
:root[data-theme="light"] .theme-toggle .ico-sun,
:root:not([data-theme="dark"]) .theme-toggle .ico-sun  { display: block; }
:root[data-theme="light"] .theme-toggle .ico-moon,
:root:not([data-theme="dark"]) .theme-toggle .ico-moon { display: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle .ico-sun  { display: none; }
  :root:not([data-theme="light"]) .theme-toggle .ico-moon { display: block; }
}
/* 44px minimum target on touch, matching every other control here. */
@media (pointer: coarse), (max-width: 900px) {
  .theme-toggle { width: 44px; height: 44px; }
}


/* --------------------------------------------------------------------------
   25. PROOF LIST

   The homepage's four claims as single lines. These replaced full cards whose
   second sentences now live on the pages that own the topics -- the homepage's
   job is to be believed quickly, and measured on a phone it was showing 4.1
   screens of text before the price.
   -------------------------------------------------------------------------- */
.proof { list-style: none; margin: 26px 0 0; padding: 0;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); overflow: hidden; }
.proof__item { display: flex; align-items: baseline; gap: 14px;
  padding: 15px 18px; border-bottom: 1px solid var(--border); }
.proof__item:last-child { border-bottom: none; }
.proof__claim { flex: 1; min-width: 0; }
/* The em-dash split: the claim's first words are the point, so they get ink
   weight while the rest stays muted. Done in copy (one strong phrase before the
   dash) rather than markup, so the template stays plain text. */
.proof__more { font-family: var(--mono); font-size: .72rem; letter-spacing: .1em;
  text-transform: uppercase; white-space: nowrap; }

/* --------------------------------------------------------------------------
   26. THE MARK DRAWS ITSELF

   Once, on page load, the header contour draws in -- the identity sheet's own
   entrance, brought to the site. Stroke-dash animation on three rings plus a
   late fade on the basin. It happens in well under two seconds, runs once, and
   the settled state is identical to the static mark, so nothing depends on it.

   Gated on .js-motion exactly like section 21: with JS off or reduced motion
   on, the mark simply IS, fully drawn, from the first frame.
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  .js-motion .brand__mark .mark__ring {
    stroke-dasharray: 270; stroke-dashoffset: 270;
    animation: tarn-draw 1.1s cubic-bezier(.22, .7, .3, 1) forwards;
  }
  .js-motion .brand__mark .mark__ring-2 { animation-delay: .12s; }
  .js-motion .brand__mark .mark__ring-3 { animation-delay: .24s; }
  .js-motion .brand__mark .mark__core {
    opacity: 0; animation: tarn-fill .45s ease .75s forwards;
  }
}
@keyframes tarn-draw { to { stroke-dashoffset: 0; } }
@keyframes tarn-fill { to { opacity: 1; } }
/* --------------------------------------------------------------------------
   27. THE WINDOWS FIGURE

   The launch page's hero: the client drawn as a survey figure, not a
   screenshot. Its mark cycles the three real states -- rest, handshake,
   protected -- on a 12s loop, caption in step, reusing the same tarn-push /
   tarn-breathe keyframes the section-22 state marks use, so the demo cannot
   drift from the real state language.

   The window interior sits on --term-bg, which stays dark in BOTH themes (it
   is a terminal; the app is dark). That is what lets the state colours be
   literals here without a light-theme variant.

   Defaults below show the PROTECTED state with the others hidden: that is the
   settled frame for reduced motion, for no-JS, and for any browser that skips
   the animation. The cycle only exists inside the no-preference gate.
   -------------------------------------------------------------------------- */
.win-fig { margin: 0; display: flex; flex-direction: column; gap: 10px; }
.win-fig__window { border: 1px solid var(--border-strong); border-radius: 10px;
  overflow: hidden; box-shadow: 0 18px 44px var(--shadow); }
.win-fig__bar { display: flex; align-items: center; justify-content: space-between;
  background: #17222A; padding: 9px 14px; }
.win-fig__apptitle { font-family: var(--mono); font-size: .72rem;
  letter-spacing: .14em; text-transform: uppercase; color: #93A5AC; }
.win-fig__controls { display: flex; gap: 7px; }
.win-fig__controls i { width: 9px; height: 9px; border-radius: 50%;
  background: #4A5C65; }
.win-fig__body { background: var(--term-bg); padding: 34px 24px 26px;
  display: flex; flex-direction: column; align-items: center; gap: 18px; }
.win-fig__stage { display: grid; width: 128px; }
.win-fig__stage svg { grid-area: 1 / 1; width: 100%; height: auto; }
.wf-ring { transform-origin: 32px 32px; }
.wfs--rest, .wfs--conn { opacity: 0; }
.wfs--live { opacity: 1; }
.win-fig__status { margin: 0; display: grid; font-family: var(--mono);
  font-size: .82rem; letter-spacing: .06em; text-align: center; }
.win-fig__status .wf-cap { grid-area: 1 / 1; }
.wf-cap--rest { color: #6E838C; opacity: 0; }
.wf-cap--conn { color: #C4873C; opacity: 0; }
.wf-cap--live { color: #8FD3C9; opacity: 1; }

.win-fig__tray { display: flex; align-items: center; justify-content: flex-end;
  gap: 12px; background: #17222A; border: 1px solid var(--border-strong);
  border-radius: 7px; padding: 7px 12px; }
.win-fig__ti { width: 9px; height: 9px; border-radius: 2px; background: #3A4C55; }
.win-fig__traymark { width: 17px; height: 17px; }
.win-fig__clock { font-family: var(--mono); font-size: .68rem; color: #93A5AC; }
.win-fig__cap { font-family: var(--mono); font-size: .68rem; letter-spacing: .08em;
  color: var(--faint); line-height: 1.6; }

@media (prefers-reduced-motion: no-preference) {
  .wfs--rest { animation: wf-slot-a 12s linear infinite; }
  .wfs--conn { animation: wf-slot-b 12s linear infinite; }
  .wfs--live { animation: wf-slot-c 12s linear infinite; }
  .wf-cap--rest { animation: wf-slot-a 12s linear infinite; }
  .wf-cap--conn { animation: wf-slot-b 12s linear infinite; }
  .wf-cap--live { animation: wf-slot-c 12s linear infinite; }
  .wfs--conn .wf-r1 { animation: tarn-breathe 1.3s ease-in-out infinite; }
  .wfs--conn .wf-r2 { animation: tarn-breathe 1.3s ease-in-out infinite .35s; }
  .wfs--live .wf-p1 { animation: tarn-push 2.6s cubic-bezier(.3,.6,.35,1) infinite; }
  .wfs--live .wf-p2 { animation: tarn-push 2.6s cubic-bezier(.3,.6,.35,1) infinite .86s; }
  .wfs--live .wf-p3 { animation: tarn-push 2.6s cubic-bezier(.3,.6,.35,1) infinite 1.72s; }
}
@keyframes wf-slot-a { 0%, 30% { opacity: 1; } 34%, 96% { opacity: 0; } 100% { opacity: 1; } }
@keyframes wf-slot-b { 0%, 30% { opacity: 0; } 34%, 63% { opacity: 1; } 67%, 100% { opacity: 0; } }
@keyframes wf-slot-c { 0%, 63% { opacity: 0; } 67%, 96% { opacity: 1; } 100% { opacity: 0; } }

/* --------------------------------------------------------------------------
   28. VERIFICATION PLATE

   The download's fingerprint, laid out like the survey plate it is. The value
   column is mono and breaks anywhere: a SHA-256 has no spaces, and on a phone
   it must wrap rather than push the page sideways.
   -------------------------------------------------------------------------- */
.plate { border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); overflow: hidden; max-width: 720px; }
.plate__row { display: flex; gap: 16px; padding: 12px 16px;
  border-bottom: 1px solid var(--border); align-items: baseline; }
.plate__row:last-child { border-bottom: none; }
.plate__key { font-family: var(--mono); font-size: .7rem; letter-spacing: .12em;
  text-transform: uppercase; color: var(--faint); flex: 0 0 84px; }
.plate__val { font-size: .82rem; color: var(--ink); background: none; }
.plate__val--pending { color: var(--faint); font-style: italic; }

/* The featured plan's "Best value" pill settles in once when the pricing grid
   reveals -- a single completed gesture, not a loop. Scale+opacity only, and
   the resting state is the plain pill, so skipping the animation loses nothing. */
@media (prefers-reduced-motion: no-preference) {
  .js-motion [data-reveal].is-revealed .plan--featured::after {
    animation: tarn-settle .5s cubic-bezier(.2, 1.4, .4, 1) both;
    animation-delay: .35s;
  }
}
@keyframes tarn-settle {
  from { transform: translateX(-50%) scale(.6); opacity: 0; }
  to   { transform: translateX(-50%) scale(1);  opacity: 1; }
}

/* --------------------------------------------------------------------------
   29. THE ROUTE  (Fig. 2, technology page)

   Device -> tunnel -> tarn -> internet, in the contour voice. The resting
   state is the COMPLETE drawing: the draw-in below only runs inside the
   no-preference gate once the figure's section reveals, so reduced motion,
   no-JS and print all get the finished route.
   -------------------------------------------------------------------------- */
.route { margin: 28px 0 0; max-width: 720px; }
.route svg { width: 100%; height: auto; display: block; }
.route__device { stroke: var(--faint); stroke-width: 2.4; }
.route__tunnel, .route__exit { stroke-width: 2.6; stroke-linecap: round; }
.route__tunnel { stroke: var(--accent); }
.route__exit { stroke: var(--accent-2); opacity: .7; }
.route__ring { stroke-width: 2.4; }
.route__ring-1 { stroke: var(--accent-2); }
.route__ring-2 { stroke: var(--accent); }
.route__core { fill: var(--ink); }
.route__net circle { fill: var(--faint); opacity: .8; }
/* Column widths mirror the SVG's geometry (device centre 7%, tunnel 25%,
   tarn 47%, internet 92% of the viewBox) so each label sits under the thing
   it names -- equal columns drifted half a step right of the drawing. */
.route__labels { display: grid; grid-template-columns: 14% 23% 24% 1fr;
  gap: 8px; margin-top: 10px; font-family: var(--mono); font-size: .68rem;
  letter-spacing: .1em; text-transform: uppercase; color: var(--faint); }
.route__labels span:nth-child(2) { text-align: center; }
.route__labels span:nth-child(3) { text-align: center; }
.route__labels span:nth-child(4) { text-align: right; }
/* At phone widths four uppercase mono labels cannot share a row; stack the
   figure's caption row into two lines of two. */
@media (max-width: 560px) {
  .route__labels { grid-template-columns: 1fr 1fr; row-gap: 4px; }
  .route__labels span:nth-child(2) { text-align: right; }
  .route__labels span:nth-child(3) { text-align: left; }
}
.route__cap { margin-top: 14px; font-family: var(--mono); font-size: .68rem;
  letter-spacing: .08em; color: var(--faint); line-height: 1.6; }

@media (prefers-reduced-motion: no-preference) {
  /* Draws on LOAD, not on reveal. The figure lives in the page's first
     section, which the reveal tagger deliberately skips (a first section is
     visible immediately) -- wiring this to [data-reveal].is-revealed meant it
     never fired at all. Found by reading computed styles, not the CSS.
     The tunnel draws first, the exit second: the order the prose explains
     them in. `both` holds the start empty and the end full. */
  .js-motion .route__draw { stroke-dasharray: 200; stroke-dashoffset: 200; }
  .js-motion .route__tunnel {
    animation: tarn-draw .9s cubic-bezier(.22, .7, .3, 1) .45s both;
  }
  .js-motion .route__exit {
    animation: tarn-draw .9s cubic-bezier(.22, .7, .3, 1) 1.15s both;
  }
}

/* The final reduced-motion word for the additions above; keep this block LAST
   in the file -- the Motion test reads the last reduce block and requires all
   three resets present. */
@media (prefers-reduced-motion: reduce) {
  .brand__mark .mark__ring, .brand__mark .mark__core, .proof__item {
    animation: none;
    transition: none;
    opacity: 1;
    stroke-dashoffset: 0;
    transform: none;
  }
  /* The Windows figure settles on its protected frame -- the defaults already
     say so; this only makes sure no animation contradicts them. */
  .route__draw { animation: none; stroke-dashoffset: 0; }
  .wfs--rest, .wfs--conn, .wf-cap--rest, .wf-cap--conn { animation: none; opacity: 0; }
  .wfs--live, .wf-cap--live { animation: none; opacity: 1; }
  .wf-ring { animation: none; transform: none; }
}
