/* ============================================================================
   Web fonts — Source Sans Pro (ODS's family) and JetBrains Mono, self-hosted
   (fonts/README.md): a throughput-measuring page 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 light-theme values, copied
   verbatim from the ODS docs and never 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 (--surface-sunken reuses the readonly grey, --border the
   input border) because ODS documents no equivalent.
   -------------------------------------------------------------------------- */
: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, declared in the page block so the ODS block stays
       verbatim: everything monospace is JetBrains Mono — legible at the 11-13px
       those blocks run at, one variable file instead of a static face per weight. */
  --ods-font-code: "JetBrains Mono", ui-monospace, "SF Mono", menlo, monospace;
}

/* ----------------------------------------------------------------------------
   Page tokens — dark. EXTRAPOLATION, not ODS (no dark theme is documented):
   the brand hue (234deg) is kept, blues lifted in lightness to hold contrast.
   ODS tokens above stay 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 (no font-size token). This
   page climbs levels 2/4/6 (28/20/16px) to keep hero and section titles
   compact; line-height stays ours (1.25, budgeted against the frame 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;
}

/* Logo + page name as one block, so the theme toggle is always the last thing
   in the row and `space-between` pins it to the sidebar's right edge. Before
   this wrapper the row read logo, toggle, subtitle: the toggle sat between the
   two, and the subtitle's negative margin — the hack that used to pull it up
   under the logo — could not reach past it. */
.sidebar-identity {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  min-width: 0;
}

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

/* Small subtitle under the OVHcloud logo, in the sidebar. Its own margin and
   padding are gone: it is now a child of .sidebar-identity, so the 6px gap
   there is what spaces it from the logo (the old -16px margin only existed to
   cancel the sidebar's 24px gap when it was a sibling). */
.sidebar-subtitle {
  font-size: 13px;
  line-height: 1.3;
  color: rgba(255, 255, 255, 0.8);
  margin: 0;
  padding: 0;
}

.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 speedtest_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 speedtest_site_flags in
   roles/speedtest/defaults/main.yml). */
.server-flag { margin-left: 4px; }

/* The row shows city and capacity only: a 34-character public name cannot fit
   this column, and wrapping it read as two different machines. The address
   travels in the href, the row's title, and a visually-hidden span — what the
   filter matches (it searches the li's textContent) and a screen reader reads. */

/* The server's access-link capacity, from link_capacity in the inventory (a
   static ordering fact). Rendered only when the host declares a value — no
   fact, no chip, never a wrong one. Quiet spec tag at the right edge of the
   row, monospace because it is a measurement token; absolute-positioned so the
   row keeps its single line.

   Gutter from real measurements (probe_chip.py, 11px JetBrains Mono): the label
   spells the unit out (`20Gb/s`, not `20G` — bare G left visitors guessing
   bits vs bytes on a download page), so the row reserves 70px, which clears
   even `100Gb/s` with 2px to spare. */
.server { position: relative; padding-right: 70px; }
.server-cap {
  position: absolute;
  right: 8px;
  top: 50%;
  transform: translateY(-50%);
  box-sizing: border-box;
  min-height: 18px;
  padding: 3px 6px;
  line-height: 1;
  font-family: var(--ods-font-code);
  font-size: 11px;
  font-weight: 600;
  color: rgba(255, 255, 255, 0.92);
  border: 1px solid var(--header-border);
  border-radius: calc(var(--ods-radius) / 2);
  white-space: nowrap;
}

.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;
}

/* `position: relative` is the containing block for the item's floating tooltip
   (.conn-tip): the tip is positioned against the item, so it lines up with the
   label it belongs to and never contributes to the item's height. */
.conn-item { display: flex; align-items: baseline; gap: 8px; position: relative; }

.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;
}

/* The "Fallback to IPv4" pill, only while the visitor is on IPv6: a plain link
   to this server's A-only ipv4.<dc> alias — the only way to force a browser
   back onto IPv4. Deliberately tiny, and it sits with the "IP version" value it
   belongs to rather than as an item of its own. */
.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 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 "Protocol" and
   "Network" items — which ship hidden and are revealed by js/main.js only once
   their values exist — would sit in the row as empty labels otherwise. */
