/* ============================================================================
   Web fonts — Source Sans Pro (the family ODS declares) and JetBrains Mono
   (our code face), both self-hosted; see fonts/README.md.
   Not loaded from Google Fonts on purpose: this page measures throughput and
   must not depend on a third party at load time. Latin subset only; anything
   outside it falls back through the stack in --ods-font.
   ========================================================================== */
@font-face {
  font-family: "Source Sans Pro";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/source-sans-pro-v23-latin-400.woff2") format("woff2");
}

@font-face {
  font-family: "Source Sans Pro";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/source-sans-pro-v23-latin-600.woff2") format("woff2");
}

/* 700 is ODS's heading weight — the headings are set by the Text component,
   not a token, and its presets all carry 700 — so it is part of what this
   page must be able to serve. Same v23 latin static as the other two. */
@font-face {
  font-family: "Source Sans Pro";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/source-sans-pro-v23-latin-700.woff2") format("woff2");
}

/* One variable file covers every weight we use (400 for commands, hostnames
   and table cells, 600 for the selected server's host), where Source Code Pro
   needed one static file per weight. */
@font-face {
  font-family: "JetBrains Mono";
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url("/fonts/jetbrains-mono-v24-latin-400-800.woff2") format("woff2");
}

/* ============================================================================
   OVHcloud Design System (ODS) tokens — canonical values, light theme.
   These are copied verbatim from the ODS documentation and must NEVER be
   redefined per theme: they are the brand, not a palette choice.
   ODS documents no dark theme; see the extrapolation block further down.
   ========================================================================== */
:root {
  --ods-brand: #000e9c;
  --ods-text: #4d5592;
  --ods-heading: #00185e;
  --ods-text-disabled: #808080;

  --ods-anchor: #0050d7;
  --ods-anchor-hover: #002dbe;
  --ods-anchor-visited: #000e9c;

  --ods-bg: #fff;
  --ods-bg-readonly: #f2f2f2;
  --ods-bg-disabled: #e6e6e6;
  --ods-border-disabled: #cccccc;
  --ods-input-border: #b3b3b3;

  --ods-critical: #bf0020;

  --ods-outline-color: #000e9c;
  --ods-outline-width: 2px;
  --ods-outline-style: solid;
  --ods-outline-offset: 2px;

  --ods-shadow: 0 2px 8px rgba(0, 14, 156, 0.2);

  --ods-radius: 8px;
  --ods-border-width: 1px;
  --ods-gap: 8px;
  --ods-duration: 300ms;

  --ods-font: "Source Sans Pro", "Trebuchet MS", arial, "Segoe UI", sans-serif;
  --ods-font-code: "Source Code Pro", arial;
}

/* ----------------------------------------------------------------------------
   Page tokens — light. Mapped onto ODS values; only these switch per theme.
   Two are derived rather than quoted, because ODS documents no equivalent:
   --surface-sunken (page ground behind the cards) reuses the readonly grey,
   and --border reuses the input border, the only documented neutral stroke.
   -------------------------------------------------------------------------- */
:root {
  --surface: var(--ods-bg);
  --surface-sunken: var(--ods-bg-readonly);
  --surface-raised: var(--ods-bg);
  --surface-hover: #f7f7fa;
  --surface-code: var(--ods-bg-readonly);

  --text: var(--ods-text);
  --text-heading: var(--ods-heading);
  --text-muted: #6a719e;

  --link: var(--ods-anchor);
  --link-hover: var(--ods-anchor-hover);

  --border: var(--ods-border-disabled);
  --border-strong: var(--ods-input-border);
  /* neutral-100: the value ODS draws table cell borders and the neutral tag
     stroke with — one step quieter than border-disabled (neutral-200). */
  --border-soft: var(--ods-bg-disabled);

  --header-bg: var(--ods-brand);
  --header-fg: #ffffff;
  --header-hover: rgba(255, 255, 255, 0.14);
  --header-border: rgba(255, 255, 255, 0.35);
  --logo-filter: brightness(0) invert(1);

  --accent-soft: #e8ecff;
  --accent-soft-border: #c3cdff;

  --elevation: var(--ods-shadow);
  --elevation-hover: 0 4px 16px rgba(0, 14, 156, 0.28);

  /* One deliberate deviation from the verbatim ODS token above, declared here
     in the page-owned block instead of in it: everything monospace — commands,
     hostnames, tags, the connection details — is set in JetBrains Mono. It
     stays legible at the 11-13px those blocks run at (its zero and one are
     unambiguous) and ships as one variable file instead of a static face per
     weight. Declared once, outside the media query, so both
     themes get it; the ODS block itself stays verbatim. */
  --ods-font-code: "JetBrains Mono", ui-monospace, "SF Mono", menlo, monospace;
}

