/* ==========================================================================
   DNS4EU site stylesheet

   This file contains NO raw values. Every colour, font, size, space, radius
   and shadow is a variable from brand/design-tokens.css, which each page loads
   before this file. If a value is missing, add it to design-tokens.css first.

   Built light-first and mobile-first. The page is mostly white; aqua and
   purple are small accents; navy is reserved for dark bands.
   Reference: brand/DESIGN.md, brand/PATTERNS.md.
   ========================================================================== */

/* ---- Self-hosted type. No third-party CDN, ever. ----------------------- */
@font-face {
  font-family: "Overused Grotesk";
  src: url("../fonts/OverusedGrotesk-VF.woff2") format("woff2");
  font-weight: 300 700;
  font-style: normal;
  font-display: swap;
}

/* ==========================================================================
   Base
   ========================================================================== */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--bg-surface);
  color: var(--fg-1);
  font-family: var(--font-sans);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  font-weight: var(--fw-regular);
  -webkit-font-smoothing: antialiased;
}

img, svg { max-width: 100%; }
img { height: auto; }

/* Heading roles measured from whalebone.io/immunity, see brand/DESIGN.md.
   Whalebone type is LIGHT: the sans never goes above 500, and big headings are
   Regular with tight tracking, not bold. Setting these to 600/700 is what makes
   a page look heavy and off-brand. */
h1 {
  font-family: var(--font-sans);
  font-weight: var(--fw-regular);
  margin: 0;
}
h2 {
  font-family: var(--font-sans);
  font-weight: var(--fw-medium);
  margin: 0;
}
/* h3 is the Albra serif accent. Until Albra is self-hosted the chain resolves
   to Overused Grotesk, which wants Medium rather than the 300 Albra is set at.
   When the Albra @font-face lands, switch this to --fw-serif-light. */
h3 {
  font-family: var(--font-serif);
  font-weight: var(--fw-medium);
  margin: 0;
}
h4 {
  font-family: var(--font-sans);
  font-weight: var(--fw-medium);
  margin: 0;
}

h1 {
  font-size: clamp(var(--fs-h1), 6vw, var(--fs-display-2));
  line-height: var(--lh-display-2);
  letter-spacing: var(--tk-display-2);
}
h2 {
  font-size: clamp(var(--fs-h3), 4.4vw, var(--fs-h2));
  line-height: var(--lh-h2);
  letter-spacing: var(--tk-h2);
}
h3 { font-size: var(--fs-h4); line-height: var(--lh-h4); }
h4 { font-size: var(--fs-body); line-height: var(--lh-body); }

p { margin: 0; }
p + p { margin-top: var(--space-4); }

a { color: var(--fg-link); text-decoration: none; }
a:hover { text-decoration: underline; }

ul { margin: 0; padding: 0; list-style: none; }

/* Visible focus everywhere. Never remove this. */
:focus-visible {
  outline: 2px solid var(--wb-purple);
  outline-offset: 3px;
  border-radius: var(--radius-xs);
}

.skip-link {
  position: absolute;
  left: var(--space-4);
  top: calc(var(--space-4) * -4);
  z-index: 200;
  background: var(--bg-surface);
  color: var(--fg-1);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-2);
  font-weight: var(--fw-medium);
  transition: top var(--dur-base) var(--ease-out);
}
.skip-link:focus { top: var(--space-4); }

/* ==========================================================================
   Layout
   ========================================================================== */

.container {
  width: 100%;
  max-width: var(--maxw);
  margin: 0 auto;
  padding-inline: var(--space-5);
}
.container--narrow { max-width: var(--maxw-narrow); }

.section { padding-block: var(--space-8); }
.section--tight { padding-block: var(--space-6); }

/* Pale Horizon band. Still light, used to separate sections. */
.section--sunken { background: var(--bg-page); }

/* The one dark band a page is allowed. Footer excepted. */
.section--inverse {
  background: var(--bg-inverse);
  color: var(--fg-on-dark);
}
.section--inverse h1,
.section--inverse h2,
.section--inverse h3 { color: var(--fg-on-dark); }

/* Small overline heading. Used on real h2s, so the font must be reset from
   the display face that h1/h2 inherit. */
.section-label {
  font-family: var(--font-sans);
  font-size: clamp(var(--fs-h3), 2.4vw, var(--fs-h2));
  line-height: var(--lh-h2);
  letter-spacing: var(--tk-h2);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
  margin-bottom: var(--space-5);
  max-width: 24ch;
}
.section-label + .lead { margin-top: calc(var(--space-5) * -1 + var(--space-3)); }
.section--inverse .section-label { color: var(--fg-on-dark); }

/* A section statement, not an overline. Centred and weighted so it reads as a
   claim the page is making, with the routes resolving underneath it. */
.section-statement {
  font-family: var(--font-sans);
  font-weight: var(--fw-medium);
  font-size: clamp(var(--fs-h3), 4.4vw, var(--fs-h2));
  line-height: var(--lh-h2);
  letter-spacing: var(--tk-h2);
  text-align: center;
  max-width: 22ch;
  margin: 0 auto var(--space-8);
  color: var(--fg-1);
}

.eyebrow {
  font-size: var(--fs-overline);
  line-height: var(--lh-overline);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  font-weight: var(--fw-medium);
  color: var(--fg-3);
  margin-bottom: var(--space-4);
}
.section--inverse .eyebrow { color: var(--wb-teal-200); }

.lead {
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  color: var(--fg-2);
  max-width: 60ch;
}
.section--inverse .lead { color: var(--fg-on-dark-muted); }

.muted { color: var(--fg-3); }
.measure { max-width: 30ch; }

.grid-2, .grid-3 { display: grid; gap: var(--space-5); }

/* ==========================================================================
   Header. Light, minimal. The homepage is a router, the header reads that way.
   ========================================================================== */

.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: var(--bg-surface);
  border-bottom: 1px solid var(--border-subtle);
}

.nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  min-height: var(--nav-h);
}

/* Logo lockup. Only ONE dimension is ever set, the other stays auto, so the
   asset can never be stretched. See brand/LOGO.md. */
.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  /* clear space: nothing intrudes on the mark */
  padding: var(--space-2) var(--space-2) var(--space-2) 0;
  flex-shrink: 0;
}
.brand:hover { text-decoration: none; }
.brand__logo { height: 24px; width: auto; display: block; }
.brand__powered {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding-left: var(--space-2);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
  white-space: nowrap;
}
.brand__wb { height: 14px; width: auto; display: block; }

.nav__links { display: none; }
.nav__links a {
  color: var(--fg-2);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-out);
}
.nav__links a:hover { color: var(--fg-1); text-decoration: none; }
.nav__links a[aria-current="page"] {
  color: var(--fg-1);
  font-weight: var(--fw-medium);
  box-shadow: inset 0 -2px 0 var(--wb-teal);
}

.nav__right { display: flex; align-items: center; gap: var(--space-4); }

/* Language. A real <select>, so it is keyboard and screen-reader native and
   works with no JavaScript. The chevron is drawn from borders rather than an
   image, which keeps it on currentColor and adds no request. */
.lang { position: relative; display: inline-flex; align-items: center; }
.lang::after {
  content: "";
  position: absolute;
  right: var(--space-3);
  width: var(--space-2);
  height: var(--space-2);
  margin-top: calc(var(--space-1) * -1);
  border-right: 1px solid var(--fg-3);
  border-bottom: 1px solid var(--fg-3);
  transform: rotate(45deg);
  pointer-events: none;
}
.lang-select {
  appearance: none;
  -webkit-appearance: none;
  padding: var(--space-2) var(--space-7) var(--space-2) var(--space-3);
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-pill);
  font-family: var(--font-sans);
  font-size: var(--fs-caption);
  font-weight: var(--fw-medium);
  line-height: var(--lh-caption);
  color: var(--fg-2);
  cursor: pointer;
}
.lang-select:hover { border-color: var(--border-default); }

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.nav__toggle {
  display: inline-flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--space-1);
  width: var(--space-7);
  height: var(--space-7);
  padding: 0 var(--space-3);
  background: none;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
  cursor: pointer;
}
.nav__toggle span {
  display: block;
  height: 2px;
  background: var(--fg-1);
  border-radius: var(--radius-pill);
}

/* ==========================================================================
   Buttons. Primary aqua fill with ink text, secondary outline. Pill radius.
   One primary per view.
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  padding: var(--space-3) var(--space-5);
  border: 1px solid transparent;
  border-radius: var(--radius-pill);
  font-family: var(--font-sans);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-regular);
  line-height: var(--lh-body-sm);
  text-decoration: none;
  cursor: pointer;
  transition: background-color var(--dur-hover) var(--ease-in-out),
              border-color var(--dur-hover) var(--ease-in-out),
              color var(--dur-hover) var(--ease-in-out);
}
.btn:hover { text-decoration: none; }
.btn--lg { padding: var(--space-4) var(--space-6); font-size: var(--fs-body); }

.btn--primary {
  background: var(--wb-teal);
  color: var(--fg-1);
  border-color: var(--wb-teal);
}
.btn--primary:hover {
  background: var(--wb-navy);
  color: var(--fg-on-dark);
  border-color: var(--wb-navy);
}

/* Brand purple. For an action that must not be aqua, which today is the pair on
   /public sending organizations to their own pages. See the note on
   .service-band--private. */
.btn--brand {
  background: var(--bg-brand);
  color: var(--fg-on-dark);
  border-color: var(--bg-brand);
}
.btn--brand:hover {
  background: var(--wb-navy);
  color: var(--fg-on-dark);
  border-color: var(--wb-navy);
}

.btn--secondary {
  background: transparent;
  color: var(--fg-1);
  border-color: var(--border-strong);
}
.btn--secondary:hover { background: var(--bg-page); }

.section--inverse .btn--secondary {
  color: var(--fg-on-dark);
  border-color: var(--border-on-dark-strong);
}

/* ---- Hover direction, taken from whalebone.io/immunity ------------------
   Immunity keeps two button tokens, --clr-btn-dk (their Midnight Depth) and --clr-btn-lt
   (their white), and switches between them by context: on a light surface a button
   hovers DARK, on a dark surface it hovers LIGHT. Only the first half of that
   was implemented here, so on a dark hero the primary button hovered to navy
   on navy and vanished under the cursor.
   Rule: a hover must never move toward the surface it sits on. --------- */
.section--inverse .btn--primary:hover,
.section--inverse .btn--secondary:hover {
  background: var(--wb-white);
  color: var(--fg-1);
  border-color: var(--wb-white);
}



/* ==========================================================================
   Hero
   ========================================================================== */

.hero { padding-block: var(--space-8); }
.hero h1 { margin-bottom: var(--space-5); max-width: 20ch; }
.hero .lead { max-width: 52ch; }

.hero__cta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-top: var(--space-6);
}

.hero__trust {
  margin-top: var(--space-6);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-3);
}
.section--inverse .hero__trust { color: var(--fg-on-dark-subtle); }

/* ==========================================================================
   Hero image band. A supplied brand photograph behind a dark band, used at
   most once per page and always under a scrim so text keeps AA contrast.
   This is an approved asset, not a CSS decoration. See brand/PATTERNS.md.
   ========================================================================== */

.hero--image { position: relative; overflow: hidden; isolation: isolate; }

/* The gateway hero. Taller than a destination hero so the Europe artwork has
   room to read as a whole map rather than a cropped strip. */
/* The homepage hero and every subpage banner are now within four vh of each
   other, asked for 2026-08-27: "at jsou vsechny stejne velke jako na hp, na hp
   muze byt o neco mensi". They were 80/88vh against 0/56vh, which is why a
   subpage felt like a different site arriving.

   The homepage keeps the smaller of the two, which is what was asked and is
   also right: it is the only hero carrying a strip above it and two buttons and
   a trust line below, so it is the one that can least afford the height. */
.hero--tall { min-height: 56vh; }

/* Subpage banner. Same artwork and treatment as the gateway so the site reads
   as one thing, but shorter, and never animated. Motion belongs to the
   homepage; a static banner is the visual cue.
   The height override has to repeat inside the wide breakpoint, because
   .hero--image sets a min-height there that a banner would otherwise inherit. */
.hero--banner { min-height: 60vh; padding-block: var(--space-6); }
/* The headline stays a step smaller than the homepage's. The heights matched;
   the type did not have to, and a subpage h1 is usually the longer sentence. */
.hero--banner h1 { font-size: clamp(var(--fs-h2), 4vw, var(--fs-h1)); }
.hero--banner .lead { font-size: var(--fs-body); line-height: var(--lh-body); }

.hero--image .hero__media {
  position: absolute;
  inset: 0;
  z-index: -3;
}
.hero--image .hero__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 72% center;
  display: block;
  filter: var(--filter-artwork);
}
/* The scrim. Without it, text over the artwork fails contrast. */
.hero--image::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -2;
  background: var(--scrim-image-v);
}
.hero--image .container { position: relative; }

/* Secondary text over artwork gets the brighter tone. The on-navy tones assume
   a flat band; artwork has bright patches that eat the contrast. */
.hero--image .lead,
.hero--image .hero__trust { color: var(--fg-on-image); }

/* The eyebrow is 11px, so it needs the full 4.5 and gets no large-text
   allowance. Mid-tone aqua measured 4.47 over the artwork; the lighter tint
   clears it comfortably. */
.hero--image .eyebrow { color: var(--wb-teal-100); }

.hero__caption {
  margin-top: var(--space-4);
  max-width: 52ch;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-on-image-subtle);
}

/* ==========================================================================
   Threat blips (v2 hero). A second SVG over the artwork, sharing its viewBox
   and preserveAspectRatio so every blip stays locked to a mesh node at any
   crop. The base map must therefore be centred, not offset.

   Honesty rule: this is an illustration, never a live feed. The hero carries a
   visible caption saying so. Do not wire it to invented numbers.
   ========================================================================== */

/* Anchored right, matching the overlay's xMaxYMid. The landmass sits right of
   centre in the artwork, so a centred crop pushed most strikes off screen at
   375px. Both layers must use the same anchor or the strikes drift off the map.
   preserveAspectRatio only offers Min/Mid/Max, hence 100%. */
.hero--v2 .hero__media img { object-position: 100% center; }

/* Zoom into Europe. Applied identically to the artwork and the strike layer:
   both fill the same box with the same aspect handling, so an identical
   transform keeps every strike welded to the map. Change them together or not
   at all. */
.hero--v2 .hero__media img,
.hero--v2 .netlayer,
/* ---- v3: split hero. Copy left on clean navy, map right, minimal overlap.
   The map carries no scrim over it, which is what finally makes the countries
   read. Portugal, Spain and France sit at the western edge of the artwork, so
   the frame is anchored left-of-centre to keep them in view rather than
   cropping them off. ------------------------------------------------------ */

/* Origin pinned to the left edge so scaling up pushes the landmass RIGHT, out
   from under the copy column. Iberia and France sat at 43% and 57% of the band
   with the old 72% origin, i.e. buried under the scrim, which is exactly why
   Portugal, Spain and France were unreadable. Origin 0 also guarantees the box
   stays covered; any negative origin exposes the left edge. */
/* The artwork is a background, not an <img>, and it is declared only inside the
   desktop breakpoint below. A phone therefore never requests it. background-size
   contain with a right position is the exact equivalent of the overlay's
   preserveAspectRatio="xMaxYMid meet", so the pulses stay welded to the map. */
.hero--map .netlayer { transform: none; }

/* No filter. europe-states.svg is already colour-graded and carries its own
   left-side scrim, so it is designed for exactly this layout. Brightening it
   washed the navy toward grey and blew the country strokes to pure white,
   which is what produced the visible parting between the two sides. */
.hero--map .hero__media { background-color: transparent; }

/* Narrow screens put full-width copy over the artwork, so it still needs
   holding back, but evenly. A gradient here would reintroduce the parting. */
.hero--map::before { background: var(--scrim-image-flat); }


/* Bottom meta strip, sitting under the whole band rather than inside the copy
   column, so the hero reads as one composition instead of a stacked block. */


/* europe-states.svg bakes its own full-canvas scrim into the artwork, so this
   band only needs a light one on top. Using the standard scrim here stacked two
   gradients and crushed the map. */
