/* ============================================================================
   Web fonts: Source Sans Pro (ODS's family) and JetBrains Mono, self-hosted
   (fonts/README.md). A page that measures throughput 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 come from the Text component rather
   than a token, and its presets all carry 700, so the page has to be able to serve
   it. 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. 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: the canonical light-theme values, copied
   verbatim from the ODS docs and never redefined per theme. They are the brand and
   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 is 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, which stays legible at the
           11-13px those blocks run at, and one variable file replaces a static face per
           weight. */
  --ods-font-code: "JetBrains Mono", ui-monospace, "SF Mono", menlo, monospace;
}

/* ----------------------------------------------------------------------------
   Page tokens, dark. This is an EXTRAPOLATION and not ODS, since no dark theme is
   documented: the brand hue (234deg) is kept and the blues are lifted in lightness
   to hold contrast. The 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, because 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 and not 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 moves them together. One value,
   one place to change, including the narrow-screen override below. */
:root { --gutter: 24px; }

/* The column pays the gutter, not the blocks inside it. It used to sit on .section
   and .footer only, which left .hero, itself a card with no side gutter, running
   edge to edge while the .panel below was inset by 24px. The two cards shared
   neither width nor edges, and 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, so there is no radius of its own because
   modern browsers already follow each element's 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 and no font-size token. This page
   climbs levels 2/4/6 (28/20/16px) to keep the 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); }

/* Bypass block (WCAG 2.4.1). The sidebar's logo, theme toggle, filter, ten servers
   and the closest-server button come first in the tab order, so on a desktop the
   test itself is fourteen tabs away. Off screen until it takes focus, which is the
   only moment it is useful, and wearing the chrome pair so it reads over whatever it
   lands on. */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
}
.skip-link:focus {
  left: 8px;
  top: 8px;
  padding: 8px 14px;
  background: var(--header-bg, #0b1030);
  color: #ffffff;
  border-radius: calc(var(--ods-radius) / 2);
  font-weight: 600;
  text-decoration: none;
  outline: var(--ods-outline-width) solid #8aacff;
  outline-offset: var(--ods-outline-offset);
}
/* The skip target is a container and not a control: it takes focus (tabindex -1)
   without painting a ring around the whole page. */
main:focus { outline: none; }

/* ============================================================================
   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: the 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 and 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: 30px; filter: var(--logo-filter); display: block; }

/* Small subtitle under the OVHcloud logo, in the sidebar. Its own margin and padding
   are gone, because it is now a child of .sidebar-identity and the 6px gap there
   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 and 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); }

/* A datacenter split into availability zones (type=3az in the inventory): one row that
   expands into its zones. The parent is a label and not a server, so it has no badge
   and no address, but it is styled like a row because that is what a visitor reads it
   as. The chevron sits at the right edge, where a row's capacity chip is, and turns
   down when the group opens. The zones keep the ordinary row markup, indented behind a
   hairline so the hierarchy is visible without a second font size. */
.dc-label {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 7px 10px;
  border-radius: calc(var(--ods-radius) / 2);
  color: #ffffff;
  cursor: pointer;
  user-select: none;
  transition: background var(--ods-duration);
}

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

.dc-label:hover { background: var(--header-hover); }

.dc-chev {
  margin-left: auto;
  flex: none;
  transition: transform var(--ods-duration);
}

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

.dc > .server-list {
  margin-left: 10px;
  padding-left: 8px;
  border-left: 1px solid rgba(255, 255, 255, 0.12);
}

.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, which is what the filter matches
   (it searches the li's textContent) and what a screen reader reads. */

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

   The gutter comes from real measurements (probe_chip.py, 11px JetBrains Mono). The
   label spells the unit out (`20Gb/s`, not `20G`, because bare G left visitors
   guessing bits against 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; }
/* The two hero paragraphs read as one block: the second follows the first at a
   hair's distance instead of the full paragraph margin. */
.subtitle + .subtitle { margin-top: 2px; }
.subtitle code { color: var(--text); font-size: 14px; }
/* The city in the hero's "you're currently connected to X" line. It replaced a
   <code> hostname there, since a sentence is no place for a 40-character FQDN (§10),
   so it keeps the emphasis the monospace gave it: weight and ink, not a font. */
.subtitle strong { color: var(--text); font-weight: 600; }

/* 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 adds 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, shown 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, because
   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 }`. The "Protocol" and "Network" items ship
   hidden and js/main.js reveals them once their values exist, so they 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 or 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 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, because 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, and 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 rather than by its parent: a
       cipher suite is 20+ characters with no break opportunity and the containing block
       is one line wide, so `max-width: 100%` clipped it, and with nowrap it ran 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 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 and 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. It renders from speedtest_isp_source_* and is present only 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 in a style="" attribute, because an inline style outranks any
   stylesheet rule and the responsive heights below could never apply. The height is
   tied to the viewport, since a hard 420px pushed the gauge past the fold on a short
   window, and the subtrahend is the chrome above it. dvh comes 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;
}

/* The file cards carry a copyable command, and a 280px track leaves the box too
   narrow for it: `curl -O https://<host>/files/1Mb.dat` broke after the host, so the
   card was three lines tall for two lines of content. A 340px floor makes the first
   line fit, which is one line on a wide window and two on a narrow one: the same
   3-up grid at 1200px and 2-up at 1000px. */
.grid--files { grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); }

/* The one thing the file page never said: -O writes the file to disk, and the 10 GiB
   one is a footgun. Quiet, above the list, where the commands are read. */
.files-hint {
  margin: 0 0 16px;
  font-size: 13px;
  color: var(--text-muted);
}
.files-hint code { font-size: 12px; }

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

/* The phone picker: a trigger that stands in for the native control (revealed by
   js/main.js) plus a panel holding the SAME filter and region list the desktop
   sidebar shows, so one rendering keeps the two lists from drifting. display: contents
   keeps the wrapper out of the desktop layout entirely. */
.server-picker-btn { display: none; }
.server-picker-panel { display: contents; }

.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. It is no longer used
           to pin the last block to the bottom: 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, because ODS badges are borderless chips. The
   colour pair stays on our page 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 when it wraps. */
  align-items: center;
  gap: 8px;
}