/* ----------------------------------------------------------------------------
   Page tokens — dark. EXTRAPOLATION, not ODS: the design system documents no
   dark theme. The brand hue (234deg) is kept and the blues are lifted in
   lightness so they hold contrast on a dark ground. The ODS tokens above are
   untouched; only the page mapping changes.
   -------------------------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --surface: #141829;
    --surface-sunken: #0c0f1c;
    --surface-raised: #1b2039;
    --surface-hover: #222846;
    --surface-code: #0c0f1c;

    --text: #c5cae6;
    --text-heading: #eef0fa;
    --text-muted: #9298bd;

    --link: #8aacff;
    --link-hover: #b3c8ff;

    --border: #2c3357;
    --border-strong: #3d4676;
    --border-soft: #262c52;

    --header-bg: var(--ods-brand);
    --header-fg: #ffffff;
    --header-hover: rgba(255, 255, 255, 0.14);
    --header-border: rgba(255, 255, 255, 0.35);
    --logo-filter: brightness(0) invert(1);

    --accent-soft: #1d2450;
    --accent-soft-border: #313b73;

    --elevation: 0 2px 8px rgba(0, 0, 0, 0.5);
    --elevation-hover: 0 4px 16px rgba(0, 0, 0, 0.6);
  }
}

:root[data-theme="dark"] {
  --surface: #141829;
  --surface-sunken: #0c0f1c;
  --surface-raised: #1b2039;
  --surface-hover: #222846;
  --surface-code: #0c0f1c;

  --text: #c5cae6;
  --text-heading: #eef0fa;
  --text-muted: #9298bd;

  --link: #8aacff;
  --link-hover: #b3c8ff;

  --border: #2c3357;
  --border-strong: #3d4676;
  --border-soft: #262c52;

  --header-bg: var(--ods-brand);
  --header-fg: #ffffff;
  --header-hover: rgba(255, 255, 255, 0.14);
  --header-border: rgba(255, 255, 255, 0.35);
  --logo-filter: brightness(0) invert(1);

  --accent-soft: #1d2450;
  --accent-soft-border: #313b73;

  --elevation: 0 2px 8px rgba(0, 0, 0, 0.5);
  --elevation-hover: 0 4px 16px rgba(0, 0, 0, 0.6);
}

/* Light forced — restate the light mapping so the toggle wins in both ways. */
:root[data-theme="light"] {
  --surface: var(--ods-bg);
  --surface-sunken: var(--ods-bg-readonly);
  --surface-raised: var(--ods-bg);
  --surface-hover: #f7f7fa;
  --surface-code: var(--ods-bg-readonly);

  --text: var(--ods-text);
  --text-heading: var(--ods-heading);
  --text-muted: #6a719e;

  --link: var(--ods-anchor);
  --link-hover: var(--ods-anchor-hover);

  --border: var(--ods-border-disabled);
  --border-strong: var(--ods-input-border);
  --border-soft: var(--ods-bg-disabled);

  --accent-soft: #e8ecff;
  --accent-soft-border: #c3cdff;

  --elevation: var(--ods-shadow);
  --elevation-hover: 0 4px 16px rgba(0, 14, 156, 0.28);
}

/* ============================================================================
   Base
   ========================================================================== */
* { margin: 0; padding: 0; box-sizing: border-box; }

/* Two columns: a fixed sidebar and the content. minmax(0, 1fr) rather than a
   bare 1fr, otherwise a wide child (a long command, a wide SVG) refuses to
   shrink and pushes the whole page into a horizontal scroll. */
body {
  font-family: var(--ods-font);
  background: var(--surface-sunken);
  color: var(--text);
  line-height: 1.5;
  min-height: 100vh;
  display: grid;
  grid-template-columns: 248px minmax(0, 1fr);
}

.content { min-width: 0; }

/* Horizontal gutter of the content column. It is a token rather than a literal
   because the column's padding has to stay in step with the cards inside it:
   the hero and the .panel below are both cards, and the gutter must move them
   together. One value, one place to change — including the narrow-screen
   override below. */
:root { --gutter: 24px; }

/* The gutter is paid by the column, not by the blocks inside it. It used to sit
   on .section / .footer only, which left .hero — itself a card, with no side
   gutter of its own — running edge to edge while the .panel below it was inset
   by 24px: the two cards shared neither width nor edges, and on any window
   under 1200px the hero touched the viewport while the test did not. */
main { padding: 0 var(--gutter); }

/* ODS: focus is a 2px solid brand outline at 2px offset, on every interactive
   element, and the mixin adds nothing else — no radius of its own (modern
   browsers already follow each element's own corners). Never removed. */
a:focus-visible,
button:focus-visible,
summary:focus-visible {
  outline: var(--ods-outline-width) var(--ods-outline-style) var(--ods-outline-color);
  outline-offset: var(--ods-outline-offset);
}