/* europe-states.svg bakes its own scrim into the artwork, so a lighter overlay
   looked like the right call. It is not: the brightness filter lifts that baked
   scrim too, cancelling its protection. Measured 2.29 on the trust line before
   reverting. v3 uses the same scrim as the other image heroes. */

/* Above the scrim, not under it. Sitting inside .hero__media put the network
   below the overlay, so the same gradient that protects the text was dimming
   the links and blips. Splitting them lets the scrim stay strong where the
   copy is while the network reads at full strength across the whole band. */
.netlayer {
  position: absolute;
  inset: 0;
  z-index: -1;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* Lightning at the nodes. White only: aqua and white are the brand signature,
   coral is reserved for genuine alerts, and connecting lines read as decoration
   rather than as the network doing something. */
.blip__core {
  fill: var(--wb-white);
  opacity: 0;
  animation: blip-core var(--blip-dur, 2.6s) var(--ease-out) infinite;
  animation-delay: var(--blip-delay, 0s);
}
.blip__ring {
  fill: none;
  stroke: var(--wb-white);
  stroke-width: 1.5;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
  animation: blip-ring var(--blip-dur, 2.6s) var(--ease-out) infinite;
  animation-delay: var(--blip-delay, 0s);
}

/* ---- Pulses (v3). A blocked event at a location: a dot appears, a ring
   spreads and fades. Radius encodes the size of the attack, so the map reads
   as activity of varying weight rather than uniform blinking.
   Deliberately slow and soft. Calm reads as a system in control; frantic reads
   as an alarm, which is the wrong feeling for a public-sector page. -------- */

.pulse__dot {
  fill: var(--wb-white);
  opacity: 0;
  animation: pulse-dot var(--p-dur, 3.4s) var(--ease-out) infinite;
  animation-delay: var(--p-delay, 0s);
}
.pulse__wave {
  fill: none;
  stroke: var(--wb-teal-100);
  stroke-width: 1.1;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
  animation: pulse-wave var(--p-dur, 3.4s) var(--ease-out) infinite;
  animation-delay: var(--p-delay, 0s);
}

/* Larger events glow a little longer and spread further, which is what makes
   the size difference legible rather than just a bigger dot. */
.pulse--lg .pulse__wave,
.pulse--xl .pulse__wave { stroke-width: 1.5; }
.pulse--xl .pulse__dot  { filter: drop-shadow(0 0 6px var(--wb-teal-100)); }

/* Short duty cycle. With 40 points a long fade left roughly a third of the map
   lit at any moment, which reads as busy rather than calm. Each event is
   visible for under a third of its cycle. There are 63 points since
   2026-08-27; density is held where it was by the gap between firings, not by
   this envelope. See .netlayer--scheduled below. */
@keyframes pulse-dot {
  0%   { opacity: 0; }
  5%   { opacity: var(--p-peak, 0.9); }
  18%  { opacity: calc(var(--p-peak, 0.9) * 0.5); }
  32%  { opacity: 0; }
  100% { opacity: 0; }
}
@keyframes pulse-wave {
  0%   { opacity: 0; transform: scale(0.6); }
  6%   { opacity: 0.7; transform: scale(0.9); }
  36%  { opacity: 0; transform: scale(var(--p-spread, 4.4)); }
  100% { opacity: 0; transform: scale(var(--p-spread, 4.4)); }
}

@media (prefers-reduced-motion: reduce) {
  .pulse__dot  { animation: none; opacity: var(--p-peak, 0.9); }
  .pulse__wave { animation: none; opacity: 0.22; transform: scale(1.6); }
}

/* Randomised firing, when there is JavaScript to do it.

   A CSS animation loops on a fixed period, so each point blinks like a
   metronome and, with the whole set running, the map settles into a rhythm a
   viewer can predict. Real traffic does not arrive on a schedule. main.js adds
   .netlayer--scheduled and then fires each point once at a time of its own
   choosing, so nothing repeats.

   Without JavaScript none of this applies and the looping animation above still
   runs, which is why the markup keeps its --p-delay: it is the fallback, not
   dead weight. --p-dur is used by both. */
.netlayer--scheduled .pulse__dot,
.netlayer--scheduled .pulse__wave { animation: none; }

.netlayer--scheduled .pulse.is-firing .pulse__dot {
  animation: pulse-dot var(--p-dur, 3.4s) var(--ease-out) 1 forwards;
  animation-delay: 0s;
}
.netlayer--scheduled .pulse.is-firing .pulse__wave {
  animation: pulse-wave var(--p-dur, 3.4s) var(--ease-out) 1 forwards;
  animation-delay: 0s;
}


/* ---- Bolts. Kept for reference; no page uses them at present. ----------- */

.strike__bolt,
.strike__glow {
  fill: none;
  stroke: var(--wb-white);
  stroke-linecap: round;
  stroke-linejoin: round;
  opacity: 0;
}
.strike__bolt {
  stroke-width: 1.4;
  animation: bolt-strike var(--blip-dur, 2.6s) var(--ease-out) infinite;
  animation-delay: var(--blip-delay, 0s);
}
.strike__glow {
  stroke-width: 5;
  animation: bolt-glow var(--blip-dur, 2.6s) var(--ease-out) infinite;
  animation-delay: var(--blip-delay, 0s);
}
.strike__hit {
  fill: var(--wb-white);
  opacity: 0;
  animation: bolt-hit var(--blip-dur, 2.6s) var(--ease-out) infinite;
  animation-delay: var(--blip-delay, 0s);
}
.strike__ring {
  fill: none;
  stroke: var(--wb-teal-100);
  stroke-width: 1.2;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
  animation: bolt-ring var(--blip-dur, 2.6s) var(--ease-out) infinite;
  animation-delay: var(--blip-delay, 0s);
}

/* The bolt exists for a few frames only. Anything longer stops looking like
   lightning and starts looking like a drawn line. */
@keyframes bolt-strike {
  0%   { opacity: 0; }
  1.5% { opacity: 1; }
  3%   { opacity: 0.2; }
  4.5% { opacity: 0.95; }
  9%   { opacity: 0; }
  100% { opacity: 0; }
}
@keyframes bolt-glow {
  0%   { opacity: 0; }
  1.5% { opacity: 0.35; }
  9%   { opacity: 0; }
  100% { opacity: 0; }
}
/* The hit lands on the second flash and lingers a beat longer than the bolt. */
@keyframes bolt-hit {
  0%   { opacity: 0; }
  4.5% { opacity: 1; }
  16%  { opacity: 0.4; }
  26%  { opacity: 0; }
  100% { opacity: 0; }
}
@keyframes bolt-ring {
  0%   { opacity: 0;   transform: scale(0.5); }
  5%   { opacity: 0.8; transform: scale(0.7); }
  34%  { opacity: 0;   transform: scale(5); }
  100% { opacity: 0;   transform: scale(5); }
}

.strike--burst .strike__bolt { animation-name: bolt-strike-burst; }
@keyframes bolt-strike-burst {
  0%   { opacity: 0; }
  1.5% { opacity: 1; }
  3%   { opacity: 0.15; }
  4.5% { opacity: 0.9; }
  6%   { opacity: 0.1; }
  7.5% { opacity: 1; }
  12%  { opacity: 0; }
  100% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .strike__bolt { animation: none; opacity: 0.55; }
  .strike__glow { animation: none; opacity: 0.12; }
  .strike__hit  { animation: none; opacity: 0.85; }
  .strike__ring { animation: none; opacity: 0.25; transform: scale(2); }
}

/* Roughly a third of the points fire as a burst of three rather than a single
   strike, which is what stops the field reading as a metronome. */
.blip--burst .blip__core { animation-name: blip-core-burst; }
.blip--burst .blip__ring { animation-name: blip-ring-burst; }

@keyframes blip-core-burst {
  0%   { opacity: 0; }
  2%   { opacity: 1; }
  5%   { opacity: 0.12; }
  8%   { opacity: 0.9; }
  11%  { opacity: 0.1; }
  14%  { opacity: 1; }
  24%  { opacity: 0.35; }
  36%  { opacity: 0; }
  100% { opacity: 0; }
}
@keyframes blip-ring-burst {
  0%   { opacity: 0;   transform: scale(0.4); }
  2%   { opacity: 0.85; transform: scale(0.6); }
  18%  { opacity: 0;   transform: scale(2.6); }
  19%  { opacity: 0;   transform: scale(0.5); }
  21%  { opacity: 0.8; transform: scale(0.7); }
  44%  { opacity: 0;   transform: scale(4.4); }
  100% { opacity: 0;   transform: scale(4.4); }
}

/* Real lightning strikes twice: a hard leader, a momentary drop, then the
   brighter return stroke, then a fast decay. That double beat plus the
   asymmetric envelope is what separates a strike from a pulse.
   Ten nodes on a 2.6s cycle staggered 0.26s apart means a strike roughly every
   quarter second across the map, while any single point flashes at 0.38Hz.
   Each is a few pixels, nowhere near the WCAG large-area flash threshold. */
@keyframes blip-core {
  0%   { opacity: 0; }
  2%   { opacity: 0.95; }
  4%   { opacity: 0.25; }
  6%   { opacity: 1; }
  16%  { opacity: 0.45; }
  30%  { opacity: 0; }
  100% { opacity: 0; }
}
@keyframes blip-ring {
  0%   { opacity: 0;   transform: scale(0.4); }
  3%   { opacity: 0.9; transform: scale(0.6); }
  34%  { opacity: 0;   transform: scale(4.4); }
  100% { opacity: 0;   transform: scale(4.4); }
}

/* Flashing is a real accessibility concern, so reduced motion gets a static
   state rather than a slower animation. */
@media (prefers-reduced-motion: reduce) {
  .blip__core { animation: none; opacity: 0.9; }
  .blip__ring { animation: none; opacity: 0.3; transform: scale(2); }
}

/* ==========================================================================
   Cards, routes, notice
   ========================================================================== */

.card {
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  padding: var(--space-6);
  box-shadow: var(--shadow-1);
}
.card h3 { margin-bottom: var(--space-3); }
.card p { color: var(--fg-2); font-size: var(--fs-body-sm); line-height: var(--lh-body-sm); }
.card__icon {
  /* --space-7, the same as .route__icon, so a card icon is one size on this
     site. The files are 62.36 square and scale down cleanly. */
  width: var(--space-7);
  height: var(--space-7);
  margin-bottom: var(--space-4);
}

/* --------------------------------------------------------------------------
   All variants. Sits under the one address most people want and points at the
   other four. A secondary button rather than a plain link: Nikolas asked for a
   button, and the page's own primary action is the setup section much further
   down, so nothing competes.

   The chevron is DRAWN, not a glyph. PATTERNS.md bans the arrow character in a
   label; it does not ban a mark, which is why the disclosure marker is a drawn
   plus. Same reasoning here.
   -------------------------------------------------------------------------- */
.all-variants { margin-top: var(--space-5); }
.all-variants__link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
}
.all-variants__mark {
  flex: none;
  width: 16px;
  height: 16px;
}
@media (prefers-reduced-motion: no-preference) {
  .all-variants__mark { transition: translate var(--dur-base) var(--ease-out); }
  .all-variants__link:hover .all-variants__mark { translate: 3px 0; }
}

/* ==========================================================================
   Figure-led card. The same card, reordered so the proof arrives before the
   prose: kicker, figure, label, heading, two or three sentences. Built for the
   "We have you covered" row, where three tiles ran four sentences each and the
   number was buried in the middle of the third one.
   The figure borrows the zig-zag's colour and weight rather than inventing a
   second way to set a number, so the page keeps one visual language for proof.
   ========================================================================== */

.cards--figures { margin-top: var(--space-8); gap: var(--space-6); }

.card--figure { display: flex; flex-direction: column; }

/* Scoped to the modifier on purpose: `.card p` is 0,1,1 and beats a bare
   `.card__figure` at 0,1,0, which reset the number back to 14px body copy. */
.card--figure .card__figure {
  margin-top: var(--space-3);
  font-family: var(--font-display);
  font-weight: var(--fw-serif-light);
  /* Capped at h1, not display: three of these sit side by side in a ~300px
     column, and display-2 wrapped every one of them onto two lines. */
  font-size: clamp(var(--fs-h2), 3.4vw, var(--fs-h1));
  line-height: 1.04;
  letter-spacing: var(--tk-h1);
  color: var(--wb-purple);
  overflow-wrap: anywhere;
}

.card--figure .card__figure-label {
  margin-top: var(--space-2);
  padding-bottom: var(--space-5);
  border-bottom: 1px solid var(--border-subtle);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-3);
}

.card--figure h3 { margin-top: var(--space-5); }
/* The link is pinned to the foot so three tiles of unequal prose still line
   their calls to action up with each other. */
.card--figure .zig__link { margin-top: auto; padding-top: var(--space-4); }
.card--figure .form__note + .zig__link { padding-top: var(--space-3); }

/* `.card--offer` lived here. One tile in the row carried the discount and was
   keyed to the brand purple so it did not sit identical to the two beside it.
   Removed 2026-08-27: Nikolas asked for all three tiles white. The row is three
   answers to three objections, no time, no people, no budget, and tinting one
   of them said the budget answer was the important one, which is not what the
   section is about. The discount still leads its own tile and has the whole
   form band further down the page. Nothing else used the class. */

.routes { display: grid; gap: var(--space-5); }

.route {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xl);
  padding: var(--space-6);
  box-shadow: var(--shadow-1);
  transition: box-shadow var(--dur-base) var(--ease-out),
              border-color var(--dur-base) var(--ease-out);
}
.route:hover { box-shadow: var(--shadow-2); border-color: var(--border-default); }
.route:focus-within { box-shadow: var(--shadow-2); }

.route__icon { width: var(--space-7); height: var(--space-7); margin-bottom: var(--space-5); }
.route__kicker {
  display: block;
  font-size: var(--fs-overline);
  line-height: var(--lh-overline);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  font-weight: var(--fw-medium);
  color: var(--fg-3);
  margin-bottom: var(--space-3);
}
.route h3 {
  font-family: var(--font-serif);
  font-weight: var(--fw-medium);
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
  margin-bottom: var(--space-3);
}
.route__desc { color: var(--fg-2); font-size: var(--fs-body-sm); line-height: var(--lh-body-sm); }

/* The free public resolver. A different category from the two routes, so it is
   marked rather than styled identically. The badge is what makes free versus
   DNS4GOV unmistakable, which the action brief asks for. */
/* The free public resolver. It sits below the two organization routes and takes
   a different shape, because it is a different audience. The badge is what makes
   free versus DNS4GOV unmistakable; do not drop it. */
.service-band {
  display: grid;
  gap: var(--space-5);
  margin-top: var(--space-6);
  padding: var(--space-6);
  background: var(--bg-surface);
  border: 1px solid var(--wb-teal-100);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-1);
}
.service-band h3 { margin-bottom: var(--space-3); }
/* FLEX, not a paragraph of inline-blocks. The two buttons were separated only
   by the whitespace between them in the markup, which is a horizontal space and
   nothing else: at 375px they wrap onto two lines and sit flush against each
   other, 0px apart. Reported from a phone, 2026-09-10. A row gap fixes the
   stacked case and the wrap keeps the side-by-side one. */
.service-band__cta {
  margin-top: var(--space-5);
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
}
.service-band__benefits { margin-top: 0; padding-top: 0; border-top: none; }

/* --------------------------------------------------------------------------
   The free public service badge.

   Restyled 2026-08-27 to a reference Nikolas supplied: a purple panel with
   white capitals, a modest radius and a tab in the top edge. The reference is a
   crop, and the element is on neither whalebone.io/immunity nor joindns4.eu, so
   its proportions were read off the image rather than measured off a page:
   radius is 17% of the height there, and the tab is about an eighth of the
   width. The tab is drawn a little larger than a strict scale would give,
   because at badge size a faithful one would be two pixels tall and invisible.

   Purple as a fill is inside the colour budget here and would not be at section
   scale: this is one 200x40 chip, an accent on a white card, which is what the
   budget means by "a primary button, a keyline, an icon, one stat".

   Capitals are typed in the markup, not transformed, which is the rule the
   address strip already follows and which this component was breaking.
   -------------------------------------------------------------------------- */