.conn-item[hidden] { display: none; }

/* The (i) that reveals the reverse DNS or the cipher suite, 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 link: an explicit `display` outranks the UA's
   `[hidden] { display: none }`, so a visitor with no PTR (or no cipher) would
   otherwise get an (i) that opens an empty tooltip. */
.conn-info[hidden] { display: none; }

.conn-tip {
    /* Floating, below its trigger: the row keeps its one-line budget whether the
       tip is open or not (the in-flow version grew the hero, and every pixel of
       the hero comes out of the iframe's height below). Downward matters — the
       first attempt pointed up from a bar near the top of the page and vanished
       behind the sticky chrome. Positioned against .conn-item; js/main.js nudges
       it back inside the viewport near an edge. */
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 20;
  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);
  /* Sized to its content and bounded by the viewport, not by its parent: a
     cipher suite is 20+ characters with no break opportunity, and the
     containing block is one line wide — `max-width: 100%` clipped it (or, with
     nowrap, let it run off the card). It breaks anywhere instead. */
  width: max-content;
  max-width: min(90vw, 32rem);
  overflow-wrap: anywhere;
  /* Out of the pointer's way. The tip overlays whatever sits below the row (the
     test panel), and it must not swallow a click meant for the page; it also
     cannot be hovered *into* — moving off the item closes it, which is what a
     hover/focus tip should do. */
  pointer-events: none;
}
.conn-tip[hidden] { display: none; }

/* 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 footer: free ASN databases
   are licensed on VISIBLE attribution, so the credit travels with the data as a
   real link, rendered from speedtest_isp_source_* and only present when a source is
   configured — 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 a style="" attribute: an inline style outranks any
   stylesheet rule, so the responsive heights below could never apply. Height is
   tied to the viewport (a hard 420px pushed the gauge past the fold on a short
   window); the subtrahend is the chrome above it. dvh second so mobile
   browsers discount the 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 a card's blocks stack in a predictable order. Not used to pin
       the last block to the bottom any more: the three cards share a height, and
       pushing the shorter cards' last block down opened a hole that read as two
       disconnected halves. Unused height stays at the bottom edge. */
  display: flex;
  flex-direction: column;
}

.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: 6px 6px 6px 12px;
  border-radius: calc(var(--ods-radius) / 2);
  color: var(--text);
  border: var(--ods-border-width) solid var(--border);
  display: flex;
    /* Centre the command in the box: the box is as tall as its tallest child (the
       38px copy button on touch), so a top-aligned command left a band of empty
       box under a one-line command. Centred, it still reads correctly wrapped. */
  align-items: center;
  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; }

/* The way back from /files/ to the speed test. An ODS-style ghost button —
   the same shape as .closest-btn in the sidebar, on the page's own tokens
   because this one sits on a card rather than on the navy bar. */
.back-btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
  padding: 7px 12px;
  border: 1px solid var(--border);
  border-radius: calc(var(--ods-radius) / 2);
  background: none;
  color: var(--link);
  font-size: 14px;
  font-weight: 600;
  text-decoration: none;
  transition: background var(--ods-duration), border-color var(--ods-duration);
}
.back-btn:hover {
  background: var(--surface-hover);
  border-color: var(--link);
  text-decoration: none;
}
.back-btn .icon-back { flex: none; }

/* The download card's pointer to the rest of the sizes. Quiet, like .ports:
   a footnote to the command above it, not a second description. */
.card .card-more {
  margin-top: 12px;
  font-size: 13px;
  color: var(--text-muted);
}