:root[data-theme="dark"] a:focus-visible,
:root[data-theme="dark"] button:focus-visible,
:root[data-theme="dark"] summary:focus-visible { outline-color: #8aacff; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) a:focus-visible,
  :root:not([data-theme="light"]) button:focus-visible,
  :root:not([data-theme="light"]) summary:focus-visible { outline-color: #8aacff; }
}

.sidebar a:focus-visible,
.sidebar button:focus-visible,
.sidebar summary:focus-visible { outline-color: #ffffff; }

/* ODS's Text component defines the heading presets (heading-1..6: 36/28/24/
   20/18/16px at weight 700 — sizes live in the component, there is no
   font-size token). This page climbs levels 2/4/6 (28/20/16px), which keeps
   the hero and section titles compact, and follows the spec on weight,
   colour and family; line-height stays ours (1.25, budgeted against the
   frame height below). */
h1, h2, h3 { color: var(--text-heading); font-weight: 700; line-height: 1.25; }
h1 { font-size: 28px; }
h2 { font-size: 20px; }
h3 { font-size: 16px; }

a { color: var(--link); text-decoration: none; }
a:hover { color: var(--link-hover); text-decoration: underline; }

code { font-family: var(--ods-font-code); }

/* ============================================================================
   Sidebar
   ========================================================================== */
.sidebar {
  background: var(--ods-brand);
  color: #ffffff;
  padding: 20px 16px;
  display: flex;
  flex-direction: column;
  gap: 24px;
  /* Its own scroll: the fleet list is long and must not drag the page with it. */
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100dvh;
  /* The sidebar itself does not scroll: logo, theme button and section links
     stay put, and only the fleet list moves. Otherwise anything below the ten
     servers falls under the fold on a shorter window. */
  overflow: hidden;
}

.sidebar-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.brand { display: block; }
.logo { height: 26px; filter: var(--logo-filter); display: block; }

/* Small subtitle under the OVHcloud logo, in the sidebar. */
.sidebar-subtitle {
  font-size: 13px;
  line-height: 1.3;
  color: rgba(255, 255, 255, 0.8);
  margin-top: -16px;
  padding: 0 10px;
}

.nav { display: flex; flex-direction: column; gap: 2px; }

.nav-link {
  color: #ffffff;
  font-size: 15px;
  text-decoration: none;
  padding: 8px 10px;
  border-radius: calc(var(--ods-radius) / 2);
  transition: background var(--ods-duration);
}

.nav-link:hover {
  background: var(--header-hover);
  color: #ffffff;
  text-decoration: none;
}

.side-label {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.65);
  margin-bottom: 8px;
  padding: 0 10px;
}

.servers {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  /* Each region is its own collapsible group now, so the scrollable box is
     the whole nav, not the (former) single flat ul. */
  overflow-y: auto;
}

.server-list {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* One details per region; see the template's proof_site_regions loop. */
.region {
  display: block;
}

.region-label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
  padding: 8px 10px 4px;
  cursor: pointer;
  border-radius: calc(var(--ods-radius) / 2);
  user-select: none;
  transition: color var(--ods-duration);
  /* The twisty is the inline SVG chevron, so the native marker goes - both
     the standards way and the WebKit way. */
  list-style: none;
}

.region-label::-webkit-details-marker { display: none; }

.region-label:hover { color: rgba(255, 255, 255, 0.85); }

.region-chev {
  flex: none;
  transition: transform var(--ods-duration);
}

.region[open] > .region-label .region-chev { transform: rotate(90deg); }

.server {
  display: block;
  padding: 7px 10px;
  border-radius: calc(var(--ods-radius) / 2);
  text-decoration: none;
  color: #ffffff;
  transition: background var(--ods-duration);
}

.server:hover { background: var(--header-hover); text-decoration: none; color: #ffffff; }

/* The server being viewed. aria-current carries the same meaning for screen
   readers, so the styling is keyed on it rather than on a spare class. */
.server[aria-current="page"] {
  background: rgba(255, 255, 255, 0.18);
  font-weight: 600;
}

.server-site { display: block; font-size: 14px; }

/* National flag rendered after the site name (see proof_site_flags in
   roles/proof/defaults/main.yml). */
.server-flag { margin-left: 4px; }

.server-host {
  display: block;
  font-family: var(--ods-font-code);
  font-size: 11px;
  color: rgba(255, 255, 255, 0.7);
}

.theme-toggle {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: var(--ods-border-width) solid var(--header-border);
  border-radius: calc(var(--ods-radius) / 2);
  color: #ffffff;
  font-family: var(--ods-font);
  font-size: 13px;
  padding: 6px;
  cursor: pointer;
  line-height: 0;
  transition: background var(--ods-duration);
}

.theme-toggle .icon {
  display: inline-block;
}

.theme-toggle:hover { background: var(--header-hover); }

/* ============================================================================
   Hero
   ========================================================================== */
.hero {
  max-width: 1200px;
  margin: 32px auto 0;
  padding: 24px;
  background: var(--surface-raised);
  border-radius: var(--ods-radius);
  box-shadow: var(--elevation);
}

.subtitle { color: var(--text-muted); font-size: 15px; margin-top: 8px; }
.subtitle code { color: var(--text); font-size: 14px; }

/* One compact row inside the hero. It sits above the test, so every pixel it
   takes comes out of the iframe's height budget below: keep it to a single
   line on a normal window. */
.connbar {
  margin-top: 16px;
  padding-top: 14px;
  border-top: var(--ods-border-width) solid var(--border);
  display: flex;
  flex-wrap: wrap;
  gap: 6px 28px;
}

.conn-item { display: flex; align-items: baseline; gap: 8px; }

.connbar dt {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
  white-space: nowrap;
}

.connbar dd {
  margin: 0;
  font-family: var(--ods-font-code);
  font-size: 13px;
  color: var(--text);
  /* An IPv6 address plus a cipher name has no space to break on. */
  overflow-wrap: anywhere;
}

/* Tiny "use IPv4" affordance shown only while the visitor is on IPv6. It is a
   link to the server's IPv4 literal, the only way to force a browser onto IPv4,
   and is deliberately small so the connection row stays one line. */
.conn-v4 {
  display: inline-block;
  margin-left: 8px;
  padding: 1px 7px;
  font-family: var(--ods-font-code);
  font-size: 11px;
  line-height: 1.5;
  color: var(--text-muted);
  text-decoration: none;
  border: var(--ods-border-width) solid var(--border);
  border-radius: 999px;
  background: transparent;
  white-space: nowrap;
  transition: color var(--ods-duration), border-color var(--ods-duration);
}
.conn-v4:hover {
  color: var(--text);
  border-color: var(--text-muted);
}
/* `display: inline-block` above would otherwise beat the `hidden` attribute
   (author styles outrank the UA's `[hidden] { display: none }`), so a visitor
   on IPv4 would still see the "use IPv4" pill. This restores the attribute's
   effect; the pill is only ever shown for IPv6 visitors. */
.conn-v4[hidden] { display: none; }

/* Same trap as the pill above: `.conn-item` declares `display: flex`, which
   outranks the UA's `[hidden] { display: none }`, so the "Network" item — which
   ships hidden and is revealed by js/main.js only when /rdns actually names a
   network — would sit in the row as an empty label otherwise. */
.conn-item[hidden] { display: none; }

/* The (i) that reveals the reverse DNS, and its tooltip. The address line used
   to print the PTR between brackets, which doubled the width of the widest item
   in a row that has a one-line budget; the value is now one hover/keyboard focus
   away and the row stays compact. */
.conn-info {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 15px;
  height: 15px;
  margin-left: 6px;
  padding: 0;
  font-family: var(--ods-font-code);
  font-size: 10px;
  font-weight: 600;
  line-height: 1;
  color: var(--text-muted);
  border: var(--ods-border-width) solid var(--border);
  border-radius: 50%;
  background: transparent;
  cursor: pointer;
  vertical-align: middle;
  transition: color var(--ods-duration), border-color var(--ods-duration);
}
.conn-info:hover,
.conn-info:focus-visible {
  color: var(--text);
  border-color: var(--text-muted);
}
/* Same trap as the pill: an explicit `display` outranks the UA's
   `[hidden] { display: none }`, so a visitor with no PTR would otherwise get an
   (i) that opens an empty tooltip. */
.conn-info[hidden] { display: none; }

.conn-tip {
  /* Anchored to the dd, which is the nearest positioned ancestor. */
  position: absolute;
  left: 0;
  bottom: calc(100% + 6px);
  z-index: 5;
  padding: 4px 8px;
  font-family: var(--ods-font-code);
  font-size: 12px;
  line-height: 1.4;
  color: var(--text);
  background: var(--surface);
  border: var(--ods-border-width) solid var(--border);
  border-radius: calc(var(--ods-radius) / 2);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
  white-space: nowrap;
  max-width: 60vw;
  overflow: hidden;
  text-overflow: ellipsis;
}
.conn-tip[hidden] { display: none; }

/* The dd is the tooltip's containing block; it stays inline-level so the row
   keeps its baseline alignment. */
.connbar dd { position: relative; }

/* Label for screen readers only (the (i) shows a glyph, not text). */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Attribution link for the IP-to-ASN database, in the page footer. Free ASN
   databases (DB-IP CC BY 4.0, IPLocate CC BY-SA 4.0, MaxMind GeoLite2) are
   licensed on visible attribution, so the credit travels with the data and is a
   real link to the provider, not a tooltip. Rendered by Ansible from
   proof_isp_source_* and only present when a source is configured — the honest
   reading of "no source, no credit, no data". */
.footer-attr {
  margin-top: 6px;
  font-size: 11px;
  color: var(--text-muted);
}
.footer-attr a { color: inherit; text-decoration: underline; }
.footer-attr a:hover { color: var(--text); }

/* ============================================================================
   Sections & cards
   ========================================================================== */
/* No side padding of its own: the column owns the gutter (see main above), so
   this box and .hero are the same width and the cards inside them line up. */
.section { max-width: 1200px; margin: 32px auto 0; }
.section > h2 { margin-bottom: 12px; }

.panel {
  background: var(--surface-raised);
  border-radius: var(--ods-radius);
  padding: 16px;
  box-shadow: var(--elevation);
}

/* Kept here rather than on a style="" attribute: an inline style outranks any
   stylesheet rule, so the responsive heights below could never apply.
   Height is tied to the viewport, not fixed: with a hard 420px the frame never
   shrank when the window got shorter, it just pushed the gauge past the fold
   (on a 500px-tall viewport only 205px of it was visible). The subtrahend is
   the chrome above it — header, hero, section heading, panel padding.
   dvh second so mobile browsers discount their retracting URL bar. */
.panel iframe {
  display: block;
  width: 100%;
  height: clamp(410px, calc(100vh - 330px), 620px);
  height: clamp(410px, calc(100dvh - 330px), 620px);
  border: none;
  background: var(--surface-raised);
}

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 16px;
}

/* Desktop: the "Other tools" cards are always shown, and the mobile server
   <select> is hidden (the filter + region list are the picker there). Both
   flip inside the narrow-width block below. */
.tools-toggle { display: none; }
.server-select-mobile { display: none; }

.card {
  background: var(--surface-raised);
  border-radius: var(--ods-radius);
  padding: 20px;
  text-decoration: none;
  color: inherit;
  box-shadow: var(--elevation);
  transition: box-shadow var(--ods-duration), background var(--ods-duration);
  /* A column so the interactive block (command / tags / ports) can be pushed
     to the bottom and line up across the row, however much the text above it
     wraps. A bare `display:block` lets each card keep its own height, which is
     how the "Other tools" row ended up with the curl command at three lines
     and its copy button at a different height than the iperf3 one. */
  display: flex;
  flex-direction: column;
}

/* The last block in a card sits at its bottom edge. Because the cards in a row
   share a height (grid stretch), this makes the three action rows align even
   though one command wraps to three lines, another to two, and the third card
   has no command at all. */
.card > :last-child {
  margin-top: auto;
}

/* Only the anchor cards are clickable, so only they get the hover affordance. */
a.card:hover {
  box-shadow: var(--elevation-hover);
  background: var(--surface-hover);
  text-decoration: none;
}

.card-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  margin-bottom: 8px;
}