/* A section label centred over what it introduces. Used once, on the
   positioning heading on /public, which sits above three tiles rather than
   above a column of prose: over a centred row, a left-aligned heading reads as
   a caption for the first tile. */
.section-label--centre {
  text-align: center;
  margin-inline: auto;
}

/* --------------------------------------------------------------------------
   A pale panel, the tile relationship inverted.

   A .card and a .panel are white with no border, so they need a --sunken
   section under them or there is no surface. That works until a page has more
   grounded sections than free ones: no two adjacent sections may share a
   ground, and two free sections cannot sit between two grounded ones, so the
   order has to move rather than the grounds. On /governments even moving the
   order was not enough once the Future block became a box.

   So: pale on white. No border, which is the same rule the flat surfaces are
   built on and not an exception to it: the surface contrast does the work in
   either direction. A border here would also lose to the late Flat surfaces
   block anyway, which is worth knowing before trying.

   Reach for this only when the alternation has run out, not as a second way of
   drawing a tile.
   -------------------------------------------------------------------------- */
.panel--sunken { background: var(--bg-sunken); }

/* The Future of DNS4EU box: the argument on the left, what is actually
   published on the right. */
/* The two names, as one panel with two halves. The eyebrow carries the name and
   the paragraph carries what it is, which is the shape a glossary entry has and
   the reason this reads as one idea rather than as two products. 2026-09-09. */
.names { gap: var(--space-7); }
.names .eyebrow { margin-bottom: var(--space-3); color: var(--wb-teal); }
.names p:last-child { margin: 0; color: var(--fg-2); }

.roadmap__box { align-items: stretch; gap: var(--space-6); }

/* A sourced figure inside a tile: the number, then what it is a number of.
   /protection had these as a zig-zag component of their own until 2026-08-27;
   the tiles carry them now, so the figure needs a size of its own rather than
   a whole card built around it. */
.card__stat {
  margin-top: var(--space-4);
  font-family: var(--font-serif);
  font-weight: var(--fw-serif-light);
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
  color: var(--fg-1);
}
.card__stat span {
  display: block;
  margin-top: var(--space-2);
  font-family: var(--font-sans);
  font-weight: var(--fw-regular);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}

/* `.case--nomedia` lived here. Two of the five case studies were published with
   no photograph, and an empty frame saying so read as a gallery with two holes
   in it, so those two led on the quotation instead. Deleted 2026-08-27: every
   card carries a holding frame now, so there is no odd one out to compensate
   for. See `.case__media--ph` further down. */

/* The left half is the claim, so it reads as the thing rather than as one of
   two columns. White on the pale box: the same surface contrast the tiles use,
   one level in. Nikolas, 2026-08-27: emphasise the left, leave the right. */
.roadmap__claim {
  background: var(--bg-surface);
  border-radius: var(--radius-md);
  padding: var(--space-5);
}

/* One size in here, not three. Nikolas, 2026-08-27: the same fonts throughout.
   The claim was a .lead at 18px over two paragraphs at 16, which reads as a
   lead and a footnote rather than as three steps of one argument. The margins
   were style attributes on the markup; they are a rule now. */
/* The claim, louder. Nikolas, 2026-09-09: this is ours, so it should not be
   the same size as everything around it. Both sentences move together, which is
   the rule he set on 2026-08-27 when it was a lead over a footnote: one size in
   here, not three. That size is now 18px rather than 16. */
.roadmap__claim.roadmap__claim--loud p {
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  font-weight: var(--fw-medium);
}

.roadmap__claim p {
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-1);
}
.roadmap__claim p + p { margin-top: var(--space-4); }
.roadmap__claim .zig__link { margin-top: var(--space-3); }

/* Somebody else already said this, so it is set as a quotation rather than as
   another of our sentences. */
.roadmap__cite {
  margin-top: var(--space-5);
  padding-left: var(--space-4);
  border-left: 2px solid var(--wb-teal);
}
.roadmap__cite p {
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-1);
}
.roadmap__cite cite {
  display: block;
  margin-top: var(--space-2);
  font-style: normal;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}
.roadmap__news { display: flex; flex-direction: column; }
.roadmap__news .all-variants { margin-top: auto; padding-top: var(--space-5); }

/* Dated links. The title carries the link, the date sits under it as a caption:
   a date is how a reader judges whether "latest" is worth a click, so it has to
   be visible without being the thing they aim at. */
.newslist {
  margin-top: var(--space-4);
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--space-4);
}
.newslist > li { display: grid; gap: var(--space-1); }
.newslist a {
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}
.newslist a:hover { color: var(--fg-link); text-decoration: underline; }
.newslist time {
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}

.badge-free {
  display: inline-block;
  align-self: flex-start;
  margin-bottom: var(--space-4);
  /* One less on each side than the filled version needed: an outline reads at a
     smaller size than a block of colour does. */
  padding: var(--space-2) var(--space-4);
  /* Outline, not fill. Nikolas, 2026-08-27: it was the loudest thing in its
     band while the actions beside it were the quietest, which is backwards. A
     label says what something is; it is not a thing to press. The purple it
     gave up went onto those actions as .btn--brand. */
  background: transparent;
  border: 1px solid var(--fg-1);
  border-radius: var(--radius-md);
  font-size: var(--fs-overline);
  line-height: var(--lh-overline);
  letter-spacing: var(--tk-overline);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}

/* On the homepage tile it is a corner tag rather than a line of content:
   Nikolas, 2026-08-27, top right of the card. Absolute, so the heading moves up
   to sit level with the icon instead of below a badge. .route is already
   position: relative for the stretched link, and that link comes later in the
   DOM, so it still covers the whole card; the badge is not interactive and does
   not need to be above it. */
.route--free .badge-free {
  position: absolute;
  top: var(--space-5);
  right: var(--space-5);
  margin: 0;
}


/* Benefits, not a table of contents. The marker is a small aqua dot rather
   than a list bullet: enough to make the lines scannable without turning the
   card into a spec sheet. */
.route__benefits {
  margin-top: var(--space-5);
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
}
.route__benefits li {
  position: relative;
  padding-left: var(--space-5);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}
.route__benefits li + li { margin-top: var(--space-3); }
.route__benefits li::before {
  content: "";
  position: absolute;
  left: 0;
  top: calc(var(--space-3) - 1px);
  width: var(--space-2);
  height: var(--space-2);
  border-radius: var(--radius-pill);
  background: var(--wb-teal);
}

.route__go {
  display: inline-flex;
  align-items: center;
  margin-top: var(--space-6);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
  color: var(--fg-link);
}
.route:hover .route__go { text-decoration: underline; }

/* The whole card is the click target, but the accessible name lives on a real
   link so keyboard and screen-reader users get the same affordance. */
.route__link {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  font-size: 0;
  color: transparent;
}

.crosssell {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-4);
  margin-top: var(--space-6);
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
  font-size: var(--fs-body-sm);
  color: var(--fg-3);
}
.crosssell a { display: inline-flex; align-items: center; min-height: 44px; }
.crosssell a { font-weight: var(--fw-medium); }

.notice {
  display: flex;
  gap: var(--space-4);
  padding: var(--space-5);
  background: var(--bg-page);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}
.notice__dot {
  flex: none;
  width: var(--space-3);
  height: var(--space-3);
  margin-top: var(--space-1);
  border-radius: var(--radius-pill);
  background: var(--wb-teal);
}

/* ==========================================================================
   Placeholder box. Stands in for an asset that does not exist yet.
   Must always carry visible [PLACEHOLDER: …] text. See brand/PATTERNS.md.
   ========================================================================== */

.placeholder-box {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: calc(var(--space-9) * 2);
  padding: var(--space-6);
  background: var(--bg-sunken);
  border: 1px dashed var(--border-default);
  border-radius: var(--radius-lg);
  text-align: center;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-3);
}

/* ==========================================================================
   Steps. 01 / 02 / 03 explainer
   ========================================================================== */

.steps { display: grid; gap: var(--space-5); counter-reset: step; }
.step {
  padding-top: var(--space-5);
  border-top: 2px solid var(--wb-teal);
  counter-increment: step;
}
.step::before {
  content: "0" counter(step);
  display: block;
  margin-bottom: var(--space-3);
  font-family: var(--font-display);
  font-weight: var(--fw-serif-light);
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
  color: var(--fg-muted);
}
.step h3 { margin-bottom: var(--space-3); }
.step p { color: var(--fg-2); font-size: var(--fs-body-sm); line-height: var(--lh-body-sm); }

/* ==========================================================================
   Resolvers. The public service variants, each with its address.
   ========================================================================== */

.resolvers { display: grid; gap: var(--space-4); }

.resolver {
  display: flex;
  flex-direction: column;
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
  box-shadow: var(--shadow-1);
}
.resolver h3 { margin-bottom: var(--space-2); }
.resolver p {
  color: var(--fg-2);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  flex: 1 1 auto;
}

.resolver__ip {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  margin-top: var(--space-5);
  padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4);
  background: var(--bg-sunken);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
}
.resolver__ip code {
  font-family: var(--font-mono);
  font-size: var(--fs-body-sm);
  color: var(--fg-1);
}

.copy-btn {
  flex: none;
  padding: var(--space-2) var(--space-3);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
  font-family: var(--font-sans);
  font-size: var(--fs-caption);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
  cursor: pointer;
  transition: background var(--dur-fast) var(--ease-out);
}
.copy-btn:hover { background: var(--bg-page); }

/* ==========================================================================
   Stat row. Social proof by numbers, on light. Each figure carries its own
   provenance tag, because an unattributed number on a public-sector site is
   worth less than no number at all.
   ========================================================================== */

.stat-row { display: grid; gap: var(--space-5); }
.stat { min-width: 0; }
.stat__value { overflow-wrap: anywhere; }

.stat {
  padding: var(--space-6);
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-1);
  border-top: 3px solid var(--wb-teal);
}
.stat__value {
  font-family: var(--font-sans);
  font-weight: var(--fw-regular);
  font-size: clamp(var(--fs-h1), 5vw, var(--fs-display-2));
  line-height: var(--lh-display-2);
  letter-spacing: var(--tk-display-2);
  color: var(--fg-1);
  font-variant-numeric: tabular-nums;
}
.stat__label {
  margin-top: var(--space-3);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}
.stat__source {
  display: block;
  margin-top: var(--space-3);
  font-size: var(--fs-overline);
  line-height: var(--lh-overline);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  font-weight: var(--fw-medium);
  color: var(--fg-muted);
}
.stat--unverified { border-top-color: var(--border-default); }
.stat--unverified .stat__value { color: var(--fg-3); }

/* ==========================================================================
   Case studies. A scroll-snap carousel: it works with no JavaScript at all,
   scrolls with a trackpad, a touch swipe or the keyboard, and the buttons are
   a progressive enhancement on top.
   ========================================================================== */

.cases { position: relative; }

.cases__track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 88%;
  gap: var(--space-5);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  padding-bottom: var(--space-2);
  /* room for focus rings, which would otherwise be clipped by the scroller */
  padding-inline: var(--space-1);
  /* No bar under the carousel of any kind, asked for 2026-08-17. The scrollbar
     goes with the control bar; the side chevrons, a swipe, a trackpad and the
     keyboard all still move the track, so nothing is lost but the rule. */
  scrollbar-width: none;
}
.cases__track::-webkit-scrollbar { display: none; }
.cases__track > * { scroll-snap-align: start; }

.case {
  display: flex;
  flex-direction: column;
  padding: var(--space-6);
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-1);
}
.case__sector {
  display: inline-block;
  align-self: flex-start;
  margin-bottom: var(--space-4);
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-pill);
  background: var(--bg-page);
  border: 1px solid var(--border-subtle);
  font-size: var(--fs-overline);
  line-height: calc(var(--lh-caption) * 1.5);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  font-weight: var(--fw-medium);
  color: var(--fg-2);
}
.case__figure {
  font-family: var(--font-sans);
  font-weight: var(--fw-regular);
  font-size: clamp(var(--fs-h1), 5vw, var(--fs-display-2));
  line-height: var(--lh-display-2);
  letter-spacing: var(--tk-display-2);
  color: var(--fg-1);
  font-variant-numeric: tabular-nums;
}
.case__figure-label {
  display: block;
  margin-top: var(--space-2);
  font-family: var(--font-sans);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  font-weight: var(--fw-medium);
  color: var(--fg-2);
  letter-spacing: normal;
}
.case__body {
  margin-top: var(--space-5);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
  flex: 1 1 auto;
}
.case__org {
  margin-top: var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--border-subtle);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}
.case__meta {
  display: block;
  margin-top: var(--space-1);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}
.case--placeholder { border-style: dashed; border-color: var(--border-default); box-shadow: none; }
.case--placeholder .case__figure { color: var(--fg-muted); }

/* Controls above the track, aligned right. Reworked twice on 2026-08-17.
   First the buttons were EMPTY, with a `.cases__btn svg` rule and no svg in the
   markup, so the row below the track rendered as two blank outlined circles
   nobody could read as prev and next. They gained chevrons and moved to the
   sides of the track.
   Then the sides turned out to be unusable: to clear the cards the buttons have
   to sit outside the track, and the container only has ~50px of gutter at 1280
   against the 56 a button and its gap need, so below roughly 1300 they landed
   on top of the first and last case study. Overlapping a case study was the
   thing being fixed, so the position had to go rather than be tuned.
   Above the track, on the heading's line, clears the cards at every width and
   still leaves nothing below the carousel. */
.cases__controls {
  display: flex;
  gap: var(--space-2);
  justify-content: flex-end;
  margin-bottom: var(--space-4);
}
.cases__controls[hidden] { display: none; }

.cases__btn {
  width: var(--space-7);
  height: var(--space-7);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-pill);
  /* It sits on top of a card now, so it needs lift to separate from one. */
  box-shadow: var(--shadow-2);
  cursor: pointer;
  color: var(--fg-1);
  transition: background-color var(--dur-hover) var(--ease-in-out),
              border-color var(--dur-hover) var(--ease-in-out),
              opacity var(--dur-hover) var(--ease-in-out);
}
.cases__btn:hover { background: var(--bg-page); border-color: var(--border-strong); }
/* Dimmed at the ends of the track, never removed: a control that disappears
   moves the one beside it and the reader loses the target they were aiming at. */
.cases__btn[disabled] { opacity: 0.4; cursor: default; box-shadow: var(--shadow-1); }
.cases__btn svg { width: var(--space-5); height: var(--space-5); }

/* ==========================================================================
   Resource link. A whitepaper or report, presented as a real thing to take
   away rather than another button.
   ========================================================================== */

.resource {
  display: grid;
  gap: var(--space-5);
  align-items: center;
  padding: var(--space-6);
  background: var(--bg-page);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xl);
}
.resource__kind {
  font-size: var(--fs-overline);
  line-height: var(--lh-overline);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  font-weight: var(--fw-medium);
  color: var(--fg-3);
  margin-bottom: var(--space-3);
}
.resource h3 { margin-bottom: var(--space-3); }
.resource p {
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}
.resource__cta { align-self: start; }

/* ==========================================================================
   Logo wall
   ========================================================================== */

.logo-wall {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-3);
  margin-top: var(--space-5);
}
.slot {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: var(--space-9);
  padding: var(--space-4);
  background: var(--bg-surface);
  border: 1px dashed var(--border-default);
  border-radius: var(--radius-md);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-muted);
  text-align: center;
}

/* ==========================================================================
   Panels and forms
   ========================================================================== */

.panel {
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xl);
  padding: var(--space-6);
  box-shadow: var(--shadow-1);
}
.panel--sunken { background: var(--bg-page); box-shadow: none; }

/* ==========================================================================
   Size router. One question that decides which of two routes a company gets.
   Rebuilt 2026-08-17: it was a bare select stacked on a Continue button, with a
   22rem dashed placeholder box under it, which is what "dreadful" referred to.
   The question and its button now sit on one line from 768 up, and the answer
   opens as a keyed card rather than as more unstyled prose.
   Both result cards are in the markup and merely unhidden, so with JavaScript
   off the reader sees both routes and nothing is lost.
   ========================================================================== */