.card .ports {
  margin-top: 6px;
    /* One step below the card description (14px) and the tags (12px): a footnote,
       not a headline. The selector is `.card .ports`, not `.ports` — otherwise
       `.card p` wins the cascade and leaves this at 14px. */
  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/1 text, the 16px radius and twice the theme
   border width — the tag is defined by its 2px stroke. Keywords off themed page
   tokens rather than ODS's light-theme hard values; font stays the code face. */
.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, so the old table styles are
   gone; if one comes back, the ODS Table spec is the reference
   (packages/ods-react/src/style/_table.scss).

   ============================================================================
   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;
  }

  /* The bar's first row: logo + page name together on the left, theme toggle
     against the right edge. flex-basis 100% is what gives the row the bar's
     full width — shrink-wrapped, space-between has nothing to push against. */
  .sidebar-top { flex: 1 1 100%; }
  /* On the bar the two sit side by side, close together, instead of stacked. */
  .sidebar-identity { flex-direction: row; align-items: center; gap: 10px; }
  /* The region labels stay on small screens even though the "Tools"/"Servers"
     captions go: they are the taps that open the collapsed groups there. The
     subtitle stays too — it names the page, next to the logo. */
  .side-label { 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 */
        /* Two traps: the text was chrome white but the ground read var(--surface-sunken),
           which flips with the theme — white option text opened on near-white in light
           mode. The sidebar is navy in BOTH themes, so pin the control to chrome like
           every other control in the bar; and the opened list is an OS popup styled
           from color-scheme, so the dark scheme keeps it dark in both themes. */
    color: var(--header-fg);
    background: var(--header-bg, #0b1030);
    color-scheme: dark;
    border: 1px solid var(--header-border);
    border-radius: calc(var(--ods-radius) / 2);
    padding: 8px 10px;
  }
  /* Engines that paint the popup's own rows from the <option>/<optgroup>
     elements take the same chrome pair. Safari builds its popup natively and
     ignores both rules, which stays legible. */
  .server-select-mobile select option,
  .server-select-mobile select optgroup {
    color: #ffffff;
    background: var(--header-bg, #0b1030);
  }
  .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;
    /* Page tokens, not the chrome pair. The toggle sits in the content column
       (--surface-raised over --surface); the header tokens are the navy
       sidebar's. It used `color: #ffffff` with `background: none`, so in light
       mode the label was white on the light card and only the border showed
       ("the show more tools button is not visible in light mode", 2026-09-11). */
    border: 1px solid var(--border);
    border-radius: calc(var(--ods-radius) / 2);
    background: var(--surface-raised);
    color: var(--text);
    font-family: inherit;
    font-size: 12px;
    cursor: pointer;
    transition: background var(--ods-duration);
  }
  .tools-toggle:hover { background: var(--surface-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: a window dragged short, or a phone in landscape, has the
   same content in far less room. Everything above the frame gives some height
   back; the sidebar layout costs no vertical space above 900px anyway.
   ------------------------------------------------------------------------ */
@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
   measurement button. All three are inert without JS, so button and banner ship
   `hidden` and js/main.js reveals them; the [hidden] rules below are not
   decoration. `hidden` is a UA display:none with no specificity, so the moment
   an element gets a display of its own, hidden stops meaning anything — every
   element here with a layout rule must 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 that the current host is already the closest — the
   one state that is a verdict, not an offer, so it reads as a success pill.
   Light pill with dark text on the navy sidebar; the colours are the ODS
   success badge pair (success-100/900), not colours invented 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. Finger ergonomics a mouse user
   never notices. Placed last on purpose: media queries carry no specificity, so
   source order 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: the strip
     chips, the region disclosures, the theme toggle. */
  .server { padding: 9px 56px 9px 12px; }
  .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; }
}

/* ---------------------------------------------------------------------------
   Bar layout, again — the two phone bugs, part two. Sits after the desktop
   rules so source order wins (media queries carry no specificity).

   In the bar's flex row .closest keeps flex: none, so once the probe speaks its
   widest content is a whole sentence, not the button label — and width: 100%
   stretched the button to 515px on a 390px screen. The group gets its own full
   row, the verdict wraps inside it, and the button is width: auto — sized by
   its label — so running the probe leaves its own button alone.
   ------------------------------------------------------------------------ */
@media (max-width: 900px) {
  .closest { flex: 1 1 100%; padding-top: 0; }
  .closest-btn { width: auto; }
  .closest-result { flex-wrap: wrap; }
  .closest-result-text { overflow-wrap: anywhere; }
  /* The success pill is inline-flex and shrinks to its text; cap it inside the
     row and let the sentence wrap instead of pushing the bar sideways. */
  .closest-result--ok { max-width: 100%; }
}