.card p { font-size: 14px; color: var(--text-muted); }

/* ODS badge, sm: min-height 20px, 12px text with line-height 1, radius
   calc(radius/2) = 4px and no stroke — ODS badges are borderless chips.
   The colour pair stays on our pages tokens (--accent-soft/--link, the
   'information'-badge idea) rather than the raw information-100/900 pair,
   so the dark extrapolation keeps working. */
.badge {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  min-height: 20px;
  line-height: 1;
  font-size: 12px;
  font-weight: 600;
  padding: 4px;
  border-radius: calc(var(--ods-radius) / 2);
  background: var(--accent-soft);
  color: var(--link);
  white-space: nowrap;
}

.cmd {
  margin-top: 12px;
  font-family: var(--ods-font-code);
  font-size: 13px;
  background: var(--surface-code);
  padding: 8px 8px 8px 12px;
  border-radius: calc(var(--ods-radius) / 2);
  color: var(--text);
  border: var(--ods-border-width) solid var(--border);
  display: flex;
  /* Top-align the copy button against the command's first line. Centring
     (`align-items:center`) reads fine on a one-line command but leaves the icon
     floating in the middle of a wrapped one — the curl command here wraps to
     three lines, so a centred button hangs off the box's midline. `flex-start`
     keeps the icon on the first line in every case. */
  align-items: flex-start;
  gap: 8px;
}