/* The pitch beside the form. A reader deciding whether to hand over an address
   should be able to see what the half hour consists of while they type, which a
   lead paragraph above a panel does not achieve once the panel is tall. */
.book { display: grid; gap: var(--space-6); }
/* The demo pitch's three points. A div of paragraphs since 2026-08-27, not a
   ul: the aqua dots came off and a list with no markers is a list pretending
   not to be one. Keeps the rule above it and the smaller type, which is what
   separated the points from the lead in the first place. */
.book__points {
  margin-top: var(--space-6);
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
}
.book__points p {
  margin: 0;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}
.book__points p + p { margin-top: var(--space-3); }
.book__form { min-width: 0; }

@media (min-width: 900px) {
  .book {
    grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    gap: var(--space-8);
    align-items: start;
  }
  /* Sticks while the form scrolls past it, so the reason to fill it in stays on
     screen for the whole form. */
  .book__pitch { position: sticky; top: calc(var(--nav-h) + var(--space-6)); }
}

/* The answer. Keyed to the brand purple like the offer card on the public
   institutions page: it is the one thing in this section a reader acts on. */
.router__result {
  margin-top: var(--space-6);
  padding: var(--space-6);
  background: var(--bg-brand-soft);
  border-radius: var(--radius-lg);
}
.router__result[hidden] { display: none; }
.router__result:focus-visible { outline: 2px solid var(--wb-purple); outline-offset: 4px; }
.router__result h3 { margin: var(--space-2) 0 var(--space-3); }
.router__result > p {
  max-width: 56ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}

.router__cta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3) var(--space-5);
  margin-top: var(--space-5);
}
/* The second route stays available but quiet, so the recommended one is
   obviously the recommended one. */
.router__alt { font-size: var(--fs-body-sm); }

.form { display: grid; gap: var(--space-4); margin-top: var(--space-5); }
.form__row { display: grid; gap: var(--space-4); }

.field { display: grid; gap: var(--space-2); }
.field label {
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}

/* Required, marked so it can be seen. ADV-57: every form on the site carried
   the required attribute and showed nothing, and an attribute is a validation
   rule rather than an indication. whalebone.io's own convention, measured
   2026-08-26, is an asterisk appended to the label text ("First name*"), so
   that is what this is rather than an invented marker.

   Muted, not coral. Every required field on these forms is marked, so the
   asterisk is the norm and not an alert, and colouring the majority of labels
   with the alert colour would spend the coral budget on punctuation.

   aria-hidden in the markup, because a screen reader already announces the
   control as required from its own attribute, and "star" on top of that is
   noise. */
.req {
  margin-left: 2px;
  color: var(--fg-3);
  font-weight: var(--fw-regular);
}
.section--inverse .req,
.newsletter .req { color: var(--fg-on-dark-muted); }
.field input,
.field select,
.field textarea {
  width: 100%;
  padding: var(--space-3) var(--space-4);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  font-family: var(--font-sans);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-1);
}
.field textarea { min-height: calc(var(--space-9) + var(--space-6)); resize: vertical; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--wb-purple); }
.field input::placeholder, .field textarea::placeholder { color: var(--fg-muted); }
.field input:disabled, .field select:disabled, .field textarea:disabled {
  background: var(--bg-sunken);
  color: var(--fg-muted);
}

.form__note {
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}

/* The thank you. Rebuilt 2026-09-09: Nikolas said it read as generic rather
   than as this brand, and it did. It was a pale mint slab with a full aqua
   border, which is the shape every framework ships as its "success alert" and
   which nothing else on this site looks like.

   It is a .panel now, because that IS the site's box: white surface, hairline
   border, large radius, shadow-1. Aqua stays a small accent rather than a fill,
   per the colour budget in DESIGN.md, and it is spent on one thing, the mark.
   Body size rather than body-sm: this is the sentence the reader came for, so
   it should not be set smaller than the form it replaces. */
.form-success {
  display: none;
  align-items: center;
  gap: var(--space-4);
  margin-top: var(--space-2);
  padding: var(--space-5);
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-1);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-1);
}
.form-success.is-visible { display: flex; }
.form-success:focus-visible { outline: 2px solid var(--wb-purple); outline-offset: 4px; }

/* The tick is the one already drawn in the address strip, reused rather than
   redrawn, in a soft aqua disc. Sized off the space scale so there is no raw
   px here either: 48px disc, 12px inset, a 24px glyph. */
.form-success__mark {
  flex: none;
  width: var(--space-7);
  height: var(--space-7);
  padding: var(--space-3);
  border-radius: 50%;
  background: var(--bg-success-soft);
  color: var(--wb-teal);
}

/* On the newsletter's navy band the white box is the point: it is the one thing
   that changes on that band, so it should read as arriving rather than as part
   of the furniture. Only the mark's disc needs help, since a pale tint on navy
   inside a white box is still on white. */
.newsletter .form-success { text-align: left; }

/* The failure state. Same shape as the success box so a reader meets one
   component in two moods, not two components. Coral is the alert colour and
   the budget says tiny amounts, so it is the border only: the fill stays
   surface white rather than becoming a second tinted band. */

}


/* A one-line hint under a field, for the case where the label cannot carry the
   rule: the trial form needs to say that company size is a number of people,
   because the CRM property is numeric and a band would not read. ADV-98. */
.field__hint {
  margin: var(--space-2) 0 0;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}

.form-error {
  display: none;
  margin-top: var(--space-2);
  padding: var(--space-5);
  border-radius: var(--radius-lg);
  background: var(--bg-surface);
  border: 1px solid var(--semantic-danger);
  box-shadow: var(--shadow-1);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-1);
}
.form-error.is-visible { display: block; }

/* ==========================================================================
   Consent. An affirmative act rather than a sentence under a button.
   Added 2026-09-09 (ADV-87). The wording and the link label are the real
   DNS4EU form's, supplied by Nikolas; nothing here is drafted.

   Two rows beside one box: the sentence, then the link, which is how the
   supplied design reads. The box spans both rows so it sits against the whole
   block rather than floating beside the first line.
   ========================================================================== */

.consent {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: var(--space-3);
  row-gap: var(--space-1);
  align-items: start;
  margin-top: var(--space-2);
}

.consent__box {
  grid-column: 1;
  grid-row: 1 / span 2;
  width: var(--space-4);
  height: var(--space-4);
  /* Aligns the box to the cap height of the first line rather than to the top
     of its line box, which sits it a couple of pixels high next to 14px text. */
  margin: 0.2em 0 0;
  accent-color: var(--wb-teal);
}

.consent__text,
.consent__more {
  grid-column: 2;
  margin: 0;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}
.consent__text { cursor: pointer; }

/* On the inverse newsletter band the two text rows and the required mark all
   need the on-dark pair, the same way .form__note does on /private. */
.newsletter .consent__text,
.newsletter .consent__more { color: var(--fg-on-dark-muted); }

/* Honeypot. Hidden from people, still reachable by bots. Not display:none,
   which many bots skip. */
.honeypot {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.contact-email {
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  font-weight: var(--fw-medium);
}

/* The three ways to reach us on /contact, ADV-96. A definition list because
   that is what it is: a route and what it is for. No new decoration, no card,
   no icon; the term carries the weight and the hairline separates the rows,
   which is the same hairline the header and the address strip already use. */
.reach { margin-top: var(--space-5); }
.reach dt {
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}
.reach dd {
  margin: var(--space-1) 0 0;
  padding-bottom: var(--space-5);
  border-bottom: 1px solid var(--border-subtle);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-2);
}
.reach dd + dt { margin-top: var(--space-5); }
.reach dd:last-child { border-bottom: 0; padding-bottom: 0; }

/* ==========================================================================
   Footer. The navy band
   ========================================================================== */

.site-footer {
  background: var(--bg-inverse);
  color: var(--fg-on-dark);
  padding-block: var(--space-8) var(--space-6);
  /* No margin. A gap here shows the white page behind, which reads as a
     seam between the last section and the footer rather than as space. */
}
.footer__top { display: grid; gap: var(--space-6); }
.footer__brand-logo { height: 28px; width: auto; display: block; margin-bottom: var(--space-4); }
.site-footer p {
  color: var(--fg-on-dark-subtle);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
}
/* Footer column headings. Rendered as small overline labels, but they are
   real h2s so the document outline has no level skips. */
.site-footer h2 {
  margin-bottom: var(--space-4);
  font-family: var(--font-sans);
  font-weight: var(--fw-medium);
  font-size: var(--fs-overline);
  line-height: var(--lh-overline);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  color: var(--wb-teal-200);
}
.footer__links li + li { margin-top: var(--space-3); }
.footer__links a {
  color: var(--fg-on-dark-muted);
  font-size: var(--fs-body-sm);
}
.footer__links a:hover { color: var(--fg-on-dark); }

.footer__powered {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-4);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-on-dark-subtle);
}
.footer__powered img { height: 14px; width: auto; display: block; }

/* Social channels, under the powered-by lockup. Written out rather than drawn:
   the platform marks are not ours to redraw and PATTERNS.md does not carry
   them. Same muted-on-dark treatment as the rest of the footer. */
.footer__socials {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  margin-top: var(--space-5);
}
.footer__socials a {
  color: var(--fg-on-dark-muted);
  font-size: var(--fs-body-sm);
}
.footer__socials a:hover { color: var(--fg-on-dark); }

/* The small print under the columns. Measured against joindns4.eu's own footer
   on 2026-08-20: it sets the co-funding line and the operator's registration in
   a narrow column, roughly 400px, not across the full width. Ours ran the whole
   1265px, which turned two lines of legal text into a band as wide as the site
   and made the bottom of every page feel like another section.

   Narrower and tighter: one hairline instead of two, 48ch rather than 80ch, and
   the stack below the columns comes down from 48+24+24+24 to 32+20+16+20. */
.footer__eu {
  display: flex;
  gap: var(--space-4);
  align-items: flex-start;
  margin-top: var(--space-6);
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-on-dark);
}
.footer__eu .flag { flex: none; }
.footer__eu p { font-size: var(--fs-caption); line-height: var(--lh-caption); max-width: 48ch; }

.footer__bottom {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-5);
  justify-content: space-between;
  align-items: baseline;
  margin-top: var(--space-4);
  padding-top: 0;
  border-top: 0;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-on-dark-subtle);
}
/* The operator's registered name and address is the longest line in the footer
   and the least likely to be read. It gets a column, not a banner. */
/* The operator block is two paragraphs now, not one span: the registered name,
   the company and VAT numbers and the register entry are a legal block, and a
   reader looking for one of them scans rather than reads. */
.footer__bottom > .footer__operator { max-width: 62ch; }
.footer__operator p + p { margin-top: var(--space-2); }
.footer__bottom-links { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-5); }
.footer__bottom-links a { color: var(--fg-on-dark-subtle); }

.footer__operator a,
.newsletter__note a {
  color: var(--fg-on-dark);
  text-decoration: underline;
  text-underline-offset: 2px;
}
.footer__operator a:hover,
.newsletter__note a:hover { text-decoration-thickness: 2px; }

/* ==========================================================================
   Motion. Respects prefers-reduced-motion
   ========================================================================== */

.reveal {
  opacity: 0;
  transform: translateY(var(--space-3));
  transition: opacity var(--dur-slow) var(--ease-out), transform var(--dur-slow) var(--ease-out);
}
.reveal.is-in { opacity: 1; transform: none; }

/* Stagger. --reveal-i is set by the script from the element's position among its
   revealing siblings, so a grid cascades instead of snapping in as one block.
   Capped in JS, or a long list ends up waiting seconds for its last item. */
.reveal { transition-delay: calc(var(--reveal-i, 0) * var(--dur-stagger)); }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .reveal { opacity: 1; transform: none; transition-delay: 0s; }
}

/* ==========================================================================
   Breakpoints. Mobile-first, so these only add
   ========================================================================== */


/* ==========================================================================
   Stat band. The numbers as a full-width band rather than three cards, and
   they count up when the band is first scrolled into view. The count is
   decoration on top of real text: the final value is in the markup, so with
   no JavaScript, or under reduced motion, the correct number is simply there.
   ========================================================================== */

.stat-band { padding-block: var(--space-7); }

.stat-band__grid {
  display: grid;
  gap: var(--space-7);
  text-align: center;
}

.stat-band__item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-2);
}

.stat-band__value {
  font-size: clamp(var(--fs-h1), 6vw, var(--fs-display-2));
  line-height: 1;
  font-weight: var(--fw-medium);
  color: var(--wb-teal-100);
  /* the digits change width as they animate, which makes the row jitter */
  font-variant-numeric: tabular-nums;
}

.stat-band__label {
  max-width: 26ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  color: var(--fg-on-dark);
}

.stat-band__source {
  font-size: var(--fs-overline);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  color: var(--fg-on-dark-subtle);
}

/* A figure nobody has confirmed must not look like one that has been. */
.stat-band__item--soft .stat-band__value { color: var(--fg-on-dark-muted); }

/* A hairline between items, not boxes around them. */
@media (min-width: 768px) {
  .stat-band__grid {
    grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
    gap: var(--space-6);
  }
  /* a rule between neighbours, but not down the left edge of a wrapped row */
  .stat-band__item + .stat-band__item {
    border-left: 1px solid var(--border-on-dark);
  }
}

/* ==========================================================================
   Newsletter band. One field, one choice, one button.
   ========================================================================== */

/* Sits inside the routes section, so the separation has to be margin. The
   tiles above are dense; this needs to read as a different thing entirely. */
/* Sits inside the routes section, so the separation has to be margin. It also
   gets its own dark surface: on a white page the band was a headline and a form
   floating in space with nothing to hold them together. */
.newsletter {
  text-align: center;
  margin-block: var(--space-9);
  padding: var(--space-8) var(--space-5);
  background: var(--bg-inverse);
  border-radius: var(--radius-xl);
  position: relative;
  overflow: hidden;
}

/* One aqua arc, bled off the corner. The only decoration on the page, and it is
   a brand colour on a brand surface rather than a new primitive. */
.newsletter::before {
  content: "";
  position: absolute;
  top: -40%;
  right: -10%;
  width: 60%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle, var(--fill-teal-soft) 0%, transparent 68%);
  pointer-events: none;
}

/* The newsletter closes the homepage. Its symmetric bottom margin stacks on top
   of the section's own bottom padding, which banked 228px of empty white above
   the footer. When it is the last thing in its container the section padding is
   the whole spacing job, so the margin goes. */
.newsletter:last-child { margin-bottom: 0; }

.newsletter > * { position: relative; }
.newsletter h2 { color: var(--fg-on-dark); }
.newsletter .newsletter__lead { color: var(--fg-on-dark-muted); }
.newsletter .newsletter__note { color: var(--fg-on-dark-muted); }
.newsletter label { color: var(--fg-on-dark); }

.newsletter h2 { margin-inline: auto; }

.newsletter__lead {
  max-width: 62ch;
  margin: var(--space-4) auto 0;
}

.newsletter__form {
  display: grid;
  gap: var(--space-3);
  max-width: 46rem;
  margin: var(--space-7) auto 0;
  text-align: left;
}

.newsletter__note {
  max-width: 62ch;
  margin: var(--space-4) auto 0;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-3);
  text-align: center;
}

@media (min-width: 900px) {
  .newsletter { margin-block: var(--space-10); padding: var(--space-9) var(--space-8); }
}

/* Two columns, not three. The three were written when this form had an email,
   an organization type and a button; the type went on 2026-09-08 and nobody
   moved the grid, so the email field sat in a third of the width with the
   consent note filling the column the select had vacated. Nikolas asked for a
   bigger box on 2026-09-09 and that is the whole fix: one field, one button,
   and everything else spanning underneath. */
@media (min-width: 768px) {
  .newsletter__form {
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: end;
    gap: var(--space-4);
  }
  .newsletter__form > .consent,
  .newsletter__form > .form-success,
  .newsletter__form > .form-error { grid-column: 1 / -1; }
}

/* ==========================================================================
   Resource, featured. The whitepaper is the one thing to take away from the
   governments page, so it gets a visual and room to breathe.
   ========================================================================== */

.resource.resource--feature {
  display: grid;
  gap: var(--space-7);
  align-items: center;
  padding: var(--space-7);
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-1);
}