/* The command text wraps instead of being clipped or scrollable, so the full command
   is always readable. min-width:0 lets the flex child shrink inside the grid track, so
   a long URL never widens the page. */
.cmd-text {
  flex: 1 1 auto;
  min-width: 0;
    /* break-word and not `anywhere`: break only at a natural boundary, a space or a
       URL's `/`, and 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) and 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 and
           not a headline. The selector is `.card .ports` and not `.ports`, or `.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, since the tag is defined by its 2px stroke. Keywords off themed page tokens
   rather than ODS's light-theme hard values, and the 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
   about 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 and 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,
       since it names the page next to the logo. */
  .side-label { display: none; }
    /* overflow: visible is load-bearing. The nav scrolls on desktop because the list is
       long, and a scroll container CLIPS the panel that hangs below it: the panel was laid
       out at the right place and painted nowhere (measured: absent from
       elementsFromPoint, present in the layout). */
  .servers { flex: 1 1 100%; min-width: 0; position: relative; overflow: visible; }

    /* A phone gets a picker instead of the desktop filter and region list. The trigger
       names the server this page is served by (city, flag, capacity, never the FQDN,
       which pushed the control into an ellipsis), and the panel that drops below it is
       that same filter and region list. The native <select> above stays as the no-JS
       form: js/main.js hides it and reveals the trigger, so a script-less phone keeps a
       working control. */
  .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 while the ground read
               var(--surface-sunken), which flips with the theme, so 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, ignores both rules, and
       stays legible. */
  .server-select-mobile select option,
  .server-select-mobile select optgroup {
    color: #ffffff;
    background: var(--header-bg, #0b1030);
  }

  /* The trigger carries the chrome pair the select had, so the swap is invisible. The
     chip rides in flow here, where a list row pins it absolute. */
  .server-picker-btn {    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    margin: 0 0 6px;
    padding: 8px 10px;
    font-family: var(--ods-font);
    font-size: 16px; /* iOS: no auto-zoom on focus */
    color: var(--header-fg);
    background: var(--header-bg, #0b1030);
    border: 1px solid var(--header-border);
    border-radius: calc(var(--ods-radius) / 2);
    text-align: left;
    cursor: pointer;
    transition: background var(--ods-duration);
  }
  .server-picker-btn[hidden] { display: none; }
    /* js/main.js adds picker-on when it wires the trigger, and the native control steps
       aside there. A class and not the [hidden] attribute, because the rule above sets
       display: block on this label and an author rule beats the UA's [hidden]: so
       `label.hidden = true` silently did nothing and the bar carried BOTH controls, the
       select still showing and the trigger stacked on it. */
  .servers.picker-on .server-select-mobile { display: none; }  .server-picker-btn:hover { background: var(--header-hover); }
  .server-picker-btn .server-site { font-size: 15px; }
  .server-picker-btn .server-cap { position: static; transform: none; margin-left: auto; }
  .server-picker-chev { flex: none; }

    /* The panel: the sidebar's own list, dropped under the bar and over the content. Only
       the wrapper turns on, so the filter, the region groups and the rows inside it keep
       every desktop rule, including the filter's behaviour. */
  .servers .server-picker-panel { display: none; }
  .servers.picker-open .server-picker-panel {
    display: block;
    position: absolute;
    left: 0;
    right: 0;
    top: calc(100% + 6px);
    z-index: 40;
    max-height: min(70vh, 480px);
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    padding: 10px;
    background: var(--header-bg, #0b1030);
    border: 1px solid var(--header-border);
    border-radius: var(--ods-radius);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
  }

  h1 { font-size: 22px; }
  h2 { font-size: 18px; }
    /* minmax(0, 1fr) and not a bare 1fr, because 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. body uses
       minmax(0, 1fr) for the same reason. 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 and not the chrome pair. The toggle sits in the content column
           (--surface-raised over --surface), where 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 the frame is
   forced portrait here.
   The frame's height is the space the page actually leaves it, because the vendor's
   attribution sits at the frame's bottom: measured 2026-09-19, the phone chrome above
   the frame is 547px, which left the credits 100-320px below the fold and invisible
   without a scroll, reported on a Pixel 10 with Firefox. So the phone chrome gives
   some back (the marketing line goes, the h1 already says it, and the section and
   panel margins tighten) and the frame takes the rest, with its WIDTH derived from
   that height by the 3/4 ratio and centred. Deriving the width is what keeps the box
   portrait: a full-width 368px column would force a 490px-tall box back under the
   fold. */
@media (max-width: 600px) {
  .subtitle-lead { display: none; }
  .section { margin-top: 20px; }
  .section > h2 { margin-bottom: 8px; }
  .panel { padding: 12px; }
  .panel iframe {
        /* The floor is about the FOLD and not about the vendor's 300px rule. Upstream hides
           the credits under a 300px-wide frame, and the height is what the credits' bottom
           edge needs to be on screen at all (measured 2026-09-19: 547px of phone chrome
           above the frame left them 100-320px below it). The floor does NOT keep the box
           308px wide, because the width below is capped by the panel, so a phone under
           about 352 CSS px gets a 272-292px-wide frame anyway (measured 2026-09-20), which
           is exactly the case that used to lose the attribution. That rule is neutralised
           where it lives, in /speedtest/assets/css/app.css, and nothing here can guarantee
           301px. On a phone shorter than about 855px the floor wins and the visitor scrolls
           a little: visible but scrolled beats hidden. */
    height: clamp(410px, calc(100dvh - 440px), 620px);
    width: auto;
    max-width: 100%;
    aspect-ratio: 3 / 4;
    margin-inline: auto;
  }
}

@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, and
   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 and not --ods-brand: the ring spins on --surface, and the brand navy
       is #000e9c in both skins (a brand token, never re-themed), which measured a hair
       over 1.5:1 on the dark card, 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, since 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 the button and the 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 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, because 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: 11.5px;
}

.server-search::placeholder { color: rgba(255, 255, 255, 0.6); }

/* Focus ring, drawn INSIDE the field: the input is width: 100% and its scrolling box
   (.servers has overflow-y: auto, which computes overflow-x to auto as well) clips
   anything that sticks out, so the usual 2px-offset outline was cut on the left and
   right and read as a doubled edge. A negative offset keeps the ODS ring and the blue,
   with the border underneath it, and nothing to clip. */
.server-search:focus-visible {
  outline: var(--ods-outline-width) solid #8aacff;
  outline-offset: calc(-1 * var(--ods-outline-width));
}

/* 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],
.dc[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; }

/* Measuring: the radar in the button turns, and that is the whole progress signal — the
   card below stays free for the verdict (AGENTS.md §10). It is the button the visitor
   just pressed, so the motion is explained by the gesture that started it. */
.closest-btn.closest-busy { cursor: progress; }
.closest-btn.closest-busy .closest-icon { animation: closest-scan 1.2s linear infinite; }

@keyframes closest-scan { to { transform: rotate(360deg); } }

/* Motion is what prefers-reduced-motion asks us to drop, and the button still reads as
   busy: it is disabled, and the progress is announced in the live region. */
@media (prefers-reduced-motion: reduce) {
  .closest-btn.closest-busy .closest-icon { animation: 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. It is the
   one state that is a verdict rather than an offer, so it reads as a success pill:
   light pill with dark text on the navy sidebar, in the ODS success badge pair
   (success-100/900) rather than 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 orange twin: a closer server was found, so the card is an offer to move. Same
   shape and weight as the green one on purpose, because the two are the same kind of
   statement in two tones, and an offer is not an error, so it does not wear the
   critical red. Amber at the green pair's lightness, with the dark ink that keeps it
   readable on the navy sidebar (contrast about 9:1). */
.closest-result--offer {
  display: inline-flex;
  background: #ffe3c2;
  color: #5c3a00;
  border-radius: calc(var(--ods-radius) / 2);
  padding: 4px 8px;
  font-weight: 600;
}

/* The two halves of a verdict: "Closest server: Gravelines" plus "— 41 ms". The tail
   (separator and detail) is one non-breaking unit, so a 216px sidebar breaks before it
   instead of leaving the dash on one line and the number on the next. In the phone bar
   the tail takes its own line and the dash goes away. */
.closest-result-main { min-width: 0; }
.closest-result-tail { margin-left: 4px; white-space: nowrap; }
.closest-result-sep { margin-right: 4px; }

/* The dismiss control that lets a visitor clear the card instead of waiting for it to
   time out. It stays quiet: a small x in the card's own ink, growing slightly on
   hover. `color: inherit` so it follows whichever tone is showing. */
.closest-result-close {
  flex: none;
  border: 0;
  background: none;
  color: inherit;
  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(0, 0, 0, 0.1); }

/* Tiny "Move to X" button, shown only when a different host is clearly closer. It is
   deliberately small and quiet, because 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 and not the width: an iPad is wide and a
   foldable can be narrow, but both are tapped. These are finger ergonomics a mouse user
   never notices. Placed last on purpose, because media queries carry no specificity and
   source order wins against the width-based blocks above.
   ------------------------------------------------------------------------ */
@media (pointer: coarse) {
  /* 26px is a mouse pointer's target and not a thumb's. The 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; }

  /* Same 40px-ish floor for the server picker's trigger, the bar's other tap target. */
  .server-picker-btn { padding: 10px 12px; }

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

/* ---------------------------------------------------------------------------
   Bar layout again: the two phone bugs, part two. It sits after the desktop rules so
   source order wins, since 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 and 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) {
    /* The bar is a column, and .closest becomes a column too: the button, the verdict and
       the move each own a row, so a short verdict cannot end up beside the button that
       produced it. It did, once the address left the sentence. */
  .closest { flex: 1 1 100%; padding-top: 0; display: flex; flex-direction: column; align-items: flex-start; }
  .closest-btn { width: auto; }
  .closest-result { flex-wrap: wrap; }
    /* The verdict breaks in two: the sentence first, where and when on the next line. A
       phone bar has no width for the whole thing on one line, and the detail is the half
       nobody needs first, the same reason the address left the verdict. */
  .closest-result-text { display: block; overflow-wrap: anywhere; }
  .closest-result-tail { display: block; margin: 2px 0 0; white-space: normal; }
  .closest-result-sep { display: none; }
    /* Both cards are inline-flex and shrink to their text, so cap them inside the row and
       let the sentence wrap instead of pushing the bar sideways. */
  .closest-result--ok,
  .closest-result--offer { max-width: 100%; }
}