/* The command text wraps (instead of being clipped/scrollable) so the full
   command is always readable. min-width:0 lets the flex child shrink within
   the grid track so a long URL never widens the page. */
.cmd-text {
  flex: 1 1 auto;
  min-width: 0;
  /* break-word (not `anywhere`): break only at a natural boundary (a space or
     a URL's `/`), never mid-token. `anywhere` is what split the curl command
     as `…/fi` / `les/100Mb.dat`, which read as two fragments. */
  overflow-wrap: break-word;
  white-space: normal;
  font-family: inherit;
  font-size: inherit;
  color: inherit;
}

.copy-btn {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  padding: 0;
  border: var(--ods-border-width) solid var(--border);
  border-radius: calc(var(--ods-radius) / 2);
  background: var(--surface-sunken);
  color: var(--text-muted);
  cursor: pointer;
  transition: color var(--ods-duration), border-color var(--ods-duration), background var(--ods-duration);
}

.copy-btn:hover {
  color: var(--link);
  border-color: var(--link);
  background: var(--surface-hover);
}

.copy-btn .icon-check { display: none; }

/* Confirmed: the copy button flips to a check. The green is ODS success-500
   (#2b8000), not an invented one. */
.copy-btn.copied {
  color: #2b8000;
  border-color: #2b8000;
  background: var(--surface-hover);
}

.copy-btn.copied .icon-copy { display: none; }
.copy-btn.copied .icon-check { display: block; }