.resource__media {
  border-radius: var(--radius-lg);
  overflow: hidden;
  aspect-ratio: 16 / 10;
}

/* A document cover is portrait, so the 16/10 crop would slice the title off it.
   Same reasoning as .testimonial__media--cover: the cover is the thing being
   offered, so it keeps its own proportions and fills the column. */
.resource__media--cover {
  aspect-ratio: auto;
  margin: 0;
}
.resource__media--cover img {
  width: 100%;
  max-width: 26rem;
  height: auto;
  object-fit: contain;
  display: block;
  margin-inline: auto;
}

.resource__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.resource__body > * + * { margin-top: var(--space-4); }

@media (min-width: 900px) {
  .resource.resource--feature {
    grid-template-columns: 1fr 1.15fr;
    gap: var(--space-8);
    padding: var(--space-8);
  }
}

/* ==========================================================================
   Sector card. Who the protection page is actually for, one card per kind of
   institution, each naming the constraint that makes DNS the right layer.
   ========================================================================== */

.sector { display: flex; flex-direction: column; gap: var(--space-3); }
.sector__kind {
  font-size: var(--fs-overline);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  color: var(--wb-purple);
}
.sector__why {
  margin-top: auto;
  padding-top: var(--space-4);
  border-top: 1px solid var(--border-subtle);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}


/* ==========================================================================
   Case carousel, unboxed. Cards had borders and a surface; they now sit
   directly on the page with only air between them, so the track reads as one
   continuous shelf rather than a row of boxes.
   ========================================================================== */

/* These used to strip the tile off a case study and leave it as a bare column.
   Beside the homepage, where every card is a white tile, the case studies then
   read as the one unfinished thing on the page. A case study is a tile like any
   other, so the values are restated here rather than deleted.

   Restated, not deleted, on purpose. The HubSpot port carries the delta between
   this file and the older copy sitting in File Manager, and a delta can only add
   rules. A rule we merely delete stays alive over there and goes on winning on
   specificity. Anything that used to override the tile has to be overridden
   back, in as many words. See HUBSPOT.md. */
.cases--plain .case {
  /* Pale, not white. /protection is the only page with three sections holding a
     flat surface and one dark band to break them up, so one of them has to
     invert, and this is it: a photograph reads on either ground and two of the
     five cases have no photograph to carry them. 2026-08-27. */
  background: var(--bg-sunken);
  border: 0;
  border-radius: var(--radius-md);
  box-shadow: none;
  padding: var(--space-6) var(--space-7);
}

.cases--plain .cases__track { gap: var(--space-7); }

/* the one line that still needs to divide: the figure from the story */
.cases--plain .case__body { margin-top: var(--space-4); }

/* A named person saying it is worth more than another line of our prose.

   THE KEYLINE IS GONE, 2026-08-27. Nikolas called it "hnusna cara" and asked
   for a quotation mark at the side instead. This reverses the 2026-08-17
   decision recorded further down this file, that a mark this size is worth
   something exactly once per page and belongs only to `.quote-band`. That
   decision was made about the PULL quote, at pull-quote size, and five of those
   in a row is what read as noise. The mark here is smaller and the cards it
   sits on are plain, so the change is a size question, not a reversal of the
   reasoning. The band's own rule is untouched.

   One mark, opening only. A closing mark would have to land between the quote
   and the <cite>, and the cite is the close: the reader knows the sentence
   ended because somebody's name follows it. */
.case__quote {
  margin: var(--space-5) 0 0;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-1);
}
.case__quote-text { margin: 0; }
.case__quote-text::before {
  content: "\201C";
  display: block;
  /* 700, the same exception the band's marks take: a quotation mark is a
     decorative glyph rather than type in the hierarchy, and at 500 it renders
     as a speck. The ban on 600/700 is about headings and body copy.
     line-height under 1 pulls the text back up under the glyph, which sits in
     the top third of its own em box, so the mark does not open a gap the size
     of its point size above the sentence it belongs to. */
  font-size: 2.8em;
  font-weight: 700;
  line-height: 0.62;
  margin-bottom: 0.1em;
  color: var(--wb-teal);
}
.case__quote cite {
  display: block;
  margin-top: var(--space-2);
  font-style: normal;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}

.cases--plain .case__meta {
  margin-top: var(--space-4);
  padding-top: var(--space-4);
  border-top: 1px solid var(--border-subtle);
}

/* ==========================================================================
   Zig-zag. Three cards alternating side to side. No spine and no bullets:
   the alternation is the structure, which is why nothing else marks it.
   Below the split they stack full width, because at that size a zig-zag of
   narrow cards is just a wobble.
   ========================================================================== */

.zig {
  display: grid;
  gap: var(--space-5);
  margin-top: var(--space-7);
}

.zig__card {
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-1);
  padding: var(--space-6);
}

.zig__card h3 { margin: var(--space-2) 0 var(--space-3); }
/* 48ch, down from 56 on 2026-08-17 with the rest of the compaction. The prose
   is the part of this component that was making the section long. */
.zig__card > p {
  max-width: 48ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}

/* the figure this sector's case study produced. Scaled back with the card:
   at h2 size inside a narrower tile the number was the only thing on it. */
.zig__figure {
  margin-top: var(--space-4);
  padding-top: var(--space-4);
  border-top: 1px solid var(--border-subtle);
  font-size: clamp(var(--fs-h4), 2.2vw, var(--fs-h3));
  line-height: 1.1;
  font-weight: var(--fw-medium);
  color: var(--wb-purple);
}
.zig__figure-label {
  display: block;
  margin-top: var(--space-2);
  max-width: 46ch;
  font-size: var(--fs-caption);
  line-height: var(--lh-body-sm);
  font-weight: var(--fw-regular);
  letter-spacing: normal;
  color: var(--fg-3);
}

@media (min-width: 900px) {
  /* 52%, down from 62% on 2026-08-17, and the padding with it. Same trajectory
     as the 78 to 62 move before it: the offset is the only structure this
     component has, and every point the card gives back makes it more visible.
     At 52% the three tiles also stop being the tallest thing on the page. */
  .zig { gap: var(--space-6); }
  .zig__card { width: 52%; padding: var(--space-6); }
  .zig__card--right { margin-left: auto; }
}

/* ==========================================================================
   Case study card. Sector, organisation, story, quote, link. No picture, and on
   /protection no figure either: those numbers live in the zig-zag above and
   printing them twice is what made that page read badly.
   ========================================================================== */

/* NO PICTURES ON A CASE CARD, and this block is what used to put them there.
   `.case__media`, `.case__media--ph` and `.case__ph-note` are all deleted
   2026-08-27, on the instruction to use /private's card on /protection 1:1 with
   no images. Nothing in the site uses them any more.

   Worth keeping the reasoning rather than the rules, because this went round
   three times in a day. First the page carried photographs out of the Immunity
   case study PDFs, which ADV-37 has never cleared for use on a DNS4EU page.
   Then every card got a navy holding frame with the DNS4EU mark, which cleared
   that problem and added five placeholders. Then the frames came off too, and
   the cards are the ones on /private: no image at all.

   That is the simplest of the three and it is the only one with nothing
   outstanding attached to it. If pictures ever come back, they need clearing
   first, and `.case--nomedia`, deleted the same day, is the trap waiting at the
   other end: three photographs beside two bare cards is worse than five bare
   cards. */

.cases--plain .case h3.case__org {
  margin: var(--space-2) 0 var(--space-3);
  font-size: var(--fs-h3);
}


/* The private-company band. Same component as the free service, but keyed
   away from aqua: aqua marks the free public service and must not be borrowed
   by the thing that is explicitly not it. */
.service-band--private { border-color: var(--border-default); }


.zig__link { margin-top: var(--space-4); font-size: var(--fs-body-sm); line-height: var(--lh-body-sm); }

/* ==========================================================================
   Edge tab. A blocked site the user needs is the one failure they cannot work
   around, so reporting it stays reachable from anywhere on the page. Pinned to
   the right edge on a wide screen; below the split it is hidden and the footer
   link carries it instead, because a fixed tab on a phone covers content.
   ========================================================================== */

.edge-tab {
  position: fixed;
  right: 0;
  top: 50%;
  z-index: 20;
  transform: translateY(-50%) rotate(180deg);
  writing-mode: vertical-rl;
  padding: var(--space-4) var(--space-2);
  background: var(--wb-navy);
  color: var(--fg-on-dark);
  text-decoration: none;
  border-radius: 0 var(--radius-md) var(--radius-md) 0;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
}
.edge-tab:hover { background: var(--wb-purple); }
.edge-tab:focus-visible { outline: 2px solid var(--wb-teal); outline-offset: 2px; }

@media (max-width: 899px) {
  .edge-tab { display: none; }
}

@media (prefers-reduced-motion: no-preference) {
  .edge-tab { transition: background-color var(--dur-hover) var(--ease-in-out); }
}


/* The two cards most often reached by the wrong reader say so with a badge.
   Aqua marks the free public service; the private-company card is keyed away
   from it deliberately, since its job is to say "not this one". */
.route--free { border-color: var(--wb-teal-200); }


/* ==========================================================================
   Route hierarchy. The two organization routes are what this page is for, so
   they are set a step larger. Everything else about the card is identical:
   the difference is type size and air, not a different component.
   ========================================================================== */

/* The audience is the heading now. It is the word a visitor scans for, and it
   was previously an 11px overline above a headline about something else. */
.route h3 {
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
  margin-bottom: var(--space-2);
}
.route__tagline {
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-2);
  margin-bottom: var(--space-3);
}

.route--primary { padding: var(--space-7); }
.route--primary h3 { font-size: clamp(var(--fs-h3), 2.6vw, var(--fs-h2)); }
.route--primary .route__tagline { font-size: var(--fs-body-lg, var(--fs-body)); }
.route--primary .route__icon { width: var(--space-8); height: var(--space-8); }

/* The routing question between the pairs. Not a section heading, just a
   signpost for the reader who is neither of the two above. */
.routes__aside {
  margin: var(--space-8) 0 var(--space-5);
  text-align: center;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  color: var(--fg-3);
}

@media (min-width: 900px) {
  .route--primary { padding: var(--space-8); }
}

/* ==========================================================================
   Testimonial. One case in the customer's own words, picture beside quote.
   The offset panel behind the photograph is a flat brand colour, not a
   gradient: it gives the image an edge to sit against without adding a second
   colour to the section.
   ========================================================================== */

.testimonial {
  display: grid;
  gap: var(--space-7);
  align-items: center;
  margin-top: var(--space-9);
}

/* Straight under a section heading the 144px above is a hole. 2026-08-27. */
.section-label + .testimonial { margin-top: var(--space-7); }

.testimonial__media {
  position: relative;
  margin: 0;
  isolation: isolate;
}
.testimonial__media::before {
  content: "";
  position: absolute;
  inset: auto auto 0 0;
  translate: calc(var(--space-5) * -1) var(--space-5);
  width: 100%;
  height: 100%;
  border-radius: var(--radius-xl);
  background: var(--wb-teal-100);
  z-index: -1;
}
.testimonial__media img {
  display: block;
  width: 100%;
  height: 100%;
  max-height: 30rem;
  object-fit: cover;
  border-radius: var(--radius-xl);
}

.testimonial__mark {
  font-size: var(--fs-display-2);
  line-height: 0.6;
  color: var(--wb-teal);
  margin-bottom: var(--space-4);
}

.testimonial__quote {
  margin: 0;
  font-family: var(--font-display);
  font-size: clamp(var(--fs-h3), 2.4vw, var(--fs-h2));
  line-height: var(--lh-h2);
  color: var(--fg-1);
  max-width: 34ch;
}

.testimonial__org {
  margin-top: var(--space-6);
  font-size: var(--fs-h3);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}
.testimonial__who {
  margin-top: var(--space-1);
  font-weight: var(--fw-medium);
  color: var(--fg-2);
}
.testimonial__who span {
  font-weight: var(--fw-regular);
  color: var(--fg-3);
}
.testimonial__who span::before { content: " · "; }

.testimonial__takeaway {
  margin-top: var(--space-6);
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}
.testimonial__cta { margin-top: var(--space-5); }

@media (min-width: 900px) {
  .testimonial {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-9);
    margin-top: var(--space-10);
  }
}


/* ==========================================================================
   Gated download dialog. Native <dialog>, so focus trapping, Escape and the
   backdrop come from the browser rather than from script.
   ========================================================================== */

.modal {
  /* The close button sits inside this box rather than in a row of its own, so
     it costs nothing vertically. */
  position: relative;
  width: min(36rem, calc(100vw - var(--space-6)));
  /* A dialog taller than the window is worse than a scrollbar, so the scroll
     stays as a floor for a short window. It should not be reached on a laptop. */
  max-height: min(88vh, 44rem);
  overflow: auto;
  padding: var(--space-6);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xl);
  background: var(--bg-surface);
  color: var(--fg-1);
  box-shadow: var(--shadow-2);
}
.modal::backdrop { background: var(--scrim-modal, rgba(5, 5, 43, 0.55)); }

.modal h2 { margin-bottom: var(--space-3); }
.modal .lead { margin-bottom: var(--space-4); }
.modal .form { margin-top: 0; }

.modal__dismiss {
  position: absolute;
  top: var(--space-2);
  right: var(--space-2);
  z-index: 1;
}
/* Clear of the close button, which now overlaps the top-right corner. */
.modal > .eyebrow,
.modal h2 { padding-right: var(--space-8); }
.modal__close {
  width: 44px;
  height: 44px;
  border: 0;
  border-radius: var(--radius-md);
  background: none;
  cursor: pointer;
  position: relative;
}
.modal__close:hover { background: var(--bg-sunken); }
/* drawn rather than a glyph, so it cannot be an arrow or a stray character */
.modal__close::before,
.modal__close::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 16px;
  height: 2px;
  background: var(--fg-2);
}
.modal__close::before { translate: -50% -50%; rotate: 45deg; }
.modal__close::after { translate: -50% -50%; rotate: -45deg; }

/* ==========================================================================
   Pull quote. A named person on a dark band. Used where a claim about speed
   would be ours; the quote makes it theirs.
   ========================================================================== */

/* Quotation mark, not a keyline. This comment said exactly that from
   2026-08-17 while the rule below kept a keyline, because the marks were scoped
   to `.quote-band` and the pair on /private is not in a band. So the page said
   one thing and the stylesheet did another for ten days. Settled 2026-08-27, on
   the same instruction that took the keyline off the case cards: the aqua rule
   down the left read as a callout rather than as somebody speaking.

   Both of the pair get it. The 2026-08-17 pass decided a PAIR should keep the
   keyline because a mark that size is worth something once per page, and that
   still holds for the 3.4em band mark. This is the smaller card-sized mark, and
   consistency across the site's quotes is worth more than the distinction. */
.pullquote {
  margin: 0;
  font-size: clamp(var(--fs-body), 1.6vw, var(--fs-h3));
  line-height: var(--lh-h3);
  color: var(--fg-on-dark);
}
.pullquote__text::before {
  content: "\201C";
  display: block;
  font-size: 2.4em;
  font-weight: 700;
  line-height: 0.62;
  margin-bottom: 0.08em;
  color: var(--wb-teal);
}
.pullquote__text {
  margin: 0;
  font-size: inherit;
  line-height: inherit;
  color: inherit;
}
.pullquote cite {
  display: block;
  margin-top: var(--space-4);
  font-style: normal;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  font-weight: var(--fw-medium);
  color: var(--fg-on-dark);
}
.pullquote cite span {
  display: block;
  font-weight: var(--fw-regular);
  color: var(--fg-on-dark-muted);
}

/* The same component on a light band. The base rule is written for a dark
   surface, and the public institutions page needs the quote on white, between
   the objection cards and the numbers. Colour only: everything else is shared. */
.pullquote--light { color: var(--fg-1); }
.pullquote--light cite { color: var(--fg-1); }
.pullquote--light cite span { color: var(--fg-3); }

/* ==========================================================================
   Big quotation marks. ONLY on a quote that is genuinely alone.
   ==========================================================================
   Scoped to `.quote-band`, which is the wrapper that already means "this quote
   stands on its own rather than sitting in a column". So the marks follow the
   structure and cannot be applied by accident: put a quote in a band and it is
   marked, put two side by side and they keep the keyline.

   Settled 2026-08-17 after two passes. The marks first went on every quote on
   the site, which made five carousel cards in a row read as noise. Then they
   stayed on both `.pullquote`s in "Protected in no time" on `/private`, which
   are a PAIR, so neither was the single quote the treatment is for. A mark this
   size is worth something exactly once per page.
   ========================================================================== */

.quote-band .pullquote { padding-left: 0; border-left: 0; }

.quote-band .pullquote__text::before,
.quote-band .pullquote__text::after {
  content: "\201C";
  font-size: 3.4em;
  /* 700, the ONE place on this site the sans goes above 500. Asked for
     2026-08-17: "big and bold". A quotation mark is a decorative glyph rather
     than type in the hierarchy, and at 500 it renders as a speck beside the
     sentence it is meant to frame. The ban on 600/700 is about headings and
     body copy looking heavy, and it holds everywhere else.
     line-height 0 stops a 45px glyph from opening up the line box it sits in;
     the vertical-align then drops it back onto the text. */
  font-weight: 700;
  line-height: 0;
  vertical-align: -0.42em;
  color: var(--wb-teal);
}
.quote-band .pullquote__text::before { margin-right: 0.04em; }
.quote-band .pullquote__text::after { content: "\201D"; margin-left: 0.02em; }

/* Room around a quote that stands on its own rather than sitting in a column. */
.quote-band { margin: var(--space-8) 0 0; }
@media (min-width: 900px) {
  .quote-band { margin-top: var(--space-9); max-width: 62%; }
}

/* Case cards with no photograph. The supplied commercial PDFs carry product
   screenshots and brand artwork, not pictures of the companies, and five empty
   frames would say less than five quotes do. */
.cases--quotes .case__figure--sm {
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
  color: var(--wb-purple);
  margin-bottom: var(--space-3);
}
.cases--quotes .case__org { margin-bottom: var(--space-2); }

@media (min-width: 600px) {
  .modal { padding: var(--space-8); }
}

@media (min-width: 600px) {
  .form__row { grid-template-columns: 1fr 1fr; }
  .resolvers { grid-template-columns: 1fr 1fr; }
  .stat-row { grid-template-columns: repeat(3, 1fr); }
  .cases__track { grid-auto-columns: 46%; }
  .resource { grid-template-columns: 1fr auto; gap: var(--space-7); }
  .logo-wall { grid-template-columns: repeat(3, 1fr); }
  .steps { grid-template-columns: repeat(3, 1fr); }
  .grid-3 { grid-template-columns: repeat(3, 1fr); }
  .section { padding-block: var(--space-9); }
  .hero { padding-block: var(--space-9); }
}

/* Tablet. Two-up where a single column would leave a card stretched across
   the full width, but still no hero artwork: the split needs 1200. */