.card .ports {
  margin-top: 6px;
  /* One step below the card description (.card p is 14px) and the tags/badges
     (12px): this is a footnote, not a headline, so it should read as the
     quietest line on the card rather than competing with the description. The
     selector is `.card .ports`, not `.ports`, because `.card p` (one class +
     one type, 0-1-1) would otherwise win the cascade and leave this at 14px —
     which is exactly the too-prominent look this is fixing. */
  font-size: 11px;
  color: var(--text-muted);
  font-family: var(--ods-font-code);
}

.tags { margin-top: 12px; display: flex; flex-wrap: wrap; gap: var(--ods-gap); }

/* ODS tag, md: 24px tall, 14px text with line-height 1, the 16px radius
   (--ods-border-radius-lg — ODS tags are pills) and twice the theme border
   width: the tag is defined by its 2px stroke. Kept off ODS's neutral-000/
   100/700 colour triad — those are light-theme hard values and the page
   keywords off themed page tokens instead. Font stays our code face, as
   everywhere else on these pages (documented 2026-09-09 deviation). */
.tag {
  display: inline-flex;
  align-items: center;
  min-height: 24px;
  padding: 1px 8px;
  font-family: var(--ods-font-code);
  font-size: 14px;
  line-height: 1;
  background: transparent;
  border: 2px solid var(--border-soft);
  border-radius: calc(var(--ods-radius) * 2);
  color: var(--text);
}

/* ============================================================================
   Table
   ========================================================================== */
/* Nothing in these pages is a <table> any more — the fleet list is
   disclosures + links and the downloads page is cards — so the old table
   styles are gone. If a table comes back, the ODS Table spec is the
   reference (packages/ods-react/src/style/_table.scss): width 100%,
   border-collapse: separate with 0 spacing, head on neutral-050 in the
   heading colour at weight 600, cell borders and the header outline on
   neutral-100, td on the text colour, sm/md/lg paddings of 8/16/24px
   vertical against 8px horizontal with 32/48/64px row min-heights, and
   optional striped rows on neutral-050. ODS audited at master 5c29f6f9
   (2026-09-10 alignment pass).

   ============================================================================
   Footer
   ========================================================================== */
.footer {
  max-width: 1200px;
  margin: 40px auto 32px;
  color: var(--text-muted);
  font-size: 13px;
}

/* A URL has no break opportunity, so a 41-character one overflows the body
   below ~344px and gives the whole page a horizontal scrollbar. */
.footer code { font-size: 13px; overflow-wrap: anywhere; }

/* ============================================================================
   Responsive
   ========================================================================== */

/* Below this a 248px column costs more than it gives: the sidebar unfolds into
   a bar across the top, and the fleet list becomes a horizontal scroller so ten
   servers do not push the content off the screen. */
@media (max-width: 900px) {
  body { grid-template-columns: minmax(0, 1fr); }

  .sidebar {
    position: static;
    height: auto;
    overflow: visible;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    gap: 12px;
    padding: 12px 16px;
  }

  .nav { flex-direction: row; flex-wrap: wrap; }
  /* The region labels stay on small screens even though the other sidebar
     captions go: they are the taps that open the collapsed groups there. */
  .side-label, .sidebar-subtitle { display: none; }
  .servers { flex: 1 1 100%; min-width: 0; }

  /* A phone gets the compact native <select> instead of the filter + region
     list: the region list is a long horizontal scroll, and the "Other tools"
     commands are not usable on a phone anyway. The select is grouped by region
     and hidden on desktop. */
  .server-select-mobile { display: block; margin: 0 0 6px; }
  .server-select-mobile select {
    width: 100%;
    font-family: var(--ods-font);
    font-size: 16px; /* iOS: no auto-zoom on focus */
    color: #ffffff;
    background: var(--surface-sunken, #0b1030);
    border: 1px solid var(--header-border);
    border-radius: calc(var(--ods-radius) / 2);
    padding: 8px 10px;
  }
  .server-search, .server-search-empty, .servers .region { display: none; }

  h1 { font-size: 22px; }
  h2 { font-size: 18px; }
  /* minmax(0, 1fr), not a bare 1fr: a grid item's automatic minimum size is its
     min-content. Long content (a command, a table, a URL) must not inflate the
     single track past the viewport and give phones a horizontal scrollbar.
     Same reason body uses minmax(0, 1fr). Commands wrap inside their card, so
     the .cmd box no longer scrolls or widens the page. */
  .grid { grid-template-columns: minmax(0, 1fr); }

  /* The "Other tools" cards (curl, iperf3, network diagnostics) are desktop
     commands a phone cannot run, so they fold behind a small toggle. */
  .tools-toggle {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    margin: 0 0 12px;
    padding: 6px 10px;
    border: 1px solid var(--header-border);
    border-radius: calc(var(--ods-radius) / 2);
    background: none;
    color: #ffffff;
    font-family: inherit;
    font-size: 12px;
    cursor: pointer;
    transition: background var(--ods-duration);
  }
  .tools-toggle:hover { background: var(--header-hover); }
  .tools-grid:not(.tools-open) { display: none; }
}

/* OpenSpeedTest picks its .Desktop or .Mobile layout from `orientation`,
   evaluated against the *iframe's* box rather than the phone's. A short,
   full-width frame reads as landscape and would serve the desktop UI crushed
   onto a phone, so force the frame portrait here. */
@media (max-width: 600px) {
  .panel iframe {
    height: auto;
    aspect-ratio: 3 / 4;
    /* A 3/4 box on a narrow *and* short screen would be taller than the
       viewport, so cap it — the frame goes back to landscape and
       OpenSpeedTest serves its desktop layout, which is the right call on a
       phone held sideways. */
    max-height: calc(100dvh - 220px);
    min-height: 260px;
  }
}

@media (max-width: 480px) {
  .sidebar { padding: 10px 12px; }
  .hero { padding: 16px; margin-top: 16px; }
  /* One override moves every block in the column, cards included. */
  :root { --gutter: 12px; }
  h1 { font-size: 20px; }
  .cmd { font-size: 12px; }
}

/* ---------------------------------------------------------------------------
   Short viewports. Width is only half the story: a window dragged short, or a
   phone in landscape, has the same content to fit in far less room. Everything
   above the frame gives some height back. The sidebar layout already helps —
   it costs no vertical space at all above 900px.
   ------------------------------------------------------------------------ */
@media (max-height: 620px) {
  .panel iframe { height: clamp(410px, calc(100dvh - 240px), 480px); }
  .hero { margin-top: 16px; padding: 16px; }
  h1 { font-size: 22px; }
  .section { margin-top: 20px; }
  .section > h2 { margin-bottom: 8px; }
  .panel { padding: 10px; }
  .footer { margin: 24px auto 16px; }
}

@media (max-height: 460px) {
  .panel iframe { height: clamp(330px, calc(100dvh - 200px), 410px); }
  .hero { margin-top: 12px; padding: 12px; }
  h1 { font-size: 20px; }
  .subtitle { font-size: 13px; margin-top: 4px; }
  .connbar { margin-top: 10px; padding-top: 10px; gap: 4px 18px; }
  .section { margin-top: 16px; }
  /* In the stacked layout the bar is in flow and scrolls away on its own. */
  .sidebar { position: static; height: auto; }
}

/* Auto server selection spinner - shown while probing latency to all servers */
.auto-select-spinner {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface);
  z-index: 1000;
}