@media (min-width: 768px) {
  .routes { grid-template-columns: 1fr 1fr; }
  /* Two pairs, each two across. The primary pair is the same width as the
     secondary one; the weight comes from type and padding, not from a
     lopsided grid, which would leave a ragged edge down the page. */
  .routes--primary,
  .routes--secondary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .grid-2 { grid-template-columns: 1fr 1fr; gap: var(--space-7); }
  .resolvers { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (min-width: 1100px) {
  .resolvers { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

@media (min-width: 900px) {
  .container { padding-inline: var(--space-6); }
  .nav__links { display: flex; align-items: center; gap: var(--space-6); }
  .lang { display: inline-flex; }
  .nav__toggle { display: none; }
  .cases__track { grid-auto-columns: 31%; }
  .service-band {
    grid-template-columns: 1.35fr 1fr;
    align-items: center;
    gap: var(--space-8);
    padding: var(--space-7);
  }

  /* Wide screens get the horizontal scrim, since the copy sits in a column on
     the left and the artwork should stay readable on the right. */
  .logo-wall { grid-template-columns: repeat(6, 1fr); }
  .footer__top { grid-template-columns: 1.4fr 1fr 1fr 1fr; gap: var(--space-7); }

  /* The bottom row sits on the SAME four columns as the row above it.
     It used to be a flex row with space-between, which pinned the two blocks to
     opposite edges of the container and left 425px of nothing between them at
     1280px wide. Worse than the hole: neither block lined up with anything. The
     operator ended in the middle of column two and the legal links started
     fifteen pixels short of column four, so the footer read as two rows that
     had been laid out by different people.

     Reusing the grid above rather than picking a new number: the operator takes
     the brand column and the one beside it, which is roughly the 62ch the cap
     was aiming at anyway, and the links take the last two, landing under the
     column headings instead of against the edge. */
  .footer__bottom {
    display: grid;
    grid-template-columns: 1.4fr 1fr 1fr 1fr;
    gap: var(--space-2) var(--space-7);
    align-items: start;
  }
  .footer__bottom > .footer__operator { grid-column: 1 / 3; max-width: none; }
  .footer__bottom-links { grid-column: 3 / 5; }

  .panel { padding: var(--space-7); }
}

/* Mobile menu. Only reachable below 900px, where the toggle is visible. */
@media (max-width: 899px) {
  .site-header.is-open .nav__links {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-4);
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    padding: var(--space-5);
    background: var(--bg-surface);
    border-bottom: 1px solid var(--border-subtle);
    box-shadow: var(--shadow-2);
  }
  .site-header.is-open .nav__links a {
    display: flex;
    align-items: center;
    width: 100%;
    min-height: 44px;
    font-size: var(--fs-body);
  }
  /* The header CTA STAYS VISIBLE on a phone. Nikolas, 2026-09-09: the one
     persistent action the site has does not get hidden behind a burger, and a
     second Contact inside the menu is not the answer either. It was hidden here
     from the start because an aqua pill beside the burger looked tight at 375px;
     measured rather than assumed, the row needs 282px of its 375 and the button
     is 97 of them, so it fits. Below 400px the padding tightens with the rest of
     the header. ADV-93. */
  .nav__right .btn {
    padding: var(--space-2) var(--space-4);
    min-height: 44px;
  }
  .brand__powered { display: none; }
}

/* ==========================================================================
   The split hero needs room for a copy column AND the full landmass beside
   it. The EU spans about 37% of the artwork, and a cover-crop takes more off
   the sides the taller the box gets, so below 1200 there is no width at
   which the copy clears Iberia without cropping the Baltics. Under that,
   the band keeps the flat tint and the copy runs full width: the map stays
   evenly visible behind it instead of half-buried under a gradient.
   ========================================================================== */

/* Below the split, the map band keeps the FLAT tint. This has to sit after the
   900px block, where .hero--image::before sets the horizontal gradient and
   would otherwise win on source order at equal specificity. */
/* The artwork is transparent now and sits to the right of the copy, so no
   overlay is needed at any width. The band is one flat colour throughout,
   which is what kills the parting for good. */
.hero--map::before { background: none; }

/* ==========================================================================
   Below the split, which is 1200 and not 900. The artwork switched on at 900
   while the copy column was only constrained at 1200, so every width in
   between put the headline on top of Europe, by 91 to 257px, with no scrim.
   The artwork is transparent and carries no scrim of its own, so there is
   nothing to lay text over. Hence: no artwork at all below 1200. The rest
   of the old note, kept because it still explains why stacking was tried:
   overlaying them was the wrong answer: the artwork is transparent so it
   carries no scrim of its own, which left text sitting directly on the
   country strokes. The hero stacks instead. Copy on the flat band at full
   contrast, artwork in its own band underneath, nothing overlapping.
   ========================================================================== */
@media (max-width: 1199px) {
  .hero--map {
    display: flex;
    flex-direction: column;
    min-height: 0;
  }
  .hero--map .container { order: 1; }

  /* No artwork on a phone. Nikolas's call: at this width the map earned no
     room, and dropping it means the request is never made either. */
  .hero--map .hero__media,
  .hero--map .netlayer { display: none; }

  /* The gateway hero was 86vh of a 812px screen before any content appeared. */
  .hero--map.hero--tall { padding-block: var(--space-7); }

  /* Touch targets. Footer and inline links were 19px high, well under the 44px
     minimum, and sat close enough together to mis-tap. */
  .footer__links a,
  .footer__bottom-links a {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
  .lang-select,
  .nav__toggle { min-height: 44px; }
  .nav__links a,
  .card p > a,
  .case__meta a,
  .zig__link a { display: flex; align-items: center; min-height: 44px; }
  .brand { min-height: 44px; }
  .copy-btn { min-height: 44px; min-width: 64px; }
}

/* Small phones. The header row alone needed 349px at 320, so it overflowed
   every page by 5px. The logo, the select arrow well and the gutters all give
   a little rather than any one of them shrinking to nothing. */
@media (max-width: 400px) {
  .container { padding-inline: var(--space-4); }
  .brand__logo { height: 20px; }
  .lang-select { padding-right: var(--space-6); }
  .nav { gap: var(--space-3); }
}

/* The artwork tier. These belong with the artwork, not at 900: they exist to
   give the map room, so below 1200 they would only stretch an empty band. */
@media (min-width: 1200px) {
  .hero--image::before { background: var(--scrim-image-h); }

  /* The map hero needs its own scrim here, and it has to come after the line
     above: both selectors are one class plus a pseudo-element, so the later one
     wins, and --scrim-image-h was quietly overriding the flat scrim set at
     .hero--map::before.

     That mattered. --scrim-image-h holds 0.88 opacity out to 62% of the width.
     Iberia renders between 60% and 68% of the artwork, so Portugal and Spain sat
     under the darkest part of the falloff while Germany and Poland, past 82%,
     came through clean. The artwork itself is even: every EU member state is
     fill-opacity 0.26, Iberia included, so this was the overlay and nothing else.

     --scrim-image-split exists for exactly this shape: a plateau over the copy
     column, fully clear by 60%, which is where the landmass starts. */
  .hero--map::before { background: var(--scrim-image-split); }

  .hero--image {
    display: flex;
    align-items: center;
    min-height: 58vh;
    padding-block: var(--space-7);
  }
  /* Held just under full height so the section beneath always breaks the fold:
     a hero that fills the screen exactly reads as the whole page. */
  /* One step less padding than the banners, which is what actually makes the
     homepage the shorter of the two. Its min-height is the lower of the pair
     already, but its hero carries a headline, a lead, two buttons and a trust
     line, so the content is what sets its height and the min-height never
     applies. Padding is the only lever that reaches it. */
  .hero--tall { min-height: 68vh; padding-block: var(--space-7); }
  /* After the .hero--image min-height, or a banner inherits it. It is meant to
     land just above the gateway now rather than well below it. */
  .hero--banner { min-height: 72vh; padding-block: var(--space-8); }
  /* Copy column sized with a token, not ch. ch resolves against each element's
     own font size, so a value set on the 16px wrapper silently throttled the
     56px headline into three lines. */
  .hero--image .hero__copy { max-width: var(--maxw-narrow); }
  .hero--image .hero__copy h1 { max-width: none; }
  .hero--image .hero__copy .lead { max-width: 52ch; }
}

/* The hero artwork. Declared here and nowhere else, so it is requested only
   when there is room for it, which is 1200 and up. Anything narrower puts the
   headline on top of Europe. */
@media (min-width: 1200px) {
  .hero--map .hero__media {
    background-image: url("../img/europe-map.svg");
    background-repeat: no-repeat;
    background-size: contain;
    background-position: right center;
  }
}

@media (min-width: 1200px) {
  .hero--map .hero__copy { max-width: 46%; }
  .hero--map .hero__copy .lead { max-width: 40ch; }
}

/* Touch input on a screen wider than the tier above, e.g. a large tablet. */
@media (pointer: coarse) {
  .nav__links a, .footer__links a, .footer__bottom-links a, .crosssell a, .card p > a, .case__meta a, .zig__link a {
    display: flex; align-items: center; min-height: 44px;
  }
  .copy-btn { min-height: 44px; min-width: 64px; }
}

/* Measure. Body copy that sits in a full-width column runs to 122 characters
   without a cap, which is roughly double what stays readable. */
.zig__card > p { max-width: 56ch; }
.case__body { max-width: 46ch; }

/* Notes and disclaimers are still prose and still need a measure. */
.form__note { max-width: 68ch; }

/* A document cover is portrait. Contain it rather than cropping the artwork. */
/* The cover is the thing being offered, so let it fill its column. The old
   26rem cap rendered the 600x848 master at 294x416, under half size, inside a
   510px column, and left the rest of the column empty. Capping on width instead
   lets it scale with the grid; the height cap stays only as a backstop so a very
   short viewport cannot make the block taller than the copy beside it. */
.testimonial__media--cover img {
  width: 100%;
  max-width: 30rem;
  height: auto;
  max-height: 40rem;
  margin-inline: auto;
  object-fit: contain;
}
.testimonial__media--cover::before { display: none; }
/* A document cover is a document, not a photograph. Square corners: a PDF has
   square corners, and rounding one makes it read as a picture of something
   rather than as the thing you are about to download. Case study photographs
   keep their radius. */
.testimonial__media--cover img,
.resource__media,
.resource__media img { border-radius: 0; }

/* ==========================================================================
   Address strip. The free public service resolver, sticky under the nav on the
   homepage.

   ADV-53, chosen 2026-08-26 from five placements, then treatment B of four.

   ---- It must stay quieter than the nav -----------------------------------

   Hard constraint, and it decides most of what follows. .site-header is 73px,
   so this sits at ~51px with everything on one line: one address per family,
   the caption inline before its address, and nothing in the row taller than a
   line of text. Check it after any change, at 1280 and at 375.

   ---- Treatment B ---------------------------------------------------------

   White with the header's own --border-subtle hairline along the bottom, so it
   reads as a second row of the header rather than as a band of the page. That
   is also what makes it survive being sticky: it stays the same object as the
   thing above it while the page scrolls under both.

   Not aqua as a fill. The colour budget makes aqua "a keyline, an icon, one
   stat", and on whalebone.io aqua is 0.05% of the painted page. Not navy: the
   hero directly below is navy and no two touching bands share a ground.
   ========================================================================== */

.addr-strip {
  background: var(--bg-surface);
  border-bottom: 1px solid var(--border-subtle);
}

/* Sticky under the header, which is itself sticky at 0 with z-index 100. This
   sits below it at 90, and under the skip link at 200.

   Only where it is still one line, and that is measured, not guessed: the row
   is 59px from 780px wide and 88px below it, where it wraps. Sticking it at 721
   pinned an 88px strip under a 73px header, which is the one thing this
   component must never be, louder than the nav.

   And only where the pair is affordable. Sticky, the two are 132px of chrome
   that never goes away. That is a reasonable trade on a wide window. On a
   375x812 phone, where the strip is 100px on its own, it would be 21% of the
   viewport gone forever, so below the breakpoint it scrolls away like any
   other band. */
@media (min-width: 780px) {
  .addr-strip {
    position: sticky;
    top: calc(var(--nav-h) + 1px);
    z-index: 90;
  }
}

.addr-strip__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-6);
  padding-block: var(--space-3);
}

.addr-strip__label {
  margin: 0;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  font-weight: var(--fw-medium);
  letter-spacing: var(--tk-label);
  color: var(--fg-3);
}

/* One family: its caption and its address, side by side on one line.
   margin:0 is not decoration: the global `p + p { margin-top: 16px }` was
   catching these as sibling paragraphs and adding 16px to each. They are spans
   now as well, so the rule cannot reach them, but the reset stays. */
.addr-strip__pair {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
  margin: 0;
}

.addr-strip__kind {
  flex: none;
  margin: 0;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  font-weight: var(--fw-medium);
  letter-spacing: var(--tk-label);
  color: var(--fg-3);
}

/* --------------------------------------------------------------------------
   The address is the copy control, and copying it should feel like something.

   The rule the whole interaction is built around: NOTHING MOVES. The old
   version swapped the button's text to "Copied", which is six characters where
   the address is ten or twenty-one, so the row visibly jumped and everything
   after it slid. A confirmation that shoves the page is not a confirmation,
   it is a glitch you have to forgive.

   So the address and its confirmation are stacked in the same box. The value
   sits in flow and sets the width; the confirmation is absolutely positioned
   over it and cannot affect layout. They cross-fade and pass each other by a
   few pixels of travel, which is what reads as one thing turning into another
   rather than two things blinking.

   Timing is deliberately faster than the site's --dur-hover of 400ms. That
   duration is right for a hover, where slow reads as calm. For a confirmation
   it reads as lag: you pressed, and a beat later something happened. Feedback
   has to feel like a consequence of the press, so it moves at 140-200ms and
   then holds, and the hold is the part that feels generous.
   -------------------------------------------------------------------------- */

.addr-strip__addr {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
  margin: 0;
  padding: var(--space-1) var(--space-2);
  margin-inline: calc(var(--space-2) * -1);
  background: none;
  border: 0;
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-1);
  text-align: left;
  cursor: pointer;
  transition: background-color var(--dur-base) var(--ease-out),
              transform 90ms var(--ease-out);
}

/* The affordance at rest is the glyph, at low opacity. On hover a soft chip
   comes in behind the whole control, which is the thing that says "press me"
   without putting a border around an address at rest. */
.addr-strip__addr:hover { background: var(--bg-page); }
.addr-strip__addr:hover .addr-strip__glyph { opacity: 1; }

/* The press. Small, fast, and released the moment the pointer lifts. This is
   the whole tactile budget: no ripple, no bounce. */
.addr-strip__addr:active { transform: scale(0.97); }

/* A 44px target on a 26px row. Grown with a pseudo-element rather than padding,
   which would push the strip past the header's height. */
.addr-strip__addr::after {
  content: "";
  position: absolute;
  inset: -9px -4px;
}

.addr-strip__value {
  overflow-wrap: anywhere;
  transition: opacity 140ms var(--ease-out), transform 140ms var(--ease-out);
}

/* Over the value, never in flow, so the button's width never changes. */
.addr-strip__done {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding-inline: var(--space-2);
  font-family: var(--font-sans);
  font-size: var(--fs-body-sm);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
  opacity: 0;
  transform: translateY(4px);
  pointer-events: none;
  transition: opacity 200ms var(--ease-out), transform 200ms var(--ease-out);
}

.addr-strip__addr.is-copied .addr-strip__value {
  opacity: 0;
  transform: translateY(-4px);
}
.addr-strip__addr.is-copied .addr-strip__done {
  opacity: 1;
  transform: none;
}

/* The two glyphs. Both are 14px, both sit in the same slot. */
.addr-strip__glyph,
.addr-strip__check {
  flex: none;
  width: 14px;
  height: 14px;
}

.addr-strip__glyph {
  color: var(--fg-3);
  opacity: 0.45;
  transition: opacity var(--dur-base) var(--ease-out);
}
.addr-strip__addr.is-copied .addr-strip__glyph { opacity: 0; }

/* The check draws itself rather than appearing. 26 is a little over the path's
   own length, so the stroke is fully hidden at rest and fully drawn at the end.

   Aqua, and the word beside it is what actually carries the meaning. #00B9BE on
   white is about 2.3:1, under the 3:1 that WCAG 1.4.11 asks of a graphic that
   is the only carrier of information. Here it is not the only carrier: the
   button also says "Copied" in ink, and the live region says it out loud. */
.addr-strip__check {
  color: var(--wb-teal);
  stroke-dasharray: 26;
  stroke-dashoffset: 26;
}
.addr-strip__addr.is-copied .addr-strip__check {
  animation: addr-check 260ms var(--ease-out) 60ms forwards;
}

@keyframes addr-check { to { stroke-dashoffset: 0; } }

/* Reduced motion keeps the confirmation and drops the travel. Somebody who has
   asked for less movement still needs to know the address was copied. */
@media (prefers-reduced-motion: reduce) {
  .addr-strip__addr,
  .addr-strip__value,
  .addr-strip__done,
  .addr-strip__glyph { transition-duration: 1ms; }
  .addr-strip__addr:active { transform: none; }
  .addr-strip__value { transform: none !important; }
  .addr-strip__done { transform: none; }
  .addr-strip__check { stroke-dashoffset: 0; }
  .addr-strip__addr.is-copied .addr-strip__check { animation: none; }
}

/* A link, not a button. A pill here made the row read as a toolbar with a
   widget in it, and this is a band of the page. Underlined at rest, where the
   site's own `a` underlines only on hover, because in a dense row a link with
   neither an underline nor a button shape gets missed. */
.addr-strip__action {
  margin-left: auto;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  font-weight: var(--fw-medium);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* Narrow. The two families do not fit side by side much below 720. Left to wrap
   on its own the row pairs up as label + IPv4, then IPv6 + the link, which is
   two lines. Forcing each pair to a full-width line of its own made four and
   cost 54px for nothing. So this breakpoint only unsticks the link from a right
   edge that is no longer there, and tightens the gap. */
@media (max-width: 720px) {
  .addr-strip__row { gap: var(--space-2) var(--space-4); }
  .addr-strip__action { margin-left: 0; }
}

/* ==========================================================================
   Long-form legal prose. One page uses it, /privacy, and any legal or
   compliance page added later should use it rather than inventing a second set
   of rules.

   A regulated disclosure is read differently from marketing copy: somebody
   arrives looking for one clause, not for the argument. So it is a narrow
   measure, a heading ladder they can scan, and nothing decorative. The wrapper
   is .container--narrow, close to the 795px whalebone.io uses for its own
   long-form column.
   ========================================================================== */

.legal { max-width: 68ch; }

/* The ladder. Space ABOVE a heading is what separates one clause from the one
   before it, so it is generous and it steps down with the level. */
.legal h2 {
  margin-top: var(--space-8);
  margin-bottom: var(--space-4);
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}
.legal h3 {
  margin-top: var(--space-7);
  margin-bottom: var(--space-3);
  font-family: var(--font-sans);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}
.legal h4 {
  margin-top: var(--space-6);
  margin-bottom: var(--space-2);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}
.legal > *:first-child { margin-top: 0; }

.legal p {
  margin-top: var(--space-4);
  color: var(--fg-2);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

.legal ul { margin-top: var(--space-4); }
.legal ul > li {
  color: var(--fg-2);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

/* The controller block. A real <address>, which browsers italicise by default
   and this site does not. */
.legal address {
  margin-top: var(--space-4);
  font-style: normal;
  color: var(--fg-1);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

/* Links here are frequent and mostly outbound. Underlined at rest, unlike the
   site's own `a`, because in a wall of prose a colour change alone is not
   enough of a signal, and some of these are the only route to a third-party
   policy the reader may actually need. */
.legal a { text-decoration: underline; text-underline-offset: 2px; }

/* The date of issue sits under the h1 and is not part of the prose ladder. It
   is the thing to check when this copy and the original diverge. */
.legal__issued {
  margin-top: var(--space-3);
  margin-bottom: var(--space-7);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-3);
}

/* ==========================================================================
   Blocking orders. The legal information and compliance page only.

   One section per court decision, one disclosure per dated batch, and inside
   each a list of the domains that batch blocked. Nearly three thousand of them,
   which is why they are collapsed: the page has to be readable by somebody who
   came for the transparency report, and findable by somebody who came for one
   domain. Find-in-page opens a native <details>, so both work.

   Generated from tools/legal-blocking-orders.js. See tools/build-legal-page.js.
   ========================================================================== */

.order { margin-top: var(--space-8); }

.order__title {
  margin: 0;
  max-width: 42ch;
  font-family: var(--font-serif);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}

/* What the order was about, in the source's own words. */
.order__note {
  margin-top: var(--space-2);
  max-width: 68ch;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-2);
}

.order__source {
  margin-top: var(--space-2);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  /* The court URL is the link text, and one of them is 230 characters. It
     breaks rather than pushing the page past the viewport. */
  overflow-wrap: anywhere;
}
.order__source a { text-decoration: underline; text-underline-offset: 2px; }

/* Counts are computed at build time, never typed, so they cannot go stale. */
.order__count {
  margin-top: var(--space-2);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}

/* One dated batch: a heading, a computed count, and the domains as text.

   They were collapsed into a <details> each until 2026-08-27. Nikolas's call is
   that a compliance register has to be readable without being opened.

   Running text rather than columns, which is also what the source page does and
   is the smaller of the two: a grid column has to be as wide as its longest
   domain, so every short one leaves a gap. Monospace, because these are
   identifiers a reader matches character by character rather than reads. */
.batch { margin-top: var(--space-5); }

.batch__title {
  margin: 0;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  font-weight: var(--fw-medium);
  color: var(--fg-1);
}
/* Computed at build time, never typed, so it cannot disagree with the list. */
.batch__count {
  margin-left: var(--space-2);
  font-size: var(--fs-caption);
  font-weight: var(--fw-regular);
  color: var(--fg-3);
}

.domlist {
  margin: var(--space-2) 0 0;
  font-family: var(--font-mono);
  font-size: var(--fs-body-sm);
  line-height: 1.8;
  color: var(--fg-2);
  /* Some of these are sixty characters of subdomain. They break rather than
     push the page past the viewport. */
  overflow-wrap: anywhere;
}


/* ==========================================================================
   Flat surfaces.

   Measured off whalebone.io/immunity: tiles are white on the pale ground at 8px
   radius, with no border and no shadow. Separation is the surface contrast
   doing the work. A tile carrying a border AND a shadow AND a large radius
   stops reading as Whalebone and starts reading as a SaaS dashboard. See
   DESIGN.md, "Surfaces, tiles and separation". --radius-md is the nearest token
   to the measured 8px.

   This block sits at the end of the file on purpose. Each of these components
   sets its own border and shadow further up, so anything earlier loses the
   cascade to them. Keeping the whole family in one late block is also what
   stops them drifting apart again.

   .service-band keeps its teal keyline: that is a brand accent marking the free
   service, not a container outline. Hover keeps a soft shadow, because the
   measured rule describes how a tile rests, and stripping all feedback off
   something clickable is a different mistake.
   ========================================================================== */
.card,
.route,
.case,
.panel,
.zig__card,
.service-band,
.resource.resource--feature {
  border-radius: var(--radius-md);
  box-shadow: none;
}

.card,
.route,
.case,
.panel,
.zig__card,
.resource.resource--feature { border: 0; }

.route:hover,
.route:focus-within { box-shadow: var(--shadow-1); }

/* One padding for every tile, re-measured 2026-08-20: immunity's cards are
   30px 40px, more horizontal than vertical. 32 and 48 are the two steps of our
   own scale that hold that proportion. The homepage's primary pair keeps its
   larger padding further up the file, because that pair is deliberately one
   step bigger than everything around it.

   Narrow screens get 24px on both axes. 48px of horizontal padding is a
   measurement taken off a desktop layout; inside a 175px grid track it is more
   padding than content, and it is what pushed .grid-3 into a horizontal
   scrollbar at 600 to 700px wide. Generous is a proportion, not a number. */
.card,
.case,
.zig__card { padding: var(--space-5); }

@media (min-width: 700px) {
  .card,
  .case,
  .zig__card { padding: var(--space-6) var(--space-7); }
}

/* A grid item's default min-width is auto, which means a track can never be
   narrower than the longest unbreakable thing inside it, which means a long
   word silently pushes the whole page sideways instead of wrapping. Every grid
   on the site wants the opposite. This is the fix for a class of bug, not for
   one card: .grid-3 went to three columns at 600px and the third one hung 41px
   off the right edge. */
.grid-2 > *,
.grid-3 > *,
.routes > *,
.steps > * { min-width: 0; }

/* A tile is white on the pale ground. A pale tile on a pale section is not a
   tile, it is a slightly different shade of nothing, which is what .panel was
   doing inside every sunken band. */
.section--sunken .panel { background: var(--bg-surface); }

/* No accent rules. Immunity has none: not one card on the page is marked with a
   coloured line across its top. Where a tile carries the thing to act on, the
   tint alone marks it and the geometry stays identical to its neighbours, which
   is the rule DESIGN.md already states. */
.step,
.stat { border-top: 0; }

/* No hairline inside a tile either. These were dividing a heading from the list
   under it, a quote from its attribution, a figure from its label: six rules
   inside components that are already separated by space. A divider earns its
   place between stacked items in a column, not inside a card. */
.route__benefits,
.testimonial__takeaway,
.case__org,
.cases--plain .case__meta,
.zig__figure,
.sector__why {
  border-top: 0;
  padding-top: 0;
}

/* ==========================================================================
   Bullets, measured off whalebone.io/immunity on 2026-08-20.

   A 7px round dot in the text colour, hanging 32px to the left, with the text
   block flush so a wrapped line lands under the first word and not under the
   dot. Ours were an 8px teal dot 24px out, sitting 11px down a 21.7px line,
   which put it below the middle of the text it belonged to. That is what read
   as out of line.

   The dot is centred arithmetically rather than by eye: half the line minus
   half the dot. currentColor rather than aqua, because aqua is a small accent
   in the colour budget and a bullet on every list is not small.
   ========================================================================== */
.route__benefits,
.service-band__benefits,
.legal ul {
  padding-left: var(--space-6);
  list-style: none;
}
.route__benefits > li,
.service-band__benefits > li,
.legal ul > li {
  position: relative;
  padding-left: 0;
}
.route__benefits > li + li,
.book__points > li + li,
.service-band__benefits > li + li { margin-top: var(--space-2); }

.route__benefits > li::before,
.book__points > li::before,
.service-band__benefits > li::before,
.legal ul > li::before {
  content: "";
  position: absolute;
  left: calc(var(--space-6) * -1);
  top: calc((1em * var(--lh-body-sm) - 7px) / 2);
  width: 7px;
  height: 7px;
  border-radius: var(--radius-pill);
  background: currentColor;
}

/* .legal lists moved to --fs-body on 2026-08-27 and the shared rule above
   centres the dot against --lh-body-sm, which would leave it two pixels high.
   Same arithmetic, the other line height. */
.legal ul > li::before { top: calc((1em * var(--lh-body) - 7px) / 2); }

/* .howto is deliberately not in that set. Those are the setup steps, where the
   number is the content: "step 4" is something a reader says out loud to
   themselves. A numbered list keeps its numbers. */

/* Overscrolling past the footer showed the white page behind it.

   This was a body::after painting 50vh of --bg-inverse with a matching
   negative margin, the idea being to cover the overscroll area without
   adding scroll height. The margin did not cancel it. Measured on the
   homepage 2026-08-26: the document was 6549px with the rule and 6229px
   without, and at maximum scroll the footer ended 320px above the bottom of
   the window with painted navy filling the rest. Half a screen of nothing,
   reachable by scrolling, on every page.

   A background on the root element paints the canvas, which is what the
   browser shows when you pull past either end, and body keeps the white for
   the document itself. No extra box, no negative margin, no dead space.

   The trade: pulling past the TOP now shows navy rather than white. The hero
   directly under the header is navy on most pages, so it reads as the page
   continuing rather than as a wrong colour, and it only appears on browsers
   that rubber-band at all. */
html { background: var(--bg-inverse); }

/* ==========================================================================
   Resolver comparison. Independent resolution-speed measurements, filterable
   by jurisdiction and data handling.

   It is a real table first and a chart second. The speed is text in a cell and
   the bar is drawn from a --compare-bar percentage on the row, so with no CSS
   and no JavaScript this is still a readable list of resolvers and numbers.

   Filtering NEVER removes a row. The comparison IS the argument: a resolver
   that drops out of the European filter has to stay on screen for the filter
   to mean anything. It fades to --fg-muted with a grey bar instead.
   ========================================================================== */

.compare { margin-top: var(--space-6); }

/* Radio inputs in a fieldset rather than buttons, so arrow keys move between
   the options and the state survives with no script at all. */
.compare__filter { border: 0; margin: 0; padding: 0; }
.compare__filter legend { padding: 0; margin-bottom: var(--space-3); }

.compare__options { display: grid; gap: var(--space-2); }
.compare__opt { position: relative; display: block; min-width: 0; }
.compare__opt input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
.compare__opt span {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: var(--space-2) var(--space-4);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-pill);
  background: var(--bg-surface);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
  text-align: center;
}
.compare__opt input:checked + span {
  background: var(--wb-teal);
  border-color: var(--wb-teal);
  color: var(--fg-1);
  font-weight: var(--fw-medium);
}
.compare__opt input:focus-visible + span {
  outline: 2px solid var(--wb-purple);
  outline-offset: 3px;
}

.compare__table {
  width: 100%;
  margin-top: var(--space-6);
  border-collapse: collapse;
}
/* Rows are laid out as grids, so the name, the figure and the bar can stack on
   a phone and sit in columns on a wide screen without a horizontal scroller.
   The table semantics are untouched.

   The tr is deliberately NOT in this list. `.compare__table tr` is (0,1,1) and
   would out-specify `.compare__row` at (0,1,0), leaving every row display:
   block with the grid silently ignored, which is exactly what happened the
   first time. Blockify the table and the tbody only, and let the row set its
   own display. */
.compare__table,
.compare__table caption,
.compare__table tbody { display: block; }

/* The caption sits above the rows. caption-side has no effect once the table
   box is display: block, and a source line naming the measurement before the
   numbers is the right order anyway. */
.compare__table caption {
  margin-bottom: var(--space-4);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-3);
  text-align: left;
  max-width: 68ch;
}

.compare__row {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-2);
  padding-block: var(--space-4);
  border-top: 1px solid var(--border-subtle);
}
.compare__row:last-child { border-bottom: 1px solid var(--border-subtle); }

.compare__name {
  display: block;
  min-width: 0;
  font-weight: var(--fw-regular);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--fg-1);
  text-align: left;
}
.compare__row--self .compare__name { font-weight: var(--fw-medium); }
.compare__note-tag {
  display: block;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}

.compare__cell {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  min-width: 0;
}
.compare__value {
  flex: none;
  width: 6rem;
  font-family: var(--font-mono);
  font-size: var(--fs-body-sm);
  color: var(--fg-1);
}
.compare__bar {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  height: var(--space-2);
  border-radius: var(--radius-pill);
  background: var(--bg-sunken);
}
.compare__bar::before {
  content: "";
  position: absolute;
  inset-block: 0;
  left: 0;
  /* --compare-bar is DATA, set per row from the measured figure, not a design
     value. The scale is fixed at 0 to 24 ms and stated in the axis. */
  width: var(--compare-bar, 0%);
  border-radius: var(--radius-pill);
  background: var(--wb-teal);
}
/* DNS4EU's own bar in purple so the reader finds it without a legend. Two
   accent colours, both inside the small-accent budget, and no others. */
.compare__row--self .compare__bar::before { background: var(--wb-purple); }

.compare__unit {
  margin-left: 0.25em;
  font-size: var(--fs-caption);
  color: var(--fg-3);
}

/* The conclusion, and the only line that states it. Changes with the step. */
.compare__verdict {
  margin-top: var(--space-5);
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
  font-size: var(--fs-body-lg);
  line-height: var(--lh-body-lg);
  color: var(--fg-1);
  max-width: 52ch;
}

/* Faded state. Driven by :has() so it works with zero JavaScript, and by
   .is-out so a browser without :has() still gets it from the script. Neither
   is required for the table to be correct: with both absent every row simply
   stays legible.

   The text drops to --fg-3, NOT --fg-muted. Measured on the sunken band this
   section sits on, --fg-muted is 3.05:1 and fails AA. A filtered-out row is
   still text the reader is meant to read, which is the whole reason it stays
   on screen, so it cannot be dimmed below the floor. --fg-3 is 9.5:1. The
   greyed bar is non-text and carries the rest of the signal. */
.compare__row.is-out,
.compare:has(.compare__opt input[value="eu"]:checked) .compare__row:not([data-eu]),
.compare:has(.compare__opt input[value="anon"]:checked) .compare__row:not([data-anon]) {
  color: var(--fg-3);
}
.compare__row.is-out .compare__name,
.compare__row.is-out .compare__value,
.compare:has(.compare__opt input[value="eu"]:checked) .compare__row:not([data-eu]) .compare__name,
.compare:has(.compare__opt input[value="eu"]:checked) .compare__row:not([data-eu]) .compare__value,
.compare:has(.compare__opt input[value="anon"]:checked) .compare__row:not([data-anon]) .compare__name,
.compare:has(.compare__opt input[value="anon"]:checked) .compare__row:not([data-anon]) .compare__value {
  color: var(--fg-3);
}
.compare__row.is-out .compare__bar::before,
.compare:has(.compare__opt input[value="eu"]:checked) .compare__row:not([data-eu]) .compare__bar::before,
.compare:has(.compare__opt input[value="anon"]:checked) .compare__row:not([data-anon]) .compare__bar::before {
  background: var(--border-default);
}

@media (prefers-reduced-motion: no-preference) {
  .compare__opt span,
  .compare__name,
  .compare__value,
  .compare__bar::before {
    transition: background-color var(--dur-hover) var(--ease-in-out),
                border-color var(--dur-hover) var(--ease-in-out),
                color var(--dur-hover) var(--ease-in-out);
  }
}

@media (min-width: 700px) {
  .compare__options {
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    gap: var(--space-2);
  }
  .compare__row {
    grid-template-columns: minmax(0, 11rem) minmax(0, 1fr);
    align-items: center;
    gap: var(--space-5);
    padding-block: var(--space-4);
  }
}

/* ---- The sequence -------------------------------------------------------
   Added by the script, and only when the reader has not asked for reduced
   motion. Nothing here is required for the table to be correct: without the
   class every row is simply at rest, visible, with its bar at full width.

   The bar multiplies its own data value by --compare-play, so the growth is
   one number going 0 to 1 rather than a second copy of the figure. */
.compare--anim .compare__row {
  opacity: 0;
  transform: translateY(var(--space-2));
}
.compare--anim { --compare-play: 0; }

.compare--anim.is-playing { --compare-play: 1; }
.compare--anim.is-playing .compare__row {
  opacity: 1;
  transform: none;
}

@media (prefers-reduced-motion: no-preference) {
  .compare--anim .compare__row {
    transition: opacity var(--dur-slow) var(--ease-out) calc(var(--row-i, 0) * 110ms),
                transform var(--dur-slow) var(--ease-out) calc(var(--row-i, 0) * 110ms);
  }
  .compare--anim .compare__bar::before {
    transition: width 640ms var(--ease-out) calc(var(--row-i, 0) * 110ms),
                background-color var(--dur-hover) var(--ease-in-out);
  }
}

.compare--anim .compare__bar::before {
  width: calc(var(--compare-bar, 0%) * var(--compare-play, 1));
}

/* Once DNS4EU is the only one left, its bar is the only colour on the block.
   The step reached that conclusion; this is the component agreeing with it. */
.compare.is-done .compare__row--self .compare__name { font-weight: var(--fw-medium); }

/* The nav gained a fourth route on 2026-08-10. At the 900 breakpoint, where
   the inline nav first appears, four labels plus the lockup and the Contact
   button leave 16px on each side. It fits, but with no room to spare, so the
   gap between links closes up until there is width for the full one. */
@media (min-width: 900px) and (max-width: 1099px) {
  .nav__links { gap: var(--space-5); }
}

/* Compact variant. Used on the public service page, where the block sits in
   the narrow column and the reader is being walked through three steps rather
   than reading a data table. */
.compare--tight .compare__row { padding-block: var(--space-3); }
.compare--tight .compare__value { width: 5rem; }
@media (min-width: 700px) {
  .compare--tight .compare__row { padding-block: var(--space-4); }
}

/* ==========================================================================
   Disclosure. A native <details>, used where a page carries more configuration
   detail than a reader needs at once: one per resolver variant, one per device.

   Native on purpose. It opens with no JavaScript, it is already in the keyboard
   order, and find-in-page opens it. Do not rebuild this out of buttons and
   hidden divs.
   ========================================================================== */

.discloses { display: grid; gap: var(--space-4); margin-top: var(--space-6); }

.disclose {
  background: var(--bg-surface);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-1);
}
.disclose[open] { box-shadow: var(--shadow-2); }

.disclose__summary {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start;
  gap: var(--space-2) var(--space-5);
  padding: var(--space-5);
  cursor: pointer;
  list-style: none;
  min-height: 44px;
}
.disclose__summary::-webkit-details-marker { display: none; }
.disclose__summary:focus-visible { outline: 2px solid var(--wb-purple); outline-offset: -2px; }

/* A real h3, not a styled span. summary's content model allows heading content,
   and without it the twelve resolver and device names disappear from the
   document outline, which is how a screen reader user navigates a page this
   long. */
.disclose__title {
  grid-column: 1;
  margin: 0;
  font-family: var(--font-serif);
  font-weight: var(--fw-medium);
  font-size: var(--fs-h4);
  line-height: var(--lh-h4);
  color: var(--fg-1);
}
.disclose__desc {
  grid-column: 1;
  max-width: 60ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}

/* A drawn plus that loses its upright when the block opens. No arrow glyphs,
   per the copy conventions. */
.disclose__mark {
  grid-column: 2;
  grid-row: 1 / span 2;
  position: relative;
  flex: none;
  width: var(--space-5);
  height: var(--space-5);
  align-self: center;
  border-radius: var(--radius-pill);
  border: 1px solid var(--border-default);
}
.disclose__mark::before,
.disclose__mark::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--space-3);
  height: 1px;
  background: var(--fg-2);
  transform: translate(-50%, -50%);
}
.disclose__mark::after { transform: translate(-50%, -50%) rotate(90deg); }
.disclose[open] .disclose__mark::after { opacity: 0; }

.disclose__body {
  padding: var(--space-5);
  border-top: 1px solid var(--border-subtle);
}
.disclose__body > p {
  max-width: 68ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}

@media (prefers-reduced-motion: no-preference) {
  .disclose__mark::after { transition: opacity var(--dur-base) var(--ease-out); }
}

/* Numbered setup steps inside a device disclosure. ul is reset globally; ol is
   not used elsewhere, so it is set here rather than in the base. */
.howto {
  margin: var(--space-4) 0 0;
  padding-left: var(--space-5);
  list-style: decimal;
}
.howto li {
  padding-left: var(--space-2);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
  max-width: 66ch;
}
.howto li + li { margin-top: var(--space-3); }
.howto code {
  font-family: var(--font-mono);
  font-size: var(--fs-caption);
  color: var(--fg-1);
  overflow-wrap: anywhere;
}

/* ==========================================================================
   Endpoint list. The full address set for one resolver variant: IPv4, IPv6,
   DNS over HTTPS and DNS over TLS, each copyable. Values wrap, because an IPv6
   address is one unbreakable string and 375px is the target width.
   ========================================================================== */

.endpoints { display: grid; gap: var(--space-3); margin-top: var(--space-5); }

.endpoint {
  min-width: 0;
  padding: var(--space-4);
  background: var(--bg-sunken);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-md);
}
.endpoint__label {
  margin-bottom: var(--space-3);
  font-size: var(--fs-overline);
  line-height: var(--lh-overline);
  letter-spacing: var(--tk-overline);
  text-transform: uppercase;
  font-weight: var(--fw-medium);
  color: var(--fg-3);
}
/* The label and the rows are all <p>, so the global p + p rule would stack a
   second margin on top of the label's own. */
.endpoint p + p { margin-top: 0; }
.endpoint__row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  min-width: 0;
}
.endpoint__row + .endpoint__row { margin-top: var(--space-2); }
.endpoint__row code {
  min-width: 0;
  font-family: var(--font-mono);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-1);
  overflow-wrap: anywhere;
}

@media (min-width: 700px) {
  .endpoints { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* A caution inside a resolver variant, e.g. ad blocking breaking a site that
   detects it. It had a coral keyline until 2026-08-27; six of them down one
   page read as a page full of warnings, so the words carry it now. */
.caution {
  margin-top: var(--space-4);
  max-width: 68ch;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}

/* ==========================================================================
   Learn article. One page per article, generated by tools/build-learn-pages.js
   from tools/learn-articles.json. ADV-79.

   A reading page, so it is the one place on this site that is a column of prose
   rather than a grid of tiles: nothing here is a card, and the page has one
   ground, white, until the previous/next band at the foot of it.
   ========================================================================== */

.article__head { margin-bottom: var(--space-7); }
.article__head .eyebrow a { color: inherit; text-decoration: none; }
.article__head .eyebrow a:hover { text-decoration: underline; }
.article__head h1 { margin: var(--space-3) 0 var(--space-4); }

/* Date, author and language on one line, separated by the gap rather than by a
   punctuation mark, so a missing author leaves no orphaned bullet behind. */
.article__meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
  margin: 0;
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}

/* 68ch, not the 30ch of .measure. Long enough that the eye is not returning to
   the left edge every five words, short enough to stay readable. */
.article__body { max-width: 68ch; }
.article__body > * + * { margin-top: var(--space-5); }
.article__body p { margin: 0; }
.article__body h2 {
  margin: 0;
  padding-top: var(--space-4);
  font-size: var(--fs-h3);
  line-height: var(--lh-h3);
}
.article__body ul { margin: 0; padding-left: var(--space-6); }
.article__body li + li { margin-top: var(--space-3); }

/* The resolver tables in the three translated launch articles. Five columns of
   addresses do not fit a phone, so the table scrolls inside its own box rather
   than pushing the page sideways. */
.table-scroll {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}
.datatable {
  border-collapse: collapse;
  width: 100%;
  min-width: 34rem;
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
}
.datatable th,
.datatable td {
  padding: var(--space-3) var(--space-4) var(--space-3) 0;
  text-align: left;
  vertical-align: top;
  border-bottom: 1px solid var(--border-subtle);
}
.datatable th {
  font-weight: 500;
  color: var(--fg-1);
  white-space: nowrap;
}
.datatable td { color: var(--fg-2); }

.article__source {
  margin-top: var(--space-7);
  padding-top: var(--space-5);
  border-top: 1px solid var(--border-subtle);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
}

/* Previous and next. Two columns from 700 up, stacked below, and the labels are
   above the titles rather than beside them so a long title has the full width. */
.article__nav-list {
  display: grid;
  gap: var(--space-5);
  margin: 0;
  padding: 0;
  list-style: none;
}
.article__nav-list li { margin: 0; }
.article__nav-list span {
  display: block;
  margin-bottom: var(--space-2);
  font-size: var(--fs-caption);
  line-height: var(--lh-caption);
  color: var(--fg-3);
}
.article__nav-list a {
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}
@media (min-width: 700px) {
  .article__nav-list { grid-template-columns: 1fr 1fr; }
  .article__nav-next { text-align: right; }
}

/* The one-line summary under a title in the Learn listing. It is the article
   page's own meta description, so the card and the search result say the same
   thing. Smaller and quieter than the title it sits under, because it is there
   to let a reader skip the article, not to sell it. */
.card__blurb {
  margin-top: var(--space-3);
  font-size: var(--fs-body-sm);
  line-height: var(--lh-body-sm);
  color: var(--fg-2);
}

/* ==========================================================================
   The address strip on a phone, 2026-08-27.

   It is on every page now, not just the homepage, so its cost is paid thirty-six
   times instead of once and the phone is where that shows. Wrapped, the row was
   three lines and about 100px: on a 375x812 screen that is an eighth of the
   viewport spent before the page has said anything.

   ONE LINE, and the way it gets there is by dropping the IPv6 pair rather than
   by shrinking everything until it fits. That is a real loss and it is the right
   one: the strip was never the full set, it is one resolver of five, and its own
   link goes to /public where every address including this IPv6 one lives. A
   reader who knows they want IPv6 is a reader who will follow that link. Shrunk
   type on a 21-character hex address helps nobody.

   The label sheds its words for the same reason. "FREE PUBLIC SERVICE" is the
   approved wording and it stays everywhere the line has room for it.
   ========================================================================== */
@media (max-width: 619px) {
  .addr-strip__row {
    flex-wrap: nowrap;
    gap: var(--space-3);
    overflow: hidden;
  }
  /* Hidden from everyone, not just from sight: a screen reader reading out an
     address the eye cannot see, on a strip that repeats on every page, is worse
     than the omission. */
  .addr-strip__pair:nth-of-type(2) { display: none; }
  .addr-strip__label {
    flex: none;
    max-width: 8ch;
    line-height: 1.15;
  }
  .addr-strip__pair { flex: 0 1 auto; min-width: 0; }
  .addr-strip__action { flex: none; margin-left: auto; white-space: nowrap; }
}