.spinner-content {
  text-align: center;
}

.spinner-ring {
  width: 48px;
  height: 48px;
  border: 4px solid var(--border);
  /* --ods-anchor, not --ods-brand: the ring spins on --surface, and the brand
     navy is #000e9c in both skins (it is a brand token, never re-themed), which
     on the dark card measured a hair over 1.5:1 — an invisible ring. --link is
     the one blue this stylesheet does theme. */
  border-top-color: var(--link);
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 16px;
}

/* The overlay covers the page for as long as the probe takes (a couple of
   seconds on the far ends of the fleet), so a spinning ring is exactly the
   motion prefers-reduced-motion asks us to drop. The overlay itself stays —
   it is also the "do not navigate away yet" signal — it just stops spinning. */
@media (prefers-reduced-motion: reduce) {
  .spinner-ring { animation: none; border-top-color: var(--link); }
  .region-chev { transition: none; }
}

/* ---------------------------------------------------------------------------
   Closest-server UI — the banner a redirect leaves behind, the filter, and the
   button that asks for a measurement. All three are inert without JS, so the
   button and the banner ship `hidden` from the template and js/main.js reveals
   them; the [hidden] rules below are not decoration.

   `hidden` is a UA rule of `display: none` with no specificity to it, so the
   moment we give an element a display of our own, hidden stops meaning
   anything. Every element here that gets a layout rule has to restate it.
   ------------------------------------------------------------------------ */
.banner {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  max-width: 1200px;
  margin: 0 0 20px;
  padding: 12px 14px;
  border: 1px solid var(--accent-soft-border);
  border-left: 3px solid var(--link);
  border-radius: var(--ods-radius);
  background: var(--accent-soft);
  color: var(--text);
  font-size: 14px;
}

.banner[hidden] { display: none; }
.banner-icon { flex: none; margin-top: 2px; color: var(--link); }
.banner-text { flex: 1 1 auto; }
.banner-text strong { color: var(--text-heading); }
.banner-from { color: var(--text-muted); }
.banner-close {
  flex: none;
  border: 0;
  background: none;
  color: var(--text-muted);
  font-size: 20px;
  line-height: 1;
  padding: 0 4px;
  cursor: pointer;
  border-radius: calc(var(--ods-radius) / 2);
}
.banner-close:hover { color: var(--text); background: var(--surface-hover); }

.server-search {
  width: 100%;
  margin: 6px 0 2px;
  padding: 7px 9px;
  /* ODS form element: 1px neutral stroke and a 32px minimum height
     (--ods-theme-input-*). The border colour is an extension: this input
     sits on the brand navy, where neutral-300 is invisible, so it takes the
     chrome stroke, and the focus outline colour is the themed link blue for
     the same reason. */
  min-height: 32px;
  border: 1px solid var(--header-border);
  border-radius: calc(var(--ods-radius) / 2);
  background: rgba(0, 0, 0, 0.18);
  color: #ffffff;
  font-family: inherit;
  font-size: 13px;
}

.server-search::placeholder { color: rgba(255, 255, 255, 0.6); }
.server-search:focus-visible { outline: var(--ods-outline-width) solid #8aacff; outline-offset: var(--ods-outline-offset); }

/* The filter hides the non-matching <li> and whole regions, and the empties are
   the only feedback a text input can give. Same [hidden] caveat as above. */
.server-list > li[hidden],
.region[hidden],
.server-search-empty[hidden] { display: none; }

.server-search-empty {
  margin: 4px 10px 0;
  font-size: 12px;
  color: rgba(255, 255, 255, 0.7);
}

.closest { flex: none; padding-top: 4px; }

.closest-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  padding: 7px 10px;
  border: 1px solid var(--header-border);
  border-radius: calc(var(--ods-radius) / 2);
  background: none;
  color: #ffffff;
  font-family: inherit;
  font-size: 12px;
  text-align: left;
  cursor: pointer;
  transition: background var(--ods-duration);
}

/* Rendered hidden until js/main.js runs: with no script the button would be a
   promise nothing keeps. */
.closest-btn[hidden] { display: none; }
.closest-btn:hover:not(:disabled) { background: var(--header-hover); }
.closest-btn:disabled { opacity: 0.6; cursor: progress; }
.closest-icon { flex: none; }

.closest-result {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 6px 10px 0;
  font-size: 12px;
  color: rgba(255, 255, 255, 0.75);
  min-height: 1em; /* the aria-live line must not move the list when it speaks */
}
.closest-result[hidden] { display: none; }
.closest-result-text { min-width: 0; }

/* Pastel-green confirmation when the current host is already the closest — the
   one state that is a verdict, not an offer, so it reads as a success pill
   rather than plain text. It sits on the navy sidebar, so the pill is light
   with dark text (the reverse of the surrounding chrome). The colours are the
   ODS success badge pair, success-100/900 (#d2f2c2 / #113300), not colours
   invented for here. */
.closest-result--ok {
  display: inline-flex;
  background: #d2f2c2;
  color: #113300;
  border-radius: calc(var(--ods-radius) / 2);
  padding: 4px 8px;
  font-weight: 600;
}

/* The dismiss control that lets a visitor clear the pill instead of waiting for
   it to time out. It stays quiet: a small x in the pill's own green, growing
   slightly on hover. */
.closest-result-close {
  flex: none;
  border: 0;
  background: none;
  color: #113300;
  font-size: 14px;
  line-height: 1;
  padding: 0 2px;
  cursor: pointer;
  border-radius: 50%;
}
.closest-result-close[hidden] { display: none; }
.closest-result-close:hover { background: rgba(17, 51, 0, 0.12); }

/* Tiny "Move to X" button, shown only when a different host is clearly closer.
   It is deliberately small and quiet: the probe result is an offer, and the
   move is the visitor's decision. */
.closest-move {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  margin: 6px 10px 0;
  padding: 5px 9px;
  border: 1px solid var(--header-border);
  border-radius: calc(var(--ods-radius) / 2);
  background: none;
  color: #ffffff;
  font-family: inherit;
  font-size: 12px;
  cursor: pointer;
  transition: background var(--ods-duration);
}
.closest-move[hidden] { display: none; }
.closest-move:hover { background: var(--header-hover); }

@keyframes spin {
  to { transform: rotate(360deg); }
}

.auto-select-spinner p {
  margin: 0;
  color: var(--text);
  font-size: 14px;
}

/* ---------------------------------------------------------------------------
   Touch devices - keyed on the pointer, not the width: an iPad is wide and a
   foldable can be narrow, but both are tapped. Everything here is finger
   ergonomics a mouse user never notices. Placed last on purpose: media
   queries carry no specificity, so source order is what wins against the
   width-based blocks above.
   ------------------------------------------------------------------------ */
@media (pointer: coarse) {
  /* 26px is a mouse pointer's target, not a thumb's; guidelines ask for 40px
     and the button's light chrome means growing it stays visually quiet. */
  .copy-btn { width: 38px; height: 38px; }

  /* Same 40px-ish floor for everything tapped in the sidebar bar: strip
     chips (single line there since .server-host hides), nav links, the
     region disclosures, the theme toggle. */
  .server { padding: 9px 12px; }
  .nav-link { padding: 10px; }
  .region-label { padding: 11px 10px 7px; }
  .theme-toggle { padding: 9px; }
  .closest-btn { padding: 10px 12px; font-size: 13px; }
  .closest-move { padding: 8px 10px; font-size: 13px; }

  /* The server filter is a form control, and iOS zooms on any focused field
     under 16px and does not zoom back out — the same trap the server picker
     above avoids. */
  .server-search { font-size: 16px; padding: 9px 10px; }
}
