/* Digithun Worldwide — site stylesheet
 *
 * Promoted from the hero prototype (owner decision, 2026-07-31). The mark
 * reads two ways: the digits 1 and 0 (digital signal), and the letters I and
 * O (input → output). The pointer pulls the two pieces apart; at the centre
 * of the viewport they align back into the D, exactly as in the logo.
 *
 * Palette is provisional — sampled from the logo file the owner provided on
 * 2026-07-30, pending the approved brand palette (vault blocker B1).
 */

/* ── Design tokens ──────────────────────────────────────────────────────
 * The three-layer colour contract (see docs/DESIGN-SYSTEM.md):
 *   Layer 1  primitives — the ONLY place a hex literal is legal.
 *   Layer 2  the alpha ramp — 4/8/12/20/32/48/55/70% via rgb(var(--x-rgb)/a).
 *   Layer 3  semantic aliases — components reference ONLY these.
 * Swapping the provisional palette for the approved one (vault blocker B1)
 * is an edit to Layer 1 alone. */
/* The one font this site ships. Everything else is the reader's own system
 * stack; the Thai ring on /products/shopscape asks for a specific face at a
 * specific weight, and no system has it.
 *
 * Self-hosted because it has to be: the content security policy is
 * font-src 'self', so a Google Fonts link would be blocked outright. Thai
 * subset only — the Latin side of that ring is Helvetica, so Google's other
 * four subsets would be bytes nobody reads. 8KB. SIL OFL 1.1, which permits
 * exactly this; provenance in docs/image-credits.md.
 *
 * `swap`, not `block`: the words are content, and they should be readable in
 * the fallback while 8KB arrives. */
@font-face {
  font-family: "IBM Plex Sans Thai";
  font-style: normal;
  font-weight: 300;
  font-display: swap;
  src: url("/assets/fonts/ibm-plex-sans-thai-300-thai.woff2") format("woff2");
  unicode-range: U+02D7, U+0303, U+0331, U+0E01-0E5B, U+200C-200D, U+25CC;
}

:root {
  /* Layer 1 — primitives */
  --navy-0: #010209;
  --navy-1: #02060f;
  --navy-2: #04091a;
  --navy-3: #071026;
  --navy-4: #08152f;
  --navy-5: #0b1e44;
  --navy-6: #102a58;
  --blue-100: #dbe9f6;
  --blue-300: #a9cbe8;
  --blue-500: #5e97d0;
  --blue-700: #2e56a4;
  --navy-800: #1e3c78;
  --ink: #e9f1fa;
  --white: #f4f8fd;
  --teal-500: #3fb6a8;
  --amber-500: #e8b84b;
  /* channel triplets so the alpha ramp needs no new literals */
  --ink-rgb: 233 241 250;
  --blue-300-rgb: 169 203 232;
  --blue-500-rgb: 94 151 208;
  --blue-700-rgb: 46 86 164;
  --navy-0-rgb: 1 2 9;
  --teal-500-rgb: 63 182 168;
  --amber-500-rgb: 232 184 75;

  /* Layer 3 — text (every token here holds ≥ 4.5:1 on navy-0..5) */
  --text-hi: var(--white);
  --text-body: var(--ink);
  --text-dim: rgb(var(--ink-rgb) / 0.55);
  --text-hint: rgb(var(--ink-rgb) / 0.52); /* measured 4.46:1 at 0.48 — a hair under AA */
  --text-accent: var(--blue-500);
  --text-accent-dim: rgb(var(--blue-300-rgb) / 0.62);
  --text-on-inverse: #0a1226;
  --faint: rgb(var(--ink-rgb) / 0.32); /* DECOR ONLY — never on copy */
  /* Layer 3 — surfaces */
  --surface-page: var(--navy-0);
  --surface-raised: rgba(4, 9, 24, 0.94);
  --surface-overlay: var(--navy-3);
  --surface-inverse: var(--white);
  /* Layer 3 — lines */
  --line-navy: rgb(var(--blue-700-rgb) / 0.32);
  --line-navy-faint: rgb(var(--blue-700-rgb) / 0.2);
  --line-blue: rgb(var(--blue-500-rgb) / 0.32);
  --line-blue-faint: rgb(var(--blue-500-rgb) / 0.08);
  --line-hover: rgb(var(--blue-300-rgb) / 0.7);
  --fill-hover: rgb(var(--blue-700-rgb) / 0.2);
  /* Layer 3 — effects */
  --glow-accent: rgb(var(--blue-500-rgb) / 0.32);
  --scrim: rgb(var(--navy-0-rgb) / 0.7);

  /* Type — 10 steps replace 43 sizes. Fluid middles are rem+vw so user
   * font scaling still works; endpoints keep today's rendered extremes. */
  --font-sans: system-ui, -apple-system, "Segoe UI", "Noto Sans Thai", sans-serif;
  --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --text-2xs: 0.625rem; /* 10px — the hard floor; nothing below, ever */
  --text-xs: 0.75rem;
  --text-sm: 0.875rem;
  --text-base: 1rem;
  --text-lg: clamp(1.0625rem, 0.95rem + 0.5vw, 1.3125rem);
  --text-xl: clamp(1.25rem, 1rem + 0.9vw, 1.75rem);
  --text-2xl: clamp(1.5rem, 1.05rem + 1.6vw, 2.4rem);
  --text-3xl: clamp(1.75rem, 1.2rem + 2.1vw, 3rem);
  --text-4xl: clamp(2.125rem, 1.125rem + 3vw, 3.9rem);
  --text-display: clamp(2.5rem, 1.35rem + 3.6vw, 5.4rem);
  --lh-slab: 0.95;
  --lh-display: 1.1;
  --lh-heading: 1.2;
  --lh-title: 1.3;
  --lh-body: 1.6;
  --lh-relaxed: 1.72;
  --track-display: -0.02em;
  --track-heading: -0.015em;
  --track-caps: 0.14em;
  --track-caps-wide: 0.18em;
  --measure-slab: 18ch;
  --measure-heading: 36ch;
  --measure-lede: 76ch;
  --measure-body: 62ch;

  /* Space & shape */
  --gutter: clamp(1.5rem, 5.5vw, 5rem); /* the ONLY horizontal inset */
  --section-y: clamp(3.5rem, 8vh, 6rem);
  --stack-head: clamp(2rem, 5vh, 3rem);
  --grid-gap: clamp(1rem, 2vw, 1.6rem);
  --card-pad: clamp(1rem, 1.8vw, 1.5rem);
  --s1: 0.35rem;
  --s2: 0.5rem;
  --s3: 0.65rem;
  --s4: 0.9rem;
  --s5: 1.15rem;
  --s6: 1.5rem;
  --s7: 2rem;
  --container-max: 1560px;
  --band-h: 300px; /* overlaps ride ratios of this, never raw pixels */
  --r-pill: 999px;
  --r-xl: 24px;
  --r-lg: 20px;
  --r-md: 16px;
  --r-sm: 10px;
  --r-xs: 8px;
  --r-round: 50%;
  --control-sm: 40px;
  --control-md: 46px;
  --z-nav: 5;
  --z-skip: 20;
  --z-overlay: 30;

  /* Motion — one signature ease; drift durations for ambient layers */
  --motion-micro: 150ms;
  --motion-hover: 200ms;
  --motion-enter: 240ms;
  --motion-exit: 180ms;
  --motion-loop: 3.2s;

  /* The section bar's full floating height: 20px of air, a 58px pill, and
   * 12px under it. Anything that has to clear the bar reads this. */
  --sub-nav-h: 5.625rem;
  --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
  --grow-sm: 1.04;
  --grow-lg: 1.08;
  --lift: -6px;
  --drift-1: 70s;
  --drift-2: 110s;
  --drift-3: 170s;
  --drift-4: 260s;

  /* Legacy aliases — kept while sections migrate; do not add new users */
  --hero-bg: var(--navy-3);
  --hero-bg-deep: var(--navy-1);
  --hero-glow: var(--navy-5);
  --muted: var(--text-dim);
}

* {
  box-sizing: border-box;
}

html,
body {
  min-height: 100%;
}

body {
  margin: 0;
  font-family: system-ui, -apple-system, "Segoe UI", "Noto Sans Thai", sans-serif;
  color: var(--ink);
  /* Layered like the reference: corner vignette → rim-light sweep on the
   * right → core glow behind the mark → near-black indigo base. */
  background:
    radial-gradient(150vmax 150vmax at 50% 45%, transparent 52%, rgba(1, 2, 8, 0.55) 100%),
    radial-gradient(95vmax 75vmax at 90% 28%, rgb(var(--blue-500-rgb) / 0.14) 0%, rgb(var(--blue-700-rgb) / 0.06) 32%, transparent 62%),
    radial-gradient(115vmin 88vmin at 50% 40%, #0c1631 0%, #070d24 55%, transparent 100%),
    linear-gradient(160deg, #060a1e 0%, #030617 55%, #010209 100%);
  background-color: #010209;
}

/* One background across every page ──────────────────────────────────────
 * About and Services used to run a `body.page-lit` variant that lifted the
 * navy ramp (navy-0 #010209 → #071026) on the argument that a long read on
 * near-black feels heavy. The owner wants the three pages to open on the same
 * colour, so that block is gone and the base `body` rule above is now the only
 * page background there is.
 *
 * Nothing needs rebinding to make that work: --surface-page is declared at
 * :root as var(--navy-0), so the Credentials cards and every other surface
 * resolve against the one ramp again. The class is off both pages and their
 * theme-color follows it to #010209 — the browser chrome is part of the page.
 * If a lighter inner tone is ever wanted back, it belongs here as one block
 * again rather than as per-section overrides. */

/* Fine vertical pinstripes, strongest on the left and fading out rightward —
 * the subtle architectural texture in the reference. */
.texture {
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(
    90deg,
    rgb(var(--ink-rgb) / 0.03) 0,
    rgb(var(--ink-rgb) / 0.03) 1px,
    transparent 1px,
    transparent 78px
  );
  -webkit-mask-image: linear-gradient(100deg, #000 0%, rgba(0, 0, 0, 0.45) 45%, transparent 72%);
  mask-image: linear-gradient(100deg, #000 0%, rgba(0, 0, 0, 0.45) 45%, transparent 72%);
  pointer-events: none;
}

/* The hero fills exactly one viewport; the page continues below it. */
.hero-viewport {
  position: relative;
  height: 100svh;
  overflow: hidden;
}

/* ── Scene ──────────────────────────────────────────────────────────────── */

.hero {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  perspective: 1100px;
  /* Lift the mark a touch — the bottom band belongs to the headline (left)
   * and, later, the detail + call-to-action column (right). */
  padding-bottom: 12vh;
}

.stage {
  position: relative;
  width: min(94vmin, 880px);
  aspect-ratio: 1;
  transform-style: preserve-3d;
  transform: rotateX(var(--tilt-x, 0deg)) rotateY(var(--tilt-y, 0deg));
}

/* ── Rings (chromosome-ideogram style, generated deterministically) ─────── */

.rings {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transform: translateZ(-90px);
}

.ring {
  /* Each ring carries an invisible full-size circle so its bounding box is
   * identical and symmetric about (0,0) — center then resolves to the true
   * middle in every engine, keeping the rings concentric while spinning. */
  transform-box: fill-box;
  transform-origin: center;
}

.ring-0 { animation: spin-cw 110s linear infinite; }
.ring-1 { animation: spin-ccw 150s linear infinite; }
.ring-2 { animation: spin-cw 92s linear infinite; }
.ring-3 { animation: spin-ccw 185s linear infinite; }
.ring-4 { animation: spin-cw 135s linear infinite; }
.ring-5 { animation: spin-ccw 108s linear infinite; }
.ring-6 { animation: spin-cw 205s linear infinite; }
.ring-7 { animation: spin-ccw 160s linear infinite; }
.ring-8 { animation: spin-cw 178s linear infinite; }

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

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

/* Two of the rings are a running binary stream — the digital signal the mark
 * is built from. They spin with the same animations as their segment siblings.
 * Deliberately small and faint: a quiet texture layer among the segments, not
 * a competing focal point (owner review, 2026-07-30). */
.digits {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 16px;
  font-weight: 500;
  letter-spacing: 0.3em;
  fill: var(--blue-300);
  fill-opacity: 0.34;
}

.ring-6 .digits {
  font-size: 17px;
  fill: var(--blue-500);
  fill-opacity: 0.42;
}

/* Outermost ring — the service keywords, so a visitor who never scrolls past
 * the fold still knows what we do. It gets its own svg, inset outwards, so it
 * clears the segment rings without changing their geometry, and it turns far
 * slower than they do: this one is meant to be read. */
.word-ring {
  position: absolute;
  /* A touch wider than the stage: nearer the viewer than the segment rings
   * (-70px vs -90px) and pushed out again here, which is what buys the ~20px
   * of air between the words and the outermost segment ring. */
  inset: -0.5%;
  width: 101%;
  height: 101%;
  transform: translateZ(-70px);
  /* The marquee. On a closed circle, turning the whole ring and sliding the
   * string along the path are the same motion — but only the turn stays
   * seamless, so the loop rides on the rotation and just runs faster. */
  animation: spin-cw 70s linear infinite;
}

/* Tracking is tuned so the keyword loop fills the circumference and still
 * stops short of the path's end — run past it and the last services simply
 * vanish. Kept pale enough to almost sink into the field: legible, never
 * loud. */
.words {
  font-size: 28px;
  font-weight: 600;
  letter-spacing: 0.09em;
  fill: var(--blue-300);
  fill-opacity: 0.12;
}

@media (max-width: 760px) {
  .words {
    font-size: 30px;
    letter-spacing: 0.03em;
  }

  /* No pointer to align the mark with, so the scene stops being something to
   * play with and becomes something that fills the screen: half again as
   * large, centred, cropped by the viewport at the sides. */
  .stage {
    width: min(146vmin, 880px);
  }

  /* The desktop layout lifts the scene to leave the bottom band for the
   * headline; on a phone the headline sits well clear, so it centres. */
  .hero {
    padding-bottom: 0;
  }
}

/* ── The mark: I (bar) + O (ring) = D ───────────────────────────────────── */

.mark {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  transform: translateZ(70px);
}

.mark svg {
  width: 44%;
  height: auto;
  overflow: visible;
  filter:
    drop-shadow(0 0 26px rgb(var(--blue-500-rgb) / 0.35))
    drop-shadow(0 18px 40px rgba(2, 6, 15, 0.85));
  animation: breathe 9s ease-in-out infinite;
}

@keyframes breathe {
  50% { transform: translateY(-9px); }
}

.io-i,
.io-o {
  transform: translate(var(--dx, 0px), var(--dy, 0px));
}

.io-i {
  --dx: var(--i-x, 0px);
  --dy: var(--i-y, 0px);
}

.io-o {
  --dx: var(--o-x, 0px);
  --dy: var(--o-y, 0px);
}

/* Flat bar per owner review (liquid-glass variant was tried and dropped) —
 * more translucent than the original so the overlap layering shows through. */
.i-bar {
  fill: var(--blue-300);
  fill-opacity: 0.72;
}

/* O is a single colour (the navy of the logo's right half); the two-tone split
 * was dropped so the assembled mark reads unmistakably as a D. */
.o-left,
.o-right {
  fill: var(--blue-700);
}

/* Labels fade in as the pieces separate (--sep is 0 at centre, 1 at edges) */

.io-label {
  position: absolute;
  top: 50%;
  margin: 0;
  transform: translateY(-50%);
  font-size: var(--text-xs);
  font-weight: 500;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--muted);
  /* จางลงตามรีวิวบอส — ยังอ่านได้เมื่อแยกเต็มระยะ แต่ไม่แย่งสายตา */
  opacity: calc(var(--sep, 0) * 0.45);
}

.label-i { left: 3%; }
.label-o { right: 3%; }

/* ── Copy ───────────────────────────────────────────────────────────────── */

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Keyboard users get a real way past the hero straight to the content. */
.skip-link {
  position: absolute;
  top: 0.75rem;
  left: 0.75rem;
  z-index: 20;
  padding: 0.6rem 1rem;
  border-radius: 10px;
  background: var(--surface-inverse);
  color: var(--text-on-inverse);
  font-weight: 600;
  text-decoration: none;
  transform: translateY(-300%);
}

.skip-link:focus-visible {
  transform: none;
}

/* ── Floating pill navigation (reference-style) ─────────────────────────── */

.topbar {
  position: absolute;
  inset-inline: 0;
  top: clamp(1rem, 3.5vh, 2.1rem);
  display: flex;
  /* Left-aligned, sharing the same left edge as the headline block. */
  justify-content: flex-start;
  padding-inline: clamp(1.5rem, 5.5vw, 5rem);
  z-index: 5;
}

/* The bar spans the full content width — the same gutters the headlines and
 * every section below use, so its ends line up with the copy rather than
 * floating free of it. Mark on the left, CTA on the right, links centred in
 * what is left between them. */
.pill-nav {
  display: flex;
  align-items: center;
  width: 100%;
  gap: clamp(0.7rem, 1.8vw, 1.5rem);
  padding: 0.3rem;
  border-radius: 999px;
  background: rgba(9, 14, 30, 0.92);
  border: 1px solid rgb(var(--ink-rgb) / 0.09);
  box-shadow:
    0 16px 36px rgba(0, 0, 0, 0.48),
    0 2px 8px rgba(0, 0, 0, 0.42);
}

/* Logo badge — the I+O D mark on a white coin, as in the reference. */
/* Logo badge — the I+O D mark on a white pill, with the company name beside
 * it at every width. */
.nav-mark {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  height: 46px;
  padding-inline: 0.6rem 0.85rem;
  border-radius: 999px;
  background: var(--surface-inverse);
  text-decoration: none;
  transition: transform 0.18s ease;
}

.nav-mark:hover,
.nav-mark:focus-visible {
  transform: scale(1.08);
}

.nav-mark svg {
  width: 25px;
  height: 25px;
}

.nav-wordmark {
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: -0.005em;
  white-space: nowrap;
  color: var(--text-on-inverse);
}

.nav-o {
  fill: var(--blue-700);
}

.nav-i {
  fill: var(--blue-300);
  fill-opacity: 0.72;
}

.nav-links {
  display: flex;
  align-items: center;
  gap: clamp(0.8rem, 1.7vw, 1.4rem);
  /* Centred in the space the mark and the CTA leave, which is what fills the
   * full-width bar instead of leaving a hole in the middle of it. */
  margin: 0 auto;
  padding: 0;
  list-style: none;
}

/* The menu toggles are in this selector deliberately. This rule was written
 * for `a` alone, and "Service" and "Our Product" are buttons — so they were the
 * only two items in the bar that could wrap. Measured: between 770 and 950px
 * "Our Product" broke onto a second line and grew the whole pill by 21px. */
.nav-links a,
.nav-links .nav-menu-toggle {
  color: var(--ink);
  text-decoration: none;
  font-size: var(--text-sm);
  font-weight: 500;
  letter-spacing: 0.01em;
  white-space: nowrap;
  transition: transform 0.18s ease, color 0.18s ease;
}

/* Display belongs to the anchors alone. In the shared rule above it is (0,2,0)
 * and out-specifies the sheet's `.nav-menu-toggle { display: none }` at
 * (0,1,0) — which put the Service and Our Product buttons back into the mobile
 * sheet, where the panel is already open and a trigger reading
 * aria-expanded="false" is a lie to a screen reader. */
.nav-links a {
  display: inline-block;
}

/* Reference behaviour: the hovered word simply grows a little. */
.nav-links a:hover,
.nav-links a:focus-visible {
  color: var(--text-hi);
  transform: scale(1.12);
}

/* CTA — white pill; hovering swaps "Say Hello" for the contact address,
 * mirroring the reference's email ⇄ "Copy this email" trick. CSS-only.
 * The rotating streak rides inside a 2px transparent border: a solid
 * padding-box face over a conic-gradient border-box, spun via --cta-angle
 * (the dev.to rotating-glow technique, in brand blues). */
@property --cta-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

.nav-cta {
  position: relative;
  display: inline-grid;
  place-items: center;
  /* Never squeezed. `overflow: hidden` below is what keeps the rotating streak
   * inside the pill, and it also makes this a scroll container — which sets a
   * flex item's automatic minimum size to ZERO. So the bar was free to shrink
   * the pill past its own label: measured at 788px the pill was 104px wide
   * around 205px of content, clipping "Say Hello" and the hover address with
   * it. The links give way instead; the CTA is the page's one action. */
  flex-shrink: 0;
  padding: 0.52rem 1rem;
  border-radius: 999px;
  border: 2px solid transparent;
  background:
    linear-gradient(var(--surface-inverse), var(--surface-inverse)) padding-box,
    conic-gradient(
      from var(--cta-angle),
      transparent 0%,
      var(--blue-500) 8%,
      var(--blue-100) 14%,
      var(--blue-500) 20%,
      transparent 32%
    ) border-box;
  box-shadow: 0 0 18px rgb(var(--blue-500-rgb) / 0.35);
  color: var(--text-on-inverse);
  font-size: var(--text-sm);
  font-weight: 600;
  text-decoration: none;
  overflow: hidden;
  transition: transform 0.18s ease;
  animation: cta-glow 3.2s linear infinite;
}

@keyframes cta-glow {
  to { --cta-angle: 360deg; }
}

.nav-cta:hover,
.nav-cta:focus-visible {
  transform: scale(1.05);
}

.cta-rest,
.cta-hover {
  grid-area: 1 / 1;
  white-space: nowrap;
  transition: opacity 0.18s ease, transform 0.18s ease;
}

.cta-hover {
  opacity: 0;
  transform: translateY(70%);
}

.nav-cta:hover .cta-rest,
.nav-cta:focus-visible .cta-rest {
  opacity: 0;
  transform: translateY(-70%);
}

.nav-cta:hover .cta-hover,
.nav-cta:focus-visible .cta-hover {
  opacity: 1;
  transform: none;
}

/* A shorter phone label, when the CMS sets one (src/cms/cta.mjs). Only one of
 * the pair is ever displayed, so the button keeps one width and a screen
 * reader hears one name — display:none takes the other out of both. */
.cta-narrow {
  display: none;
}

/* Disclosure toggle — hidden on wide screens, a two-bar burger on mobile. */
.nav-toggle {
  display: none;
  align-content: center;
  justify-items: center;
  row-gap: 5px;
  width: 38px;
  height: 38px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgb(var(--ink-rgb) / 0.08);
  cursor: pointer;
}

.nav-toggle-bar {
  display: block;
  width: 16px;
  height: 2px;
  border-radius: 2px;
  background: var(--ink);
  transition: transform 0.18s ease;
}

/* Open state: the two bars cross into an X. Addressed by nth-of-type — the
 * button also holds a visually-hidden label span, so first/last-of-type
 * would silently miss one bar (that bug shipped as a lone slash). */
.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-of-type(1) {
  transform: translateY(3.5px) rotate(45deg);
}

.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-of-type(2) {
  transform: translateY(-3.5px) rotate(-45deg);
}

@media (max-width: 1119px) {
  /* Tighter gutters, and the pill spans the row instead of overflowing it. */
  .topbar {
    padding-inline: 1rem;
  }

  /* 1119, and it has moved three times for the same reason: the bar is
   * measured, not guessed. It was 760, then 959 when the row was five links
   * needing 810px of bar, then 1023 when Blog made it six.
   *
   * Universe made it seven, which is the case the last revision of this note
   * predicted in as many words — "a seventh link will need this number to move
   * again, or a shorter label". At 1023 the bar was left horizontal and 63px
   * over its own pill at 1024.
   *
   * Measured at a real 1120-wide viewport — the narrowest the bar is now shown
   * at — the pill is 997 wide, the CTA takes 209, and 27px of gap is left
   * before the links reach it: the same slack six links had at 1024. Nothing
   * overflows and the page does not scroll sideways. Measure it that way if
   * you change this: shrinking the element in devtools keeps the wide-gutter
   * padding this very rule swaps out, so it reports the bar as needing more
   * room than it does. The real boundary is about 1091; 1119 is that plus
   * room to breathe.
   *
   * The cost is real and was taken deliberately: a 1024-wide tablet now gets
   * the sheet, which 1023 was chosen to avoid. Seven links, a wordmark and a
   * glowing pill do not fit in 911px, so the choice was the sheet or a
   * shorter label — and the labels are the owner's copy, not this file's to
   * cut. Shrinking the gap was measured too and buys about 6px, which is not
   * a fix.
   *
   * Below it the links used to be squeezed until "Our Product" broke onto a
   * second line and the CTA was clipped mid-word. There is no width to find,
   * so the bar becomes the sheet earlier instead of failing quietly.
   *
   * Mark and burger travel together on the left; the CTA is pushed to the
   * far right by the auto margin instead of everything spreading evenly.
   * (The full width comes from the base rule now.) */
  .pill-nav {
    justify-content: flex-start;
    gap: 0.5rem;
  }

  /* Touch has no hover — dropping the hidden email label frees the width
   * it was reserving, which is what pushed the pill past the viewport. */
  .cta-hover {
    display: none;
  }

  /* Same block, same reason: on a phone the label sits beside the menu button
   * with no slack — measured at 360px, "Say Hello" is exactly the room there
   * is — so a longer desktop label gives way to its short phone label. */
  .cta-wide {
    display: none;
  }

  .cta-narrow {
    display: inline;
  }

  /* Badge and name on the left; burger and CTA travel together on the right,
   * so the two things you can press sit under the same thumb. */
  .nav-wordmark {
    font-size: var(--text-xs);
  }

  .nav-cta {
    padding: 0.48rem 0.95rem;
  }

  .nav-toggle {
    display: grid;
    grid-auto-flow: row;
    margin-left: auto;
  }

  /* The link row becomes a disclosure panel under the pill. */
  .nav-links {
    display: none;
  }

  /* A right-hand sheet, not a dropdown: the menu is now a full service list
   * and a dropdown under the pill would be a scrolling box inside a bar. */
  .nav-links[data-open] {
    position: fixed;
    inset: 0 0 0 auto;
    z-index: 70;
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.35rem;
    width: min(24rem, 88vw);
    margin: 0;
    padding: 4.25rem 1.4rem 2rem;
    overflow-y: auto;
    overscroll-behavior: contain;
    border-radius: 0;
    border: 0;
    border-left: 1px solid var(--line-navy);
    background: var(--surface-overlay);
    box-shadow: -24px 0 60px rgb(0 0 0 / 0.5);
  }

  .nav-links[data-open] > li > a {
    display: block;
    padding: 0.75rem 0;
    border-top: 1px solid var(--line-navy-faint);
    font-size: var(--text-base);
  }

  /* The page behind it dims, so the sheet reads as a layer rather than as
   * part of the page it is covering. */
  .nav-links[data-open]::before {
    content: "";
    position: fixed;
    inset: 0;
    right: min(24rem, 88vw);
    z-index: -1;
    background: var(--scrim);
  }

  /* The toggle has to stay above the sheet, or there is no way back out —
   * and it has to MOVE. It lives centred in the pill bar, which puts the
   * close control in the middle of the sheet's top edge, where nobody looks
   * for it. While the sheet is open it pins to the sheet's own top-right
   * corner, which is where a right-hand sheet's close belongs.
   *
   * Selected with :has() because the toggle comes BEFORE the list in source
   * order — a sibling combinator cannot reach backwards to it. */
  .nav-toggle {
    position: relative;
    z-index: 71;
  }

  .pill-nav:has(.nav-links[data-open]) .nav-toggle {
    position: fixed;
    top: 1.05rem;
    right: 1.05rem;
    z-index: 72;
  }

  .pill-nav {
    position: relative;
  }
}

/* Headline block sits in the lower-left corner, reference-style. The first
 * line carries all the emphasis; the company line stays quiet beneath it. */
.copy {
  position: absolute;
  left: clamp(1.5rem, 5.5vw, 5rem);
  bottom: clamp(1.75rem, 7vh, 4.5rem);
  text-align: left;
  /* No width cap on desktop — the headline breaks via <br>, and the company
   * line is meant to sit on a single line. Mobile re-caps below. */
}

.copy h1 {
  margin: 0 0 1rem;
  font-size: var(--text-4xl);
  font-weight: 700;
  line-height: 1.07;
  letter-spacing: -0.018em;
  color: var(--text-hi);
}

/* Scroll hint — the Coding Artist pattern (mouse outline, dot falling
 * through, label underneath) in our palette: pale enough to read as a hint
 * rather than a control, parked in the hero's bottom-right corner opposite
 * the headline. */
.scroll-hint {
  position: absolute;
  right: clamp(1.5rem, 5.5vw, 5rem);
  bottom: clamp(1.75rem, 7vh, 4.5rem);
  display: grid;
  justify-items: center;
  gap: 0.62rem;
}

.scroll-mouse {
  position: relative;
  width: 30px;
  height: 48px;
  border: 2px solid rgb(var(--blue-300-rgb) / 0.34);
  border-radius: 999px;
}

.scroll-dot {
  position: absolute;
  top: 9px;
  left: 50%;
  width: 5px;
  height: 5px;
  margin-left: -2.5px;
  border-radius: 50%;
  background: var(--blue-300);
  opacity: 0.75;
  animation: scroll-fall 2s ease-in-out infinite;
}

@keyframes scroll-fall {
  80% {
    opacity: 0.35;
  }

  100% {
    transform: translateY(var(--fall, 24px));
    opacity: 0;
  }
}

.scroll-label {
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text-accent-dim);
}

/* On a phone the headline reaches almost across the row, so the hint tucks
 * into the gutter at a smaller size rather than sitting on top of it. */
@media (max-width: 760px) {
  .scroll-hint {
    right: 1rem;
    gap: 0.45rem;
  }

  .scroll-mouse {
    width: 24px;
    height: 40px;
  }

  .scroll-dot {
    --fall: 20px;
    top: 7px;
  }

  .scroll-label {
    font-size: var(--text-2xs);
    letter-spacing: 0.13em;
  }
}

.sub {
  margin: 0;
  font-size: var(--text-base);
  font-weight: 400;
  letter-spacing: 0.012em;
  color: var(--muted);
}

/* ── About page: the same hero scene, pushed to one side, with the belief
 * story opposite it (owner brief, 2026-07-31). Only .hero's alignment and
 * the stage size change — rings/word-ring/mark are untouched. */

/* Fixed band, not a viewport: this hero is a banner above the page, so its
 * height is stated once here (owner brief, 2026-08-05) instead of tracking
 * 100svh like the home hero. The phone tier below still releases it to
 * auto height, where the story stacks under the mark.
 *
 * --about-hero-top is the band's shared top line: the story column hangs
 * from it and the ring starts on it, so the two read as one row however
 * large the ring gets (owner brief, 2026-08-05). */
.hero-viewport.about-hero {
  height: 820px;
  --about-hero-top: 85px;
}

/* align-items: start, not the inherited centre — centring re-derives the
 * ring's top edge from its own size, which drifts away from the story
 * column at every breakpoint. Hanging both from one padding keeps them level. */
.hero-viewport.about-hero .hero {
  justify-items: start;
  align-items: start;
  padding: var(--about-hero-top) 0 0 clamp(1.5rem, 6vw, 6rem);
}

/* vmin alone isn't safe here: on a wide-but-short screen it's driven by
 * height, and a size that tall can exceed the viewport's own width — so
 * every tier below also caps by vw (room beside the story column) and by
 * the band's own height (room within the fixed hero, clear of the nav).
 * That last cap is px, not svh, because the band no longer follows the
 * viewport: 672px is the same 82% of the 820px height it used to take. */
.hero-viewport.about-hero .stage {
  width: min(184vmin, 2080px, 58vw, 672px);
}

.believe {
  position: absolute;
  right: clamp(1.5rem, 6vw, 5rem);
  /* Sits up close under the title band — the page-top band now carries the
   * head room that this offset used to provide. Same variable as the ring's
   * padding, so the two stay on one line. */
  top: var(--about-hero-top, 85px);
  max-width: calc(42ch + 100px);
  display: grid;
  gap: 1.1rem;
}

.believe h1 {
  margin: 0;
  font-size: var(--text-3xl);
  font-weight: 700;
  line-height: 1.08;
  letter-spacing: -0.022em;
  text-wrap: balance;
  color: var(--text-hi);
}

.believe p {
  margin: 0;
  font-size: var(--text-base);
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

/* The closing line is the page's payoff, so it is set one step below the
 * heading — big enough to land, never competing with it.
 * Scoped as `.believe .believe-close` on purpose: a bare `.believe-close`
 * is (0,1,0) and would lose the font-size to `.believe p` at (0,1,1). */
.believe .believe-close {
  margin-top: 0.5rem;
  font-size: var(--text-2xl);
  font-weight: 700;
  line-height: 1.12;
  letter-spacing: -0.018em;
  text-wrap: balance;
  color: var(--text-hi);
}

/* A third of the line above it. `em` keeps that ratio exact at every
 * viewport; the rem floor stops it shrinking past legibility once the
 * fluid heading bottoms out on a phone. */
.believe .believe-close span {
  display: block;
  margin-top: 0.55rem;
  font-size: max(0.75rem, 0.34em);
  font-weight: 500;
  line-height: 1.5;
  letter-spacing: 0.01em;
  color: var(--text-dim);
}

/* The 1040px stage only has room to breathe on very wide screens — below
 * that, shrink it back before it collides with the 42ch story column. */
@media (max-width: 1700px) {
  .hero-viewport.about-hero .stage {
    width: min(152vmin, 1680px, 56vw, 656px);
  }
}

/* Tablet: shrink again — even the reduced stage and a wide story column no
 * longer both fit beside each other. */
@media (max-width: 1100px) {
  .hero-viewport.about-hero .stage {
    width: min(112vmin, 1240px, 40vw, 541px);
  }

  /* The +100px from the base rule doesn't fit once the stage also claims
   * width here — back to the plain column, no added width, at this tier. */
  .believe {
    max-width: 36ch;
  }
}

/* Phone: stack instead of split — the story is too long to fit the fixed
 * band, so the section returns to normal document flow. */
@media (max-width: 760px) {
  .hero-viewport.about-hero {
    height: auto;
    overflow: visible;
    padding: clamp(6rem, 16vh, 8rem) 0 clamp(3rem, 8vh, 4rem);
  }

  .hero-viewport.about-hero .hero {
    position: static;
    display: block;
    padding: 0;
  }

  .hero-viewport.about-hero .stage {
    /* vw, not vmin — doubling a vmin-based size risks exceeding a narrow
     * viewport's own width on a tall phone. */
    width: min(96vw, 840px);
    margin: 0 auto;
  }

  .hero-viewport.about-hero .believe {
    position: static;
    max-width: none;
    margin: clamp(2.5rem, 7vh, 3.5rem) clamp(1.25rem, 5vw, 2rem) 0;
    transform: none;
  }
}

/* ── Motion & viewport care ─────────────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  .ring,
  .word-ring,
  .scroll-dot,
  .mark svg {
    animation: none;
  }

  .stage {
    transform: none;
  }
}

@media (prefers-contrast: more) {
  :root {
    --ink: #ffffff;
    --muted: rgba(255, 255, 255, 0.82);
    --faint: rgba(255, 255, 255, 0.66);
  }

  .footer-col a,
  .nav-links a {
    opacity: 1;
  }

  .pill-nav,
  #news-email {
    border-color: rgba(255, 255, 255, 0.55);
  }
}

@media (max-width: 420px) {
  .io-label { display: none; }
  .copy { max-width: 24ch; }
}

/* ── Footer (reference-style, in our palette) ───────────────────────────── */

/* ══ COLOUR IN TYPE ══════════════════════════════════════════════════════
 * Measured on /platforms/alibaba-cloud, 20,202 characters of body text:
 *
 *     white or a transparency of white ........ 97.8%
 *     --text-accent ............................ 0.1%   (30 characters)
 *     --amber-500 .............................. 0.0%   (8 characters)
 *     Chrome's default link purple ............. 1.2%   ← not ours at all
 *
 * So the palette was not missing. It was unused, and the only real hue on the
 * page was an accident: there is no bare `a` rule anywhere in this file, so
 * every classless link in prose fell through to the browser's own dark-mode
 * link colour. #9e9eff is in nobody's brand.
 *
 * THE RULE: colour marks the reading PATH, never the reading.
 *
 * Body copy is never coloured — a paragraph in blue is harder to read and says
 * nothing a paragraph in white does not. What earns a hue is the furniture a
 * scanner uses to move through the page, and there are only four such jobs:
 *
 *     orient   the eyebrow      "a new section starts here"   accent-dim
 *     weigh    the lead figure  "this is the number"          accent
 *     exit     a link in prose  "this leads somewhere"        accent
 *     warn     the caveat       the one alert hue, rationed   amber
 *
 * Everything else stays on the four-step white value scale (hi / body / dim /
 * hint), which is what carries the hierarchy and will keep carrying it.
 *
 * THE BUDGET: hue stays at or under 12% of a page's characters. This is a
 * corporate site — the goal is a page that is not monotonous, not a page that
 * is colourful, and the difference between those is a number. It is measured,
 * not eyeballed: tests/site/type-colour.test.mjs walks the built pages and
 * fails the build if any page goes over.
 *
 * WHY BLUE AND NOT THE VENDOR'S HUE: on the platform pages the vendor's colour
 * is already spoken for — it is the mark and the product icons, and it means
 * "this is Google's" or "this is Alibaba's". Our accent means "this is ours".
 * Letting the two mix would cost the page the one distinction it is built on.
 * ════════════════════════════════════════════════════════════════════════ */

/* Classless links in text flow — the ones that were rendering purple. Scoped
 * to `a:not([class])` inside text-flow elements on purpose: every component
 * link on the site (nav, buttons, cards, jump pills, crumbs) already carries a
 * class and its own colour, and a bare `a { }` rule would repaint all of them.
 * Underline stays: colour alone is never the only signal a link is a link.
 *
 * `th` is in the list, and it was missed the first time: five links on
 * /services/agentic-ai sit in a row LABEL rather than in the description cell,
 * and they were still rendering in the browser's purple. Where a label is also
 * a link, the link wins — the accent plus the underline is what tells a reader
 * the row is clickable, and a row label that only looked like a label would
 * hide that. */
:is(p, li, td, th, dd, figcaption) > a:not([class]) {
  color: var(--text-accent);
  text-decoration-color: rgb(var(--blue-500-rgb) / 0.45);
  text-underline-offset: 0.18em;
}

:is(p, li, td, th, dd, figcaption) > a:not([class]):hover,
:is(p, li, td, th, dd, figcaption) > a:not([class]):focus-visible {
  color: var(--blue-300);
  text-decoration-color: currentColor;
}

/* The brand pill sits on a light ground, so its anchor takes the inverse ink.
 * It was inheriting the same browser default as the prose links; nothing
 * visible read from it, but a stray `currentColor` in the mark would have. */
.nav-mark {
  color: var(--text-on-inverse);
}

/* ── the content-page format ─────────────────────────────────────────────
 * `.sd-page` on <main> marks a page that runs the full colour format: the 28
 * Services, Platforms, Hub, Industries, Packages, Solutions and Resources
 * pages. The homepage, About, 404 and the styleguide are deliberately NOT in
 * it — between them they carry one or two eyebrows, and this format exists to
 * break up long scanning documents, which those are not.
 *
 * A page-level hook rather than component-scoped rules, because the hero
 * eyebrow sits OUTSIDE any .sd-block. Scoping to the component would colour
 * the section eyebrows and leave the hero's on the old value, which reads as a
 * bug rather than as a system.
 *
 * Measured on the real pages before it was written: 2.6% → 6.6% of characters
 * on /platforms/google-cloud, 2.4% → 8.8% on /platforms/alibaba-cloud. The
 * budget is 12%.
 *
 * There is NO amber rule here, although the mock that this was chosen from
 * carried one. Amber is the alert hue and it is rationed: nothing on these
 * pages is a warning, and spending it on a footnote would leave nothing to
 * warn with later. */
.sd-page .eyebrow {
  color: var(--text-accent);
}

/* Row labels. `<th scope="row">` is the key you scan the column FOR — it is
 * furniture, not prose. The description beside it in the `<td>` is the prose,
 * and that stays on the white scale. */
.sd-page .sd-table tbody th {
  color: var(--blue-100);
}

/* The fact tile's label under its figure — the unit the number is counted in.
 *
 * `:has(.sd-proof-figure)` is load-bearing, not decoration. `.svc-why-card`
 * carries TWO roles: on the platform pages it is a fact tile, where the h3 is
 * a small label under a big number ("products", "regions, 130 zones"); on the
 * Services Hub it is a service card, where the h3 is the card's TITLE and the
 * primary thing on it. Without the guard this rule demoted all seven Hub titles
 * to a dim label, which is the opposite of what they are. */
.sd-page .svc-why-card:has(.sd-proof-figure) h3 {
  color: var(--text-accent-dim);
}

/* …and the other role. A linked card title stays a title: --text-hi, with the
 * accent moved onto the UNDERLINE instead of the words. The Hub's seven titles
 * were rendering in Chrome's purple with a default underline — they read as
 * unstyled links rather than as headings, which is what the reader first sees
 * on the page that routes them to everything else. */
.sd-page .svc-why-card h3 > a:not([class]) {
  color: var(--text-hi);
  text-decoration-color: rgb(var(--blue-500-rgb) / 0.5);
  text-underline-offset: 0.16em;
}

.sd-page .svc-why-card h3 > a:not([class]):hover,
.sd-page .svc-why-card h3 > a:not([class]):focus-visible {
  color: var(--blue-300);
  text-decoration-color: currentColor;
}

/* The "emphasise" role: the phrase inside a paragraph that carries its point.
 * This currently matches almost nothing — the platform pages contain zero
 * <strong> in prose. The rule is here so the role exists the moment a copy
 * pass starts marking them; it is the one part of this format a stylesheet
 * cannot deliver on its own. */
.sd-page :is(.sd-block, .sd-hero) :is(p, li) > strong {
  color: var(--blue-100);
  font-weight: 600;
}

/* ── eyebrow — the section-level kicker, defined ONCE ────────────────────
 * Canonical class is .eyebrow; the four legacy names alias it during
 * migration. Anatomy: a --text-accent "//" span, then Title-case text in
 * --text-accent-dim. Chat name: "eyebrow".
 *
 * The text was --text-hint, which put the one element repeated on every
 * section of every page — the reader's whole sense of "where am I" — on the
 * faintest step of white the palette has. It now takes accent-dim, measured at
 * 4.9:1 on the page ground, the same contrast --text-hint was carrying. Same
 * legibility, and the section markers finally read as a set. */
.eyebrow,
.top-kicker,
.partners-eyebrow,
.moves-eyebrow,
.footer-head,
.dir-title {
  margin: 0 0 1rem;
  font-size: var(--text-xs);
  font-weight: 500;
  letter-spacing: 0.06em;
  color: var(--text-accent-dim);
}

.moves-eyebrow {
  margin-bottom: 0;
}

.footer-head {
  margin-bottom: 1.35rem;
}

.eyebrow span,
.top-kicker span,
.partners-eyebrow span,
.moves-eyebrow span,
.footer-head span {
  margin-right: 0.55rem;
  color: var(--text-accent);
}

.footer {
  position: relative;
  overflow: hidden;
  padding: clamp(4rem, 9vh, 7rem) clamp(1.5rem, 5.5vw, 5rem) clamp(1.75rem, 4vh, 3rem);
  background:
    radial-gradient(90vmax 55vmax at 50% 115%, rgba(46, 90, 168, 0.34) 0%, rgba(30, 60, 120, 0.16) 38%, transparent 70%),
    linear-gradient(180deg, #010209 0%, #030617 45%, #060c26 100%);
  border-top: 1px solid rgb(var(--ink-rgb) / 0.05);
}

/* Perspective floor grid fading upward — the reference's ground plane,
 * recoloured to the blue theme. */
.footer::after {
  content: "";
  position: absolute;
  inset: auto -20% 0 -20%;
  height: 58%;
  background:
    repeating-linear-gradient(90deg, rgb(var(--blue-500-rgb) / 0.09) 0, rgb(var(--blue-500-rgb) / 0.09) 1px, transparent 1px, transparent 88px),
    repeating-linear-gradient(0deg, rgb(var(--blue-500-rgb) / 0.08) 0, rgb(var(--blue-500-rgb) / 0.08) 1px, transparent 1px, transparent 60px);
  transform: perspective(430px) rotateX(58deg);
  transform-origin: 50% 100%;
  -webkit-mask-image: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.75) 55%, #000 100%);
  mask-image: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.75) 55%, #000 100%);
  pointer-events: none;
}

/* A ring cluster peeking in from the top-right corner — most of it is off
 * the edge, so the visible arc reads as slow rotation. */
.footer-rings {
  position: absolute;
  top: 0;
  right: 0;
  width: min(46rem, 110vw);
  aspect-ratio: 1;
  /* Anchored to the corner then pushed out by its own size, so roughly the
   * same arc fraction peeks in at every viewport width. */
  transform: translate(42%, -40%);
  opacity: 0.5;
  pointer-events: none;
}

.footer-rings svg {
  width: 100%;
  height: 100%;
}

.footer-inner {
  position: relative;
  z-index: 1;
  display: grid;
  gap: clamp(2.5rem, 6vh, 4rem);
}

.footer-cols {
  display: grid;
  /* Five now: Platforms joined when the platform pages shipped. The two
   * breakpoints below still fold this to 2 and then 1.
   *
   * The last column sizes to its content rather than taking a fifth: at five
   * equal columns the contact address (186px) overflowed a 136px column and ran
   * past the footer's right edge. Contact takes what it needs, the other four
   * share the rest. */
  grid-template-columns: repeat(4, minmax(0, 1fr)) minmax(0, auto);
  gap: clamp(1.5rem, 4vw, 3rem);
}

.footer-col ul {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 1.05rem;
  justify-items: start;
}

.footer-col a {
  color: var(--ink);
  opacity: 0.88;
  text-decoration: none;
  font-size: var(--text-base);
  transition: opacity 0.15s ease;
}

.footer-col a:hover,
.footer-col a:focus-visible {
  opacity: 1;
}

/* A footer entry with no page behind it — Reeeed is ours and is named on five
 * pages, but it has no page of its own and is not in the nav's product menu.
 * Left as plain text rather than pointed somewhere it is not. Dimmer than the
 * links around it, because an unlinked item inheriting full-strength ink reads
 * as the MOST important thing in the column, which is backwards. */
.footer-col li > span {
  color: var(--ink);
  opacity: 0.5;
  font-size: var(--text-base);
}

/* A long address must wrap rather than escape the column. */
.footer-col a {
  overflow-wrap: anywhere;
}

.footer-rule {
  height: 1px;
  background: rgb(var(--ink-rgb) / 0.14);
}

.footer-news {
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 3rem);
  align-items: start;
}

.news-copy h2 {
  margin: 0 0 0.7rem;
  font-size: var(--text-xl);
  font-weight: 600;
}

.news-copy p {
  margin: 0;
  max-width: 46ch;
  font-size: var(--text-base);
  color: var(--muted);
}

.news-form {
  display: grid;
  gap: 0.8rem;
}

.news-row {
  display: flex;
  gap: 0.6rem;
}

#news-email {
  flex: 1;
  min-width: 0;
  padding: 0.78rem 1rem;
  border-radius: 10px;
  border: 1px solid rgb(var(--ink-rgb) / 0.14);
  background: rgb(var(--ink-rgb) / 0.06);
  color: var(--ink);
  font: inherit;
  font-size: var(--text-base);
}

#news-email::placeholder {
  color: var(--text-hint);
}

#news-email:focus-visible {
  outline: 2px solid var(--blue-500);
  outline-offset: 1px;
}

.news-join {
  padding: 0.78rem 1.6rem;
  border: 0;
  border-radius: 10px;
  background: var(--surface-inverse);
  color: var(--text-on-inverse);
  font: inherit;
  font-size: var(--text-base);
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.15s ease;
}

.news-join:hover,
.news-join:focus-visible {
  transform: scale(1.04);
}

.news-consent {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  font-size: var(--text-sm);
  color: var(--muted);
}

.news-consent input {
  width: 1.05rem;
  height: 1.05rem;
  accent-color: var(--blue-500);
}

/* The form is real since /subscribe arrived (src/cms/newsletter.mjs). What
 * follows is the states it can be in.
 *
 * The trap is a field that form-filling bots fill and people never meet: off
 * screen rather than display:none (bots skip what is not rendered), and the
 * markup keeps it out of the tab order and away from assistive tech. */
.news-trap {
  position: absolute;
  left: -10000px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* The answer, under the box. Its words stay on the white scale — the colour
 * policy keeps hue off sentences — and the state rides on a small marker in
 * front: the accent for "done", the one alert hue for "fix this". Empty, it
 * takes no row in the form's grid. */
.news-status {
  display: flex;
  align-items: flex-start;
  gap: 0.55rem;
  margin: 0;
  max-width: 46ch;
  font-size: var(--text-sm);
  line-height: 1.5;
  color: var(--text-body);
}

.news-status:empty {
  display: none;
}

.news-status::before {
  content: "";
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  margin-top: 0.45em;
  border-radius: 50%;
  background: var(--text-hint);
}

.news-status[data-state="ok"]::before {
  background: var(--text-accent);
}

.news-status[data-state="error"]::before {
  background: var(--amber-500);
}

.news-status strong {
  font-weight: 600;
  color: var(--text-hi);
  overflow-wrap: anywhere;
}

#news-email[aria-invalid="true"] {
  border-color: var(--amber-500);
}

.news-consent input[aria-invalid="true"] {
  outline: 2px solid var(--amber-500);
  outline-offset: 2px;
}

.news-form[aria-busy="true"] .news-join {
  opacity: 0.6;
  cursor: progress;
}

/* Signed up: the box and the tick give way to the thank-you. A state on the
 * form rather than [hidden] on each part, because both parts set their own
 * display and would outrank the browser's [hidden] rule. */
.news-form[data-state="done"] .news-row,
.news-form[data-state="done"] .news-consent {
  display: none;
}

.footer-base {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 1rem;
  font-size: var(--text-sm);
  color: var(--muted);
}

.footer-base p {
  margin: 0;
}

.footer-legal {
  display: flex;
  gap: 2rem;
}

.footer-legal a {
  color: var(--muted);
  text-decoration: none;
}

.footer-legal a:hover,
.footer-legal a:focus-visible {
  color: var(--ink);
}

/* The columns fold before the newsletter does. 760px was tuned when this was a
 * four-column footer; at five, the first four are squeezed to ~94px just above
 * that breakpoint and every second label wraps. Fold them at 900 instead and
 * leave the newsletter's own breakpoint where it was. */
@media (max-width: 900px) {
  .footer-cols {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    row-gap: 2.25rem;
  }
}

@media (max-width: 760px) {
  .footer-news {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 420px) {
  .footer-cols {
    grid-template-columns: 1fr;
  }

  .footer-base {
    flex-direction: column;
    align-items: flex-start;
  }
}

/* ── Clients — minimal single-row marquee ───────────────────────────────── */

.clients {
  position: relative;
  overflow: hidden;
  padding: clamp(3.5rem, 8vh, 5.5rem) 0;
}

.clients-inner {
  padding-inline: clamp(1.5rem, 5.5vw, 5rem);
  margin-bottom: clamp(2rem, 5vh, 3rem);
}

/* Two-tone lede, reference-style: bright claim, muted continuation. It now
 * follows the partners grid, so it carries a heading's weight rather than a
 * caption's. */
.clients-lede {
  margin: 0;
  max-width: 46ch;
  font-size: var(--text-xl);
  line-height: 1.4;
  letter-spacing: -0.01em;
  color: var(--muted);
}

.clients-lede strong {
  font-weight: 600;
  color: var(--ink);
}

.clients-marquee {
  overflow: hidden;
  /* Logos dissolve at both edges instead of clipping hard. */
  -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 8%, #000 92%, transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0%, #000 8%, #000 92%, transparent 100%);
}

.clients-track {
  display: flex;
  align-items: center;
  width: max-content;
  margin: 0;
  padding: 0;
  list-style: none;
  animation: clients-scroll 55s linear infinite;
}

.clients-marquee:hover .clients-track {
  animation-play-state: paused;
}

/* The spacing is padding INSIDE each item, not flex `gap`. With `gap`, a
 * track of 2n items measures 2n items + (2n-1) gaps, so -50% lands half a
 * gap short of item n+1 and the loop jumps once per cycle. Folding the
 * space into the item makes the track exactly 2n equal pitches. */
.clients-track li {
  flex: none;
  padding-right: clamp(3rem, 6vw, 5.5rem);
}

.clients-track img {
  display: block;
  width: auto;
  /* The floor is what a phone gets (+20%) and the ceiling what a desktop
   * gets (+10%) — one clamp covers both asks. */
  height: clamp(89px, 9.5vw, 113px);
  opacity: 0.5;
  transition: opacity 0.18s ease;
}

.clients-track img:hover {
  opacity: 0.9;
}

/* The track holds two identical halves, so half a track = one full set. */
@keyframes clients-scroll {
  to { transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .clients-track {
    animation: none;
  }

  /* Still reachable: swipe or scroll through the row by hand. */
  .clients-marquee {
    overflow-x: auto;
    -webkit-mask-image: none;
    mask-image: none;
  }
}

/* ── Binary field panel (gamut.so-style background study) ───────────────── */

.binary-panel {
  position: relative;
  /* No longer a full screen: the script closes the panel onto its own type
   * (data-binary-hug) with the inset it is given. This floor only matters
   * before the script runs, and without it — where the DOM copies below set
   * the height instead. */
  min-height: 12rem;
  overflow: hidden;
  /* No-JS fallback: a faint static tile of the same 10px "0/1" pitch. The
   * canvas replaces it as soon as the script runs. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cg font-family='monospace' font-size='9' fill='%23a9cbe8' fill-opacity='0.22'%3E%3Ctext x='5' y='8' text-anchor='middle'%3E1%3C/text%3E%3Ctext x='15' y='8' text-anchor='middle'%3E0%3C/text%3E%3Ctext x='5' y='18' text-anchor='middle'%3E0%3C/text%3E%3Ctext x='15' y='18' text-anchor='middle'%3E1%3C/text%3E%3C/g%3E%3C/svg%3E");
  background-size: 20px 20px;
}

.js .binary-panel {
  background-image: none;
}

/* Without JS the two lines are ordinary text over the static tile. With the
 * script running the canvas spells them out in 0s and 1s instead, so the DOM
 * copies step aside — they stay in the tree as the strings' source. */
.binary-panel {
  display: grid;
  align-content: center;
  justify-items: start;
  /* 100px of air above and below the slab. In canvas mode the script sets the
   * panel's height to the type plus this same inset; here it is what gives the
   * DOM copies the same rhythm. */
  padding: 100px clamp(1.5rem, 5.5vw, 5rem);
  text-align: left;
}

/* Heavy uppercase slab, stacked tight and ragged-right — the same shape the
 * canvas spells out in 0s and 1s. */
.binary-line {
  margin: 0;
  max-width: 18ch;
  font-size: clamp(1.9rem, 6.4vw, 4.6rem);
  font-weight: 800;
  line-height: 1.06;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  color: var(--blue-100);
}

/* Taken out of flow, not just hidden: left in place they would stretch the
 * panel past one screen and drag the canvas's centre line with it. */
.js .binary-line {
  display: none;
}

/* …except on a phone, where the 10px cell grid is too coarse to hold a
 * legible letter, so the words are set as real type over the field. */
.js .text-in-dom .binary-line {
  display: block;
  position: relative;
  z-index: 1;
}

.binary-line[data-marquee] {
  align-self: stretch;
  max-width: none;
  overflow: hidden;
}

.marquee-track {
  display: inline-flex;
  white-space: nowrap;
}

.js .text-in-dom .marquee-track {
  animation: binary-marquee 13s linear infinite;
}

@keyframes binary-marquee {
  to { transform: translateX(-50%); }
}

/* Phone: no character cap at all. Capping the measure broke each line at
 * whatever word crossed it and left whatever fell short stranded — the ragged
 * hole down the right-hand side. The three lines are the same length as
 * written, so left whole they set their own even measure, and the size is
 * what makes them span the screen. */
@media (max-width: 760px) {
  .binary-line {
    max-width: none;
    /* Sized for the longest state the block ever reaches, which is the
     * cycling line wearing its caret — tuned to the bare words and that one
     * extra character tips it over and wraps. */
    font-size: clamp(1.5rem, 8.1vw, 3rem);
  }
}

@media (prefers-reduced-motion: reduce) {
  .marquee-track {
    animation: none;
  }
}

.binary-canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

/* ── Page-top band — the pattern every inner page opens with ─────────────
 * The same 0/1 field as the hero panel, 300px tall, carrying the page's own
 * name. The nav pill floats over its top edge, so the title is hung off the
 * BOTTOM (app.js: data-binary-align="bottom") with a 20px foot. Adding it to
 * a new page is markup only — no CSS or JS per page.
 * data-binary-lit turns on the hover recolour: letter cells under the pointer
 * pick a random light primitive on top of growing and scrambling. */
.page-top {
  position: relative;
  height: 300px;
  overflow: hidden;
  display: grid;
  align-content: end;
  justify-items: start;
  padding: 0 clamp(1.5rem, 5.5vw, 5rem) 20px;
  /* Same no-JS fallback tile as the panel — replaced by the canvas on load. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cg font-family='monospace' font-size='9' fill='%23a9cbe8' fill-opacity='0.22'%3E%3Ctext x='5' y='8' text-anchor='middle'%3E1%3C/text%3E%3Ctext x='15' y='8' text-anchor='middle'%3E0%3C/text%3E%3Ctext x='5' y='18' text-anchor='middle'%3E0%3C/text%3E%3Ctext x='15' y='18' text-anchor='middle'%3E1%3C/text%3E%3C/g%3E%3C/svg%3E");
  background-size: 20px 20px;
}

.js .page-top {
  background-image: none;
}

/* The hero panel's slab type is sized for a full screen; the DOM copy here
 * (no-JS, and phone widths where the cell grid is too coarse to spell) needs
 * its own measure. */
.page-top .binary-line {
  max-width: none;
  font-size: clamp(2rem, 7vw, 3.4rem);
}

/* ── About page body ──────────────────────────────────────────────────────
 * Structure borrowed from the reference the owner supplied — oversized
 * numerals, numbered rows, a full-bleed pull quote — but kept in this site's
 * dark palette rather than its light one. The reference alternates black and
 * white sections; adopting that here would mean a second visual identity for
 * one page, so only the devices were taken, not the ground. */

.about-open,
.story,
.build,
.impact,
.milestones,
.trust,
.about-cta,
.svc-close {
  padding: clamp(3.5rem, 9vh, 7rem) clamp(1.5rem, 5.5vw, 5rem);
}

/* ── The section ring ────────────────────────────────────────────────────
 * One circle across the four sections from "What we build" down to
 * "Credentials", carrying twice a hero ring's segment density so the size
 * reads as detail rather than as a blown-up copy.
 *
 * That run is about twice as tall as the page is wide, and a corner-anchored
 * quarter-circle only ever covers a SQUARE — side equal to its radius. So a
 * quarter cannot fill this shape: sized to the height it collapses into a
 * band across the top, sized to the width it sits in the bottom third. The
 * centre therefore moves to the right of the page, level with the middle of
 * the run, and the visible slice grows from a quarter to about a third.
 *
 * Two numbers set it, both as a share of the run's height H:
 *   radius r = 0.52H  — reaches from near the top of the run to near the
 *                       bottom, which is what "spans the four sections" means
 *   centre  = 0.26H right of the page edge — pushes the arc's leftmost point
 *                       out to roughly mid-page so it sweeps beside the copy
 *                       rather than straight through the middle of it
 * The circle draws at 92% of its box (r=460 of a 1000 viewBox), hence the
 * box height of 0.52 / 0.46 = 113%, and the 23% of that box the transform
 * adds on top of the 50% that lands the centre on the page edge. */
.ring-span {
  position: relative;
  overflow: hidden;
}

.section-rings {
  position: absolute;
  right: 0;
  top: 50%;
  height: 113%;
  aspect-ratio: 1;
  transform: translate(73%, -50%);
  opacity: 0.34;
  pointer-events: none;
}

.section-rings svg {
  width: 100%;
  height: 100%;
}

/* At this radius the rim covers far more ground per degree than the hero's
 * rings do, so the shared 150s would read as hurried. Duration only — the
 * reduced-motion reset kills the shorthand and must keep winning. */
.section-rings .ring-1 {
  animation-duration: 320s;
}

/* Everything that carries words has to sit above the artwork. */
.ring-span > section {
  position: relative;
  z-index: 1;
}

/* Below desktop the copy uses the full width, so the arc would have nothing
 * to sweep through but the text itself. */
@media (max-width: 1100px) {
  .section-rings {
    display: none;
  }
}

/* ── Graphic element: fan ────────────────────────────────────────────────
 * A polar grid of coloured blocks — concentric rings crossed by radial
 * sectors, each cell inked or blank. Generated by scripts/gen-fan.mjs; see the
 * header there for the geometry and the flags.
 *
 * The generator emits COLOUR SLOTS, never hex, and this block is the only
 * place the slots resolve. That is the whole point of the split: one asset
 * re-themes from here, the art layer stays inside the token contract, and a
 * palette change is a CSS edit rather than a regeneration.
 *
 * Roles, not shades — a re-theme has to keep the rhythm:
 *   a…e  the five that carry the colour
 *   q1…2 the quiet mass, most of the field, which is what stops it vibrating
 *   t1…2 washed-out tints that keep it from reading as flat
 * Ratios live in the generator (34% quiet, 13% tint); they are a property of
 * the pattern, not of the palette. */
.fan {
  display: block;
  width: 100%;
  height: 100%;
}

.fan-a { fill: var(--blue-500); }
.fan-b { fill: var(--teal-500, #3fb6a8); }
.fan-c { fill: var(--amber-500, #e8b84b); }
.fan-d { fill: var(--blue-700); }
.fan-e { fill: var(--navy-800); }
.fan-q1 { fill: rgb(var(--ink-rgb) / 0.09); }
.fan-q2 { fill: rgb(var(--ink-rgb) / 0.05); }
.fan-t1 { fill: var(--blue-300); fill-opacity: 0.55; }
.fan-t2 { fill: var(--blue-100); fill-opacity: 0.4; }

/* On a light ground the quiet mass has to invert — ink at 9% is invisible on
 * paper. Everything else holds, because the five carriers are mid-tone. */
.fan--paper .fan-q1 { fill: rgb(var(--navy-0-rgb) / 0.1); }
.fan--paper .fan-q2 { fill: rgb(var(--navy-0-rgb) / 0.05); }

/* ── fan · motion ────────────────────────────────────────────────────────
 * The fan does NOT spin, and that is a measured decision rather than taste.
 * The system's ambient rule is ≥ 8px/s or stay still. Spun at --drift-2 a
 * mark only reaches 8px/s past r=613, but the marks start at r=90 — so the
 * inner 44% of the radius would be buying repaints for movement nobody can
 * see, which is the exact case the rule exists to forbid. --drift-4 fails
 * even at the rim (7.7px/s).
 *
 * So the ambient moves LIGHT across a still field instead. That also keeps
 * the cost flat: one composited pseudo-element, whether the fan holds 500
 * marks or 5000. Spinning would have scaled with the mark count.
 *
 * It reads as the page's own sentence, too — the pulse leaves the eye and
 * arrives at the rim: deep input, real output. */
.fan-stack {
  position: relative;
  display: grid;
}

.fan-stack > .fan {
  grid-area: 1 / 1;
}

/* A ring of light leaving the centre. It travels in the first quarter of the
 * cycle and rests for the other three, which is what keeps it ambient rather
 * than a strobe — and means the only moving quarter moves FAST (about
 * 128px/s at a 640px render), clearing the 8px/s floor with room to spare.
 * Period is the declared loop token at the system's stated 3× pulse. */
.fan-stack.fan--pulse::after {
  content: "";
  grid-area: 1 / 1;
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(
    closest-side,
    transparent 54%,
    rgb(var(--blue-300-rgb) / 0.26) 70%,
    rgb(var(--ink-rgb) / 0.1) 78%,
    transparent 86%
  );
  mix-blend-mode: plus-lighter;
  animation: fan-pulse calc(var(--motion-loop) * 3) linear infinite;
}

/* With an entrance in play the pulse holds until the last band has landed —
 * a light ring crossing marks that have not arrived yet reads as a glitch.
 * Derived from the stagger, not typed as a literal: last delay + enter. */
.fan-stack.fan--pulse.fan--build::after {
  animation-delay: calc(350ms + var(--motion-enter));
}

/* transform + opacity only, so the 3000 marks underneath are never repainted. */
@keyframes fan-pulse {
  0% { transform: scale(0.06); opacity: 0; }
  4% { opacity: 1; }
  25% { transform: scale(1.15); opacity: 0; }
  100% { transform: scale(1.15); opacity: 0; }
}

/* ── fan · the turning 0/1 rows ──────────────────────────────────────────
 * Some rows are 0/1 on a circle instead of blocks, and those DO turn. That is
 * not a contradiction of the no-spin rule above: the floor is about speed, and
 * speed depends on radius. gen-fan.mjs solves 2πr·(render/viewBox)/period ≥ 8
 * per row, gives each the slowest token it is allowed, and leaves a row that
 * cannot clear the floor static — at a small enough render it emits no turning
 * rows at all rather than shipping invisible motion.
 *
 * They carry .ring, the site's rotation base, so transform-origin, the
 * offstage pause and the reduced-motion reset all come for free. Durations
 * read the drift tokens rather than restating the seconds, which the legacy
 * .ring-N rules above still do. */
.fan-spin-1 { animation: spin-cw var(--drift-1) linear infinite; }
.fan-spin-2 { animation: spin-cw var(--drift-2) linear infinite; }
.fan-spin-3 { animation: spin-cw var(--drift-3) linear infinite; }
.fan-spin-4 { animation: spin-cw var(--drift-4) linear infinite; }

/* Sense is its own class, so a row's direction and its speed stay independent. */
.fan-rev { animation-direction: reverse; }

/* Phase. The system is explicit that ring variety comes from direction and
 * PHASE rather than from inventing durations, and that matters here because
 * the floor genuinely leaves some radii only ONE legal token — at r=527 on a
 * 640px render nothing but --drift-1 clears 8px/s, so two rows there cannot be
 * given different speeds. Pulling them out of step is what stops them reading
 * as one welded ring. These are delays, not periods: no new duration enters
 * the system. Negative, so every row is already mid-cycle on the first frame
 * instead of the whole fan starting from a common zero. */
.fan-phase-1 { animation-delay: -7s; }
.fan-phase-2 { animation-delay: -19s; }
.fan-phase-3 { animation-delay: -31s; }
.fan-phase-4 { animation-delay: -43s; }
.fan-phase-5 { animation-delay: -53s; }
.fan-phase-6 { animation-delay: -61s; }
.fan-phase-7 { animation-delay: -73s; }
.fan-phase-8 { animation-delay: -89s; }

/* Row fade — a second axis, applied to the row wrapper rather than the marks.
 *
 * The colour slots already carry a fill-opacity, but that belongs to the ROLE:
 * every row inked with .fan-a came out at exactly the same strength, so the
 * field stayed flat however varied its weights and rates were. `opacity` on
 * the wrapper is a different property and multiplies with the slot's own, so
 * the two compose instead of one overwriting the other — and the role split
 * the palette depends on survives intact.
 *
 * Weighted toward the strong end by the generator: the faint rows are the
 * exception that gives the field depth, not the rule. */
.fan-fade-1 { opacity: 1; }
.fan-fade-2 { opacity: 0.86; }
.fan-fade-3 { opacity: 0.72; }
.fan-fade-4 { opacity: 0.58; }
.fan-fade-5 { opacity: 0.45; }
.fan-fade-6 { opacity: 0.34; }

.fan-digits {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-weight: 500;
  letter-spacing: 0.3em;
  fill: var(--blue-300);
  fill-opacity: 0.42;
}

/* Enter, not ambient: the bands arrive from the eye outward. Six groups carry
 * it — the reason the generator emits bands at all. Animating the marks
 * themselves would be 3000 nodes for the same 240ms.
 *
 * It waits for the stage gate. An entrance that fires on load is spent before
 * anyone scrolls to it, so the bands hold at nothing until the gate opens —
 * app.js starts every staged section closed for exactly this reason. */
.js .fan--build .fan-band {
  opacity: 0;
}

.js .fan--build:not(.offstage) .fan-band {
  animation: fan-band-in var(--motion-enter) var(--ease-out) forwards;
}

.js .fan--build:not(.offstage) .fan-band-2 { animation-delay: 70ms; }
.js .fan--build:not(.offstage) .fan-band-3 { animation-delay: 140ms; }
.js .fan--build:not(.offstage) .fan-band-4 { animation-delay: 210ms; }
.js .fan--build:not(.offstage) .fan-band-5 { animation-delay: 280ms; }
.js .fan--build:not(.offstage) .fan-band-6 { animation-delay: 350ms; }

/* The 0/1 rows arrive last, after the blocks they sit among. The wrapper takes
 * the fade so the rows inside keep their own `animation` free for the spin —
 * one element cannot run both on the same property. */
.js .fan--build .fan-digits-layer {
  opacity: 0;
}

.js .fan--build:not(.offstage) .fan-digits-layer {
  animation: fan-band-in var(--motion-enter) var(--ease-out) 420ms forwards;
}

@keyframes fan-band-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* Infinite loops stop off screen, like every other ambient on the site. */
.offstage .fan-stack.fan--pulse::after {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  /* Replace, don't freeze: the pulse is removed rather than parked mid-sweep,
   * and the bands are simply present instead of arriving. */
  .fan-stack.fan--pulse::after { display: none; }

  .js .fan--build .fan-band,
  .js .fan--build .fan-digits-layer {
    opacity: 1;
    animation: none;
  }

  /* The turning rows have to be stopped HERE, not by the global .ring reset
   * further up the file. That reset and .fan-spin-* are both (0,1,0), and
   * .fan-spin-* is declared later — so it would win and the rows would keep
   * turning for a reader who asked for stillness. Same trap the reveal engine
   * documents; this is the explicit fix rather than a :where() dilution. */
  .fan-spin-1,
  .fan-spin-2,
  .fan-spin-3,
  .fan-spin-4 {
    animation: none;
  }
}

/* ── Services hero ───────────────────────────────────────────────────────
 * Copy left, artwork right, per the reference. The artwork is not a picture
 * but the site's own ring language at density — so it inherits the palette,
 * the spin, the reduced-motion reset and the offstage pause for free. */
.svc-hero {
  /* Deliberately NOT overflow:hidden. The artwork clips itself, and the hero
   * clipping too is what used to cut it off flat at the section boundary. */
  position: relative;
  display: grid;
  /* One column: the artwork is out of flow, so a second track would only
   * reserve space the copy is not allowed to use. The copy keeps its measure
   * from max-width instead, which is what stops it running under the fan. */
  grid-template-columns: minmax(0, 1fr);
  align-items: center;
  padding: clamp(4rem, 11vh, 8rem) clamp(1.5rem, 5.5vw, 5rem);
}

.svc-hero-copy {
  position: relative;
  z-index: 1;
  /* Held clear of the artwork. Wider than this and the last lines of the lede
   * start crossing into the fan, where they lose contrast. */
  max-width: min(34rem, 52%);
}

.svc-hero h1 {
  margin: 0.2rem 0 0;
  max-width: 12ch;
  font-size: var(--text-display);
  font-weight: 700;
  line-height: 1.02;
  letter-spacing: -0.03em;
  color: var(--text-hi);
}

.svc-hero-sub {
  margin: clamp(1.5rem, 4vh, 2.5rem) 0 0;
  max-width: var(--measure-lede);
  font-size: var(--text-lg);
  line-height: var(--lh-body, 1.6);
  color: var(--text-body);
}

/* The vortex. Its own clip, so it stops at the column edge and leaves the
 * copy's half clean — the section itself must NOT clip, or the artwork could
 * not run past the page gutter the way the reference's does. */
/* Full height of the section, anchored to the right. The clip lives here so
 * the fan can be far larger than the frame and still be tidy at the edges. */
.svc-hero-art {
  /* How far the artwork runs past the hero's bottom edge. Three measurements
   * at 1440, all relative to the hero's bottom at 985:
   *   299px — clears the badge strip (it ends at 1284)
   *   345px — clears the WHEEL's own lower arc (it ends at 1330), so the
   *           curve finishes by itself instead of meeting a straight cut
   *   396px — the first service line starts (1381); the ceiling
   * 23rem = 368px sits above the second and below the third, which is the
   * only band where the badges are covered AND the arc is not chopped.
   *
   * Desktop only in effect. Stacked, the wheel's own lower edge sits ~340px
   * above that paragraph, so a taller box there reveals nothing; pushing the
   * wheel down to meet it puts the copy over its densest rings. */
  --bleed: 23rem;
  position: absolute;
  inset: 0 0 auto auto;
  width: min(56rem, 62%);
  height: calc(100% + var(--bleed));
  overflow: hidden;
  pointer-events: none;
  /* The wheel is a background, not a subject. The mask fades its dense side
   * out before it reaches the copy, so the text never has to fight it. */
  mask-image: linear-gradient(to right, transparent 0%, rgb(0 0 0 / 0.22) 26%, rgb(0 0 0) 66%);
}

/* The artwork is positioned, so it would otherwise paint over the sections it
 * now reaches into — the overview for certain, and the first service line if
 * anything reflows into the 28px that separates them. */
.svc-overview,
.svc-line {
  position: relative;
  z-index: 1;
}

/* The fan sits with its CENTRE on the column's right edge, so what shows is a
 * slice of the wheel rather than a disc parked in a box — the arcs read as
 * arcs, and the eye stays off-canvas the way the reference frames it.
 *
 * .fan-stack is the fan's own box, nested inside the clip rather than merged
 * with it: the pulse is a ::after sharing the fan's grid cell, so it has to be
 * sized and centred on the fan, not on the column. */
.svc-hero-art .fan-stack {
  position: absolute;
  /* Centred on the HERO, not on the box — the box is taller than the hero by
   * the bleed, so a plain 50% would drag the wheel down with it and reframe
   * the composition the bleed was only supposed to extend. */
  top: calc(50% - var(--bleed) / 2);
  left: 100%;
  width: min(86rem, 100vw);
  aspect-ratio: 1;
  transform: translate(-50%, -50%);
}

/* Stacked, the fan becomes a band under the copy: it keeps the page's
 * character at a width where two columns would leave the headline too narrow
 * to land. */
@media (max-width: 900px) {
  /* No room for a column beside the copy, so the fan becomes a wash behind it
   * — still present, never competing with the words. */
  .svc-hero-copy {
    max-width: none;
  }

  .svc-hero-art {
    inset: 0 0 auto 0;
    width: auto;
    opacity: 0.42;
  }

  .svc-hero-art .fan-stack {
    left: 78%;
    width: min(46rem, 130vw);
  }
}

.about-open h1 {
  margin: 0.2rem 0 0;
  max-width: 16ch;
  font-size: var(--text-display);
  font-weight: 700;
  line-height: 1.02;
  letter-spacing: -0.03em;
  color: var(--text-hi);
}

.about-open-sub {
  margin: clamp(1.5rem, 4vh, 2.5rem) 0 0;
  max-width: var(--measure-lede);
  font-size: var(--text-lg);
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

/* Shared head for every body section below the opening. */
.story-head h2 {
  margin: 0 0 clamp(2rem, 5vh, 3rem);
  max-width: 22ch;
  font-size: var(--text-3xl);
  font-weight: 700;
  line-height: 1.08;
  letter-spacing: -0.025em;
  text-wrap: balance;
  color: var(--text-hi);
}

/* ── Story beats ─────────────────────────────────────────────────────────
 * The numeral is the reference's device: set large and dim, it indexes the
 * beat without competing with the sentence it belongs to. */
.beats {
  margin: 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--line-navy);
}

.beat {
  display: grid;
  grid-template-columns: minmax(4.5rem, 8vw) 1fr;
  gap: clamp(1rem, 3vw, 3rem);
  padding: clamp(1.6rem, 4vh, 2.6rem) 0;
  border-bottom: 1px solid var(--line-navy);
}

.beat-no {
  font-size: var(--text-2xl);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  color: rgb(var(--blue-300-rgb) / 0.35);
  font-variant-numeric: tabular-nums;
}

.beat-body h3 {
  margin: 0 0 0.6rem;
  font-size: var(--text-xl);
  font-weight: 600;
  line-height: 1.2;
  color: var(--text-hi);
}

.beat-body p {
  margin: 0;
  max-width: var(--measure-body);
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

/* The story's own pull quote — smaller than the manifesto, so the two never
 * read as competing centrepieces. */
.pull {
  margin: clamp(2.5rem, 6vh, 4rem) 0 0;
}

/* The measure belongs on the element that carries the type, not on the
 * blockquote: `ch` resolves against the element's OWN font-size, so 26ch here
 * was 262px of 16px-wide characters holding 38px text — six lines, with
 * "project" stranded alone. On `p` it resolves against the real size, and 32ch
 * is what the longer of the two sentences needs to stay on one line. */
.pull p {
  margin: 0;
  max-width: 32ch;
  font-size: var(--text-2xl);
  font-weight: 600;
  line-height: 1.16;
  letter-spacing: -0.02em;
  text-wrap: balance;
  color: var(--text-hi);
}

/* ── Manifesto — the page's emotional centre ─────────────────────────────
 * Given the most room of anything here, per the brief. */
.manifesto {
  padding: clamp(5rem, 14vh, 10rem) clamp(1.5rem, 5.5vw, 5rem);
  background: var(--navy-1);
  border-block: 1px solid var(--line-navy);
}

.manifesto blockquote {
  margin: 0 auto;
  width: fit-content;
}

/* The measure belongs on the paragraph, not on the blockquote around it: `ch`
 * resolves against the element's OWN font-size, and the blockquote is still at
 * body size — 30ch there is a 240px column that breaks display type into two
 * words a line. */
.manifesto p {
  margin: 0;
  max-width: 22ch;
  font-size: var(--text-4xl);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.025em;
  text-wrap: balance;
  color: var(--text-hi);
}

.manifesto footer {
  display: grid;
  gap: 0.2rem;
  margin-top: clamp(1.5rem, 4vh, 2.5rem);
  padding-top: 1.2rem;
  border-top: 1px solid var(--line-navy);
  font-size: var(--text-sm);
}

.manifesto strong {
  font-weight: 600;
  color: var(--text-body);
}

.manifesto span {
  color: var(--text-dim);
}

/* ── Products ───────────────────────────────────────────────────────────── */
.build-lede,
.impact-lede,
.trust-lede {
  margin: 0 0 clamp(1.8rem, 4vh, 2.6rem);
  max-width: var(--measure-lede);
  font-size: var(--text-lg);
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

.build-card {
  max-width: 62ch;
  padding: clamp(1.4rem, 3vw, 2.2rem);
  border: 1px solid rgb(var(--blue-700-rgb) / 0.34);
  border-radius: var(--r-lg, 24px);
  background: var(--surface-raised);
}

.build-card-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.6rem 1rem;
  margin-bottom: 0.9rem;
}

.build-card h3 {
  margin: 0;
  font-size: var(--text-2xl);
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--text-hi);
}

.build-badge {
  margin: 0;
  padding: 0.24rem 0.7rem;
  border: 1px solid rgb(var(--teal-500-rgb) / 0.5);
  border-radius: 999px;
  font-size: var(--text-2xs);
  letter-spacing: 0.04em;
  color: var(--teal-500);
}

.build-card p {
  margin: 0;
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

.build-link {
  display: inline-block;
  margin-top: 1.1rem;
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--text-accent);
  text-decoration: none;
}

.build-link:hover,
.build-link:focus-visible {
  text-decoration: underline;
}

.build-more {
  margin: 1.4rem 0 0;
  font-size: var(--text-sm);
  color: var(--text-hint);
}

.impact p {
  max-width: var(--measure-body);
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

.impact strong {
  font-weight: 600;
  color: var(--text-body);
}

/* ── Milestones ─────────────────────────────────────────────────────────── */
.timeline {
  margin: 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid var(--line-navy);
}

.mile {
  display: grid;
  grid-template-columns: minmax(6rem, 10vw) 1fr;
  gap: clamp(1rem, 3vw, 3rem);
  align-items: baseline;
  padding: clamp(1.2rem, 3vh, 2rem) 0;
  border-bottom: 1px solid var(--line-navy);
}

.mile-year {
  font-size: var(--text-2xl);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  color: rgb(var(--blue-300-rgb) / 0.4);
  font-variant-numeric: tabular-nums;
}

.mile p {
  margin: 0;
  max-width: var(--measure-body);
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

/* The Google Cloud appointment is the strongest line here, so it is the only
 * one given full-brightness type and a lit year. */
.mile.is-lead .mile-year {
  color: var(--text-accent);
}

.mile.is-lead p {
  font-size: var(--text-lg);
  color: var(--text-hi);
}

/* ── Trust wall ─────────────────────────────────────────────────────────── */
.trust-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
  gap: 1px;
  margin: 0;
  padding: 0;
  list-style: none;
  background: var(--line-navy);
  border: 1px solid var(--line-navy);
}

.trust-grid li {
  display: grid;
  gap: 0.3rem;
  align-content: center;
  min-height: 6.5rem;
  padding: 1.2rem;
  background: var(--surface-page, var(--navy-2));
}

.trust-name {
  font-size: var(--text-base);
  font-weight: 600;
  line-height: 1.3;
  color: var(--text-hi);
}

.trust-by {
  font-size: var(--text-xs);
  color: var(--text-hint);
}

.trust-panya {
  margin: clamp(1.6rem, 4vh, 2.4rem) 0 0;
  max-width: var(--measure-body);
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

.trust-panya strong {
  font-weight: 600;
  color: var(--text-hi);
}

/* ── Closing ────────────────────────────────────────────────────────────── */
.about-cta,
.svc-close {
  text-align: center;
}

.about-cta h2,
.svc-close h2 {
  margin: 0;
  font-size: var(--text-3xl);
  font-weight: 700;
  letter-spacing: -0.025em;
  color: var(--text-hi);
}

.about-cta p,
.svc-close p {
  margin: 1rem auto clamp(1.6rem, 4vh, 2.2rem);
  max-width: 46ch;
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

@media (max-width: 760px) {
  .beat,
  .mile {
    grid-template-columns: 1fr;
    gap: 0.5rem;
  }

  .manifesto blockquote {
    max-width: none;
  }
}

/* ── Services page ───────────────────────────────────────────────────────
 * Nearly everything here is borrowed: .story-head, .beats, .trust-grid and
 * .about-cta all come from About unchanged. What is new is the service LINE —
 * a repeated block of tagline, intro, deliverables, stack and one CTA — plus
 * the two variants the brief asks for: extra weight on the flagship, and a
 * quieter register for healthcare. */

.svc-jump {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: clamp(1.5rem, 4vh, 2.25rem);
}

/* Anchors, not buttons: they move you down the page, and the page is long
 * enough that a reader arriving for one line should not have to scroll past
 * four others to reach it. */
.svc-jump a {
  padding: 0.34rem 0.85rem;
  border: 1px solid var(--line-blue);
  border-radius: var(--r-pill, 999px);
  font-size: var(--text-sm, 0.875rem);
  text-decoration: none;
  color: var(--text-dim);
  transition: color var(--motion-micro) var(--ease-out),
    border-color var(--motion-micro) var(--ease-out);
}

.svc-jump a:hover,
.svc-jump a:focus-visible {
  color: var(--text-hi);
  border-color: var(--line-hover);
}

.svc-overview,
.svc-line,
.svc-how,
.svc-why {
  padding: clamp(3.5rem, 9vh, 7rem) clamp(1.5rem, 5.5vw, 5rem);
}

/* Technology partners and Our clients are lifted from the homepage, and they
 * arrive carrying homepage geometry that does not survive the move:
 *
 *   1. `.partners` has margin-top: calc(var(--band-h) * -0.45) so it laps the
 *      divider band above it THERE. This page has no bands, so that lap pulls
 *      the section 135px up into Retail with nothing to hide behind — the same
 *      trap as the flagship's old lap, and the reason its gap measured 32px
 *      against a 162px page rhythm.
 *   2. Both carry their own padding scale (8vh, ceiling 5.5–6rem), which lands
 *      ~9px short of every section around them here (9vh, ceiling 7rem).
 *
 * Scoped to a modifier rather than the page, so the homepage's own copies —
 * which still lap a real band — are untouched. Doubled selectors because the
 * base .partners/.clients rules sit further down the file and would otherwise
 * win on order alone. */
.partners.partners--stacked,
.clients.clients--stacked {
  margin-top: 0;
  padding-block: clamp(3.5rem, 9vh, 7rem);
}

/* No optical correction beyond that, deliberately. Measured mid-reveal the two
 * sections look 14-30px out, which is tempting to cancel with padding — but
 * that offset IS the reveal: `[data-reveal]` holds an element 26px low (40px
 * for the partners head and grid) until it settles, and getBoundingClientRect
 * reports the translated box. Sections that carry data-reveal themselves move
 * with their contents and show nothing; these two carry it on their CHILDREN,
 * so the children slide inside a still box and read as a spacing error.
 * Padding tuned against that state bakes the translate in permanently. Measure
 * with --p forced to 1. */

.svc-overview-lede {
  margin: 0 0 clamp(2rem, 5vh, 3rem);
  max-width: var(--measure-lede);
  font-size: var(--text-lg);
  line-height: var(--lh-body, 1.6);
  color: var(--text-body);
}

/* The same wall About uses for its credentials — one grid, one story.
 * About's inherits 17rem tracks, which put five badges on 4 + 1 and left a
 * dead block beside the orphan. A narrower track lets all five sit in one
 * strip; auto-fit collapses the spare tracks so they stay equal width. */
.svc-credentials {
  max-width: 76rem;
  grid-template-columns: repeat(auto-fit, minmax(min(13rem, 100%), 1fr));
}

/* ── a service line ──────────────────────────────────────────────────── */

.svc-line {
  scroll-margin-top: 5rem;
}

.svc-tagline {
  margin: 0.1rem 0 0;
  max-width: 26ch;
  font-size: var(--text-2xl);
  font-weight: 600;
  line-height: 1.16;
  letter-spacing: -0.02em;
  color: var(--text-hi);
}

.svc-intro {
  margin: clamp(1.1rem, 3vh, 1.75rem) 0 0;
  max-width: var(--measure-lede);
  font-size: var(--text-lg);
  line-height: var(--lh-body, 1.6);
  color: var(--text-body);
}

.svc-deliver-head {
  margin: clamp(1.75rem, 4vh, 2.5rem) 0 0.9rem;
  font-size: var(--text-xs);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-hint);
}

.svc-deliver {
  display: grid;
  gap: 0.55rem;
  margin: 0;
  padding: 0;
  max-width: 68ch;
  list-style: none;
}

/* The marker is drawn, not a bullet glyph: it keeps the same 0/1-adjacent
 * geometry the rest of the site uses and stays put when a line wraps. */
.svc-deliver li {
  position: relative;
  padding-left: 1.5rem;
  font-size: var(--text-base, 1rem);
  line-height: var(--lh-body, 1.6);
  color: var(--text-body);
}

.svc-deliver li::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.62em;
  width: 0.5rem;
  height: 2px;
  background: var(--text-accent);
}

/* A gap that is invisible is a gap that ships. This one is meant to be seen
 * and to look unfinished, because it is. */
.svc-deliver li.svc-todo {
  padding: 0.7rem 0.9rem 0.7rem 1.5rem;
  border: 1px dashed rgb(var(--amber-500-rgb) / 0.55);
  border-radius: var(--r-sm, 10px);
  color: var(--text-dim);
  font-style: italic;
}

.svc-deliver li.svc-todo::before {
  left: 0.6rem;
  background: var(--amber-500);
}

.svc-stack {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.45rem;
  margin: clamp(1.5rem, 4vh, 2rem) 0 0;
}

.svc-stack-label {
  margin-right: 0.35rem;
  font-size: var(--text-xs);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-hint);
}

.svc-chip {
  padding: 0.22rem 0.7rem;
  border: 1px solid var(--line-navy);
  border-radius: var(--r-pill, 999px);
  font-size: var(--text-sm, 0.875rem);
  color: var(--text-dim);
}

.svc-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 1.25rem;
  margin: clamp(1.5rem, 4vh, 2rem) 0 0;
}

.svc-cta,
.svc-cta-second {
  font-size: var(--text-base, 1rem);
  font-weight: 600;
  text-decoration: none;
  color: var(--text-accent);
}

.svc-cta-second {
  font-weight: 500;
  color: var(--text-dim);
}

.svc-cta:hover,
.svc-cta:focus-visible,
.svc-cta-second:hover,
.svc-cta-second:focus-visible {
  color: var(--text-hi);
}

/* Data Analytics runs the weave as the SECTION'S BACKGROUND, not as a block
 * beneath the copy. The section carries a floor so the drawing has room to be
 * read as a drawing rather than a stripe. */
.svc-line--weave {
  position: relative;
  min-height: min(40rem, 76vh);
}

.svc-weave {
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  z-index: -1;
  pointer-events: none;
  /* Sized off the section's HEIGHT, width following the ratio — the whole
   * composition stays visible, where stretching it to the full width would
   * have cropped the field top and bottom. */
  display: flex;
  align-items: center;
  opacity: 0.62;
  /* The story runs left to right and the copy is on the left, so that is the
   * end that gives way: the streams stay legible as something arriving, the
   * gate and the field carry full strength out in the clear. */
  mask-image: linear-gradient(
    to right,
    rgb(0 0 0 / 0.16) 0%,
    rgb(0 0 0 / 0.3) 30%,
    rgb(0 0 0 / 0.72) 52%,
    rgb(0 0 0) 72%
  );
}

.svc-weave .weave {
  width: auto;
  /* Sized off the section's height, so this one number sets both dimensions. */
  height: 54.6%;
  max-width: none;
}

/* The copy holds a measure that keeps it off the busy half, the same way the
 * cloud line does. */
.svc-line--weave .svc-tagline,
.svc-line--weave .svc-intro,
.svc-line--weave .svc-deliver {
  max-width: min(48ch, 100%);
}

@media (max-width: 61.99rem) {
  /* Stacked, there is no clear half to sit in. The drawing goes back to being
   * a band across the top of the section, well under the copy. */
  .svc-line--weave {
    min-height: 0;
  }

  .svc-weave {
    top: 0;
    right: 0;
    left: 0;
    bottom: auto;
    display: block;
    opacity: 0.26;
    mask-image: linear-gradient(to bottom, rgb(0 0 0) 42%, transparent 94%);
  }

  .svc-weave .weave {
    width: 100%;
    height: auto;
  }

  .svc-line--weave .svc-tagline,
  .svc-line--weave .svc-intro,
  .svc-line--weave .svc-deliver {
    max-width: none;
  }
}

/* The flagship carries no divider. It stacks in normal flow like every other
 * service line — the mesh in its frame is what marks it out, and the band was
 * a second marker doing the same job louder. Nothing lapped, so nothing here
 * pulls the card up: a stray negative margin would climb over Data Analytics
 * instead, with no band left to hide behind. */

/* The flagship runs the mesh as its background. It is clipped by the card's
 * own radius — the drawing belongs INSIDE the frame that marks this line out
 * as the flagship, not spilling past it. */
.svc-line--flagship {
  overflow: hidden;
}

.svc-mesh {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  opacity: 0.8;
  /* Measured, not eyeballed. The copy's ink ends at 53% of this layer, and five
   * of the ten node cards were sitting behind it — solid high-contrast shapes
   * under running text, which is what made it hard to read. So the layer stays
   * near-invisible across the whole width the copy occupies and only opens up
   * past it, where the agent and its right-hand systems live.
   *
   * The wires still cross the quiet zone, so a packet leaving a left-hand
   * system fades up as it travels in toward the agent rather than appearing
   * from nowhere. */
  mask-image: linear-gradient(
    to right,
    rgb(0 0 0 / 0.05) 0%,
    rgb(0 0 0 / 0.08) 38%,
    rgb(0 0 0 / 0.14) 53%,
    rgb(0 0 0 / 0.62) 60%,
    rgb(0 0 0) 66%
  );
}

/* The pointer light. A second copy of the dot grid, painted bright and masked
 * to a soft circle that follows the cursor — the reference pen's trick, with
 * the roles swapped: there the lattice is opaque and the light shows through
 * its gaps, here the dots are the material and only the lit ones show.
 *
 * Pitch, radius and origin come from JS (app.js reads the SVG's own screen
 * CTM), because the SVG scales with the section and a hard-coded pitch would
 * drift off the faint grid the moment the viewport changed. */
.svc-mesh::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: radial-gradient(
    circle at var(--mesh-dot-r, 1.6px) var(--mesh-dot-r, 1.6px),
    rgb(var(--blue-300-rgb)) var(--mesh-dot-r, 1.6px),
    transparent 0
  );
  background-size: var(--mesh-dot, 30px) var(--mesh-dot, 30px);
  background-position: var(--mesh-dot-x, 0) var(--mesh-dot-y, 0);
  /* Three stops, not two: a hard-edged circle reads as a hole cut in the page,
   * a long tail reads as a light. */
  mask-image: radial-gradient(
    circle var(--mesh-glow, 190px) at var(--mesh-gx, 50%) var(--mesh-gy, 50%),
    rgb(0 0 0) 0%,
    rgb(0 0 0 / 0.55) 38%,
    transparent 78%
  );
}

/* Gated with display, not opacity. An opacity fade leaves the layer live and
 * mid-transition if the tab is throttled — measured at 0 with the class on and
 * the rule present, which is a state that can also happen to a real visitor on
 * a background tab. display has no in-between. */
.svc-mesh:not(.is-lit)::after {
  display: none;
}

/* The light is the pointer's own movement, so there is no autonomous motion to
 * replace. It is switched off rather than parked: a large bright shape chasing
 * the cursor is exactly what someone asking for less motion is asking to
 * avoid, and the drawing reads fine without it. */
@media (prefers-reduced-motion: reduce) {
  .svc-mesh::after { display: none; }
}

.svc-mesh .mesh {
  width: auto;
  height: 118%;
  max-width: none;
}

.svc-line--flagship .svc-tagline,
.svc-line--flagship .svc-intro,
.svc-line--flagship .svc-proof,
.svc-line--flagship .svc-deliver {
  max-width: min(48ch, 100%);
}

@media (max-width: 61.99rem) {
  /* Stacked, there is no clear half. The canvas becomes a band behind the
   * head of the section, well under the copy. */
  .svc-mesh {
    inset: 0 0 auto 0;
    display: block;
    opacity: 0.22;
    mask-image: linear-gradient(to bottom, rgb(0 0 0) 46%, transparent 94%);
  }

  .svc-mesh .mesh {
    width: 100%;
    height: auto;
  }

  .svc-line--flagship .svc-tagline,
  .svc-line--flagship .svc-intro,
  .svc-line--flagship .svc-proof,
  .svc-line--flagship .svc-deliver {
    max-width: none;
  }
}

/* Healthcare runs the helix as its background, on the side the copy leaves
 * clear — same arrangement as the other three lines. */
.svc-line--careful {
  position: relative;
  overflow: hidden;
}

.svc-helix {
  position: absolute;
  inset: 0 0 0 auto;
  /* Pulled 100px off the right edge, per the owner. The mask travels with the
   * layer, so the side that gives way stays the side the copy is on. */
  right: 100px;
  width: min(38rem, 44%);
  z-index: -1;
  pointer-events: none;
  display: grid;
  place-items: center;
  opacity: 0.5;
  /* Fades on every side, so the strand reads as passing through the section
   * rather than as an object parked in a box. */
  mask-image: linear-gradient(to right, transparent 0%, rgb(0 0 0) 38%),
    linear-gradient(to bottom, transparent 0%, rgb(0 0 0) 16%, rgb(0 0 0) 84%, transparent 100%);
  mask-composite: intersect;
}

/* The copy takes the same measure the other lines do, which is what leaves a
 * column for the strand — before this it ran to 76ch and left 337px. */
.svc-line--careful .svc-tagline,
.svc-line--careful .svc-intro,
.svc-line--careful .svc-deliver {
  max-width: min(48ch, 100%);
}

@media (max-width: 61.99rem) {
  /* Stacked, there is no clear column. The strand lies behind the head of the
   * section at a whisper instead. */
  .svc-helix {
    inset: 0 0 auto 0;
    width: auto;
    height: 46%;
    opacity: 0.22;
    mask-image: linear-gradient(to bottom, rgb(0 0 0) 44%, transparent 94%);
    mask-composite: add;
  }

  .svc-line--careful .svc-tagline,
  .svc-line--careful .svc-intro,
  .svc-line--careful .svc-deliver {
    max-width: none;
  }
}

/* Retail runs the floor on the side its copy leaves clear, like the rest. */
.svc-line--retail {
  position: relative;
  overflow: hidden;
}

.svc-floor {
  position: absolute;
  inset: 0 0 0 auto;
  /* 30% down from the 62% it was at: 868px became 608px. */
  width: min(38rem, 43.5%);
  z-index: -1;
  pointer-events: none;
  display: grid;
  place-items: center;
  opacity: 0.72;
  /* Back to a plain edge fade. The heavy ramp here was for the 62% size, where
   * the drawing reached back under the copy; at 43.5% the layer starts 117px
   * clear of it, so suppressing the left third only hid the drawing from
   * itself. */
  mask-image: linear-gradient(to right, transparent 0%, rgb(0 0 0) 30%),
    linear-gradient(to bottom, transparent 0%, rgb(0 0 0) 14%, rgb(0 0 0) 86%, transparent 100%);
  mask-composite: intersect;
}

.svc-line--retail .svc-tagline,
.svc-line--retail .svc-intro,
.svc-line--retail .svc-deliver {
  max-width: min(48ch, 100%);
}

@media (max-width: 61.99rem) {
  .svc-floor {
    inset: 0 0 auto 0;
    width: auto;
    opacity: 0.26;
    mask-image: linear-gradient(to bottom, rgb(0 0 0) 46%, transparent 94%);
    mask-composite: add;
  }

  .svc-line--retail .svc-tagline,
  .svc-line--retail .svc-intro,
  .svc-line--retail .svc-deliver {
    max-width: none;
  }
}

/* ── floor ───────────────────────────────────────────────────────────────
 * Graphic element 05, for Retail AI & Digital Commerce. Vendors standing on
 * one marketplace floor, orders travelling in to the platform, what it learns
 * going back out — the line's own sentence is "commerce that gets smarter with
 * every order", and a loop is the only shape that says that.
 *
 * Isometric, where the other four are flat. This is the line about a place
 * where things physically happen, and depth is also what keeps it from reading
 * as a second mesh: same hub-and-spokes, but a floor rather than wiring.
 *
 * Geometry from scripts/gen-floor.mjs. No colour in the markup.
 */
.floor {
  display: block;
  width: 100%;
  height: auto;
}

.floor .fl-a { color: rgb(var(--blue-500-rgb)); }
.floor .fl-b { color: rgb(var(--teal-500-rgb)); }
.floor .fl-c { color: rgb(var(--blue-300-rgb)); }
.floor .fl-d { color: rgb(var(--amber-500-rgb)); }

/* The floor itself: outline only, so it reads as ground rather than as a slab
 * the blocks are standing on top of. */
.floor .fl-tile {
  fill: none;
  stroke: rgb(var(--blue-500-rgb) / 0.45);
  stroke-width: 1.1;
}

.floor .fl-route {
  stroke: rgb(var(--blue-300-rgb) / 0.55);
  stroke-width: 1.8;
  stroke-linejoin: round;
}

/* An order in transit. Short dash, long gap — the dash IS the order, the same
 * device the mesh uses for its packets. */
.floor .fl-order {
  stroke: currentColor;
  stroke-width: 3.6;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 7 190;
  animation: fl-run var(--fl-run, 4.2s) linear infinite;
}

.floor .fl-flow-1 { --fl-run: 3.4s; }
.floor .fl-flow-2 { --fl-run: 4.2s; }
.floor .fl-flow-3 { --fl-run: 5.4s; }

/* The solid the faces are painted on. It is the page's own ground, so the
 * block occludes the floor grid behind it while the faces keep their airy
 * values on top — without it the grid shows through the 0.3 side face and
 * appears to float over the blocks. */
.floor .fl-mass {
  fill: rgb(var(--navy-0-rgb));
}

/* Three faces, three values. Two of them have to disagree about the light, or
 * the block reads as a flat hexagon. */
.floor .fl-top {
  fill: currentColor;
  fill-opacity: 0.92;
}

.floor .fl-left {
  fill: currentColor;
  fill-opacity: 0.3;
}

.floor .fl-right {
  fill: currentColor;
  fill-opacity: 0.55;
}

/* The platform in the middle is the only thing outlined — it is what every
 * route ends at. */
.floor .fl-platform .fl-top {
  stroke: var(--blue-100);
  stroke-width: 1.4;
  stroke-opacity: 0.8;
}

.floor .fl-stall {
  animation: fl-live calc(var(--motion-loop) * 2.5) ease-in-out infinite;
}

.floor .fl-ph-1 { animation-delay: -0.5s; }
.floor .fl-ph-2 { animation-delay: -1.4s; }
.floor .fl-ph-3 { animation-delay: -2.3s; }
.floor .fl-ph-4 { animation-delay: -3.2s; }
.floor .fl-ph-5 { animation-delay: -4.1s; }
.floor .fl-ph-6 { animation-delay: -5.2s; }

/* The marks that say which trade each block is. Line work, taking the block's
 * own accent — not emoji, which are colour glyphs carrying their own palette
 * and rendering differently on every platform. */
.floor .fl-ico-line {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.floor .fl-ico-fill {
  fill: currentColor;
}

/* Floating, not resting: they bob above their block. transform-box keeps the
 * bob in the group's own space so it composes with the translate the generator
 * put there rather than replacing it. */
.floor .fl-icon {
  transform-box: view-box;
  animation: fl-bob calc(var(--motion-loop) * 2.2) ease-in-out infinite;
}

/* The engine hangs higher and breathes a little wider than the trades it runs. */
.floor .fl-icon-hub {
  animation-duration: calc(var(--motion-loop) * 3);
}

/* The engine's mark. Bigger than the trades around it, drawn in a gradient
 * rather than one flat accent, and standing in its own light — the owner asked
 * for it to read as the AI symbol of the drawing rather than as a ninth icon.
 *
 * stop-color is a CSS property, so the gradient can be classed and coloured
 * here: no colour reaches the markup, same as everything else. */
.floor .fl-grad-0 { stop-color: var(--white); }
.floor .fl-grad-1 { stop-color: var(--blue-100); }
.floor .fl-grad-2 { stop-color: rgb(var(--blue-300-rgb)); }

.floor .fl-halo-0 { stop-color: rgb(var(--blue-300-rgb) / 0.62); }
.floor .fl-halo-1 { stop-color: rgb(var(--blue-500-rgb) / 0.24); }
.floor .fl-halo-2 { stop-color: rgb(var(--blue-500-rgb) / 0); }

.floor .fl-icon-hub .fl-ico-line {
  stroke: url(#floor-ai);
  /* Scaled 2.6x, a 2.2 stroke lands at 5.7px and the chip fills in solid.
   * The stroke is in user units, so it has to come down as the mark goes up. */
  stroke-width: 1.5;
}

.floor .fl-icon-hub .fl-ico-fill {
  fill: url(#floor-ai);
}

/* The light it stands in. Its own loop, slower than the bob, so the two never
 * settle into one beat. */
.floor .fl-halo {
  transform-box: fill-box;
  transform-origin: center;
  animation: fl-glow calc(var(--motion-loop) * 2.6) ease-in-out infinite;
}

@keyframes fl-glow {
  0%, 100% { opacity: 0.55; scale: 0.88; }
  50% { opacity: 1; scale: 1.12; }
}

.offstage .floor .fl-halo {
  animation-play-state: paused;
}

@keyframes fl-bob {
  0%, 100% { translate: 0 0; }
  50% { translate: 0 -9px; }
}

.offstage .floor .fl-icon {
  animation-play-state: paused;
}

@keyframes fl-run {
  /* 7 + 190 = 197. Change either and this has to move with it. */
  to { stroke-dashoffset: -197; }
}

/* Opacity only, and never far — a stall that fades out reads as a vendor going
 * dark, which is the opposite of the claim. */
@keyframes fl-live {
  0%, 100% { opacity: 0.74; }
  50% { opacity: 1; }
}

.offstage .floor .fl-order,
.offstage .floor .fl-stall {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  /* Replace, don't freeze: the orders become evenly spaced marks sitting still
   * on their routes, and the stalls settle lit. */
  .floor .fl-order,
  .floor .fl-stall,
  .floor .fl-icon,
  .floor .fl-halo {
    animation: none;
  }

  .floor .fl-order {
    stroke-dasharray: 7 34;
    opacity: 0.75;
  }

  .floor .fl-stall {
    opacity: 0.92;
  }
}

/* ── helix ───────────────────────────────────────────────────────────────
 * Graphic element 04, for Healthcare AI. A double helix built the way the
 * reference builds it: a row of upright rungs, each spinning about its own
 * horizontal axis, each a fixed fraction of a turn ahead of the last. Nothing
 * draws a curve — the curve is what 28 phase-shifted rungs add up to.
 *
 * Deliberately the quietest of the four. This line's register is care taken,
 * not speed achieved, so the strand turns once every 14s where the
 * reference turns every 3, and the palette stays on the section's own teal
 * instead of the reference's magenta and cyan.
 */
/* The perspective lives on its own wrapper. It cannot go on .svc-helix,
 * because a mask is a grouping property — it flattens 3D, and the rungs would
 * spin in a flat plane no matter what depth they were given. */
.helix-stage {
  position: relative;
  perspective: 900px;
  perspective-origin: 50% 50%;
}

.helix {
  display: flex;
  align-items: center;
  gap: 18px;
  /* preserve-3d is what puts the RUNGS in the same space as this tilt, rather
   * than each one spinning inside its own flattened box. Without it the
   * pointer tilt reads as the whole picture turning, not as looking around a
   * solid object. */
  transform-style: preserve-3d;
  /* Built as a horizontal row, then stood up and leaned over. The pointer adds
   * its own two angles on top, so the resting pose stays the resting pose. */
  transform: rotate(120deg) scale(1.2)
    rotateX(var(--helix-x, 0deg)) rotateY(var(--helix-y, 0deg));
}

.helix span {
  position: relative;
  display: block;
  width: 2px;
  height: 130px;
  background: rgb(var(--ink-rgb) / 0.1);
  animation: helix-turn 14s linear infinite;
}

/* The two bases. They swap ends every half turn, which is what sells it as one
 * strand crossing over rather than two rows of dots. */
.helix span::before,
.helix span::after {
  content: "";
  position: absolute;
  left: 50%;
  width: 12px;
  height: 12px;
  margin-left: -6px;
  border-radius: 50%;
}

.helix span::before {
  top: -6px;
  background: rgb(var(--teal-500-rgb));
}

.helix span::after {
  bottom: -6px;
  background: rgb(var(--blue-500-rgb));
}

@keyframes helix-turn {
  to { transform: rotateX(360deg); }
}

.helix span:nth-child(1) { animation-delay: -0.00s; }
.helix span:nth-child(2) { animation-delay: -0.50s; }
.helix span:nth-child(3) { animation-delay: -1.00s; }
.helix span:nth-child(4) { animation-delay: -1.50s; }
.helix span:nth-child(5) { animation-delay: -2.00s; }
.helix span:nth-child(6) { animation-delay: -2.50s; }
.helix span:nth-child(7) { animation-delay: -3.00s; }
.helix span:nth-child(8) { animation-delay: -3.50s; }
.helix span:nth-child(9) { animation-delay: -4.00s; }
.helix span:nth-child(10) { animation-delay: -4.50s; }
.helix span:nth-child(11) { animation-delay: -5.00s; }
.helix span:nth-child(12) { animation-delay: -5.50s; }
.helix span:nth-child(13) { animation-delay: -6.00s; }
.helix span:nth-child(14) { animation-delay: -6.50s; }
.helix span:nth-child(15) { animation-delay: -7.00s; }
.helix span:nth-child(16) { animation-delay: -7.50s; }
.helix span:nth-child(17) { animation-delay: -8.00s; }
.helix span:nth-child(18) { animation-delay: -8.50s; }
.helix span:nth-child(19) { animation-delay: -9.00s; }
.helix span:nth-child(20) { animation-delay: -9.50s; }
.helix span:nth-child(21) { animation-delay: -10.00s; }
.helix span:nth-child(22) { animation-delay: -10.50s; }
.helix span:nth-child(23) { animation-delay: -11.00s; }
.helix span:nth-child(24) { animation-delay: -11.50s; }
.helix span:nth-child(25) { animation-delay: -12.00s; }
.helix span:nth-child(26) { animation-delay: -12.50s; }
.helix span:nth-child(27) { animation-delay: -13.00s; }
.helix span:nth-child(28) { animation-delay: -13.50s; }

@media (prefers-reduced-motion: reduce) {
  /* Replace, don't freeze — and here that is not a formality. Killing the
   * animation alone parks every rung at rotateX(0), which is a flat ladder,
   * not a helix. So each rung is posed at the angle its delay would have put
   * it at: the same shape, standing still. */
  .helix {
    transform: rotate(120deg) scale(1.2);
  }

  .helix span {
    animation: none;
  }

  .helix span:nth-child(1) { transform: rotateX(0.00deg); }
  .helix span:nth-child(2) { transform: rotateX(12.86deg); }
  .helix span:nth-child(3) { transform: rotateX(25.71deg); }
  .helix span:nth-child(4) { transform: rotateX(38.57deg); }
  .helix span:nth-child(5) { transform: rotateX(51.43deg); }
  .helix span:nth-child(6) { transform: rotateX(64.29deg); }
  .helix span:nth-child(7) { transform: rotateX(77.14deg); }
  .helix span:nth-child(8) { transform: rotateX(90.00deg); }
  .helix span:nth-child(9) { transform: rotateX(102.86deg); }
  .helix span:nth-child(10) { transform: rotateX(115.71deg); }
  .helix span:nth-child(11) { transform: rotateX(128.57deg); }
  .helix span:nth-child(12) { transform: rotateX(141.43deg); }
  .helix span:nth-child(13) { transform: rotateX(154.29deg); }
  .helix span:nth-child(14) { transform: rotateX(167.14deg); }
  .helix span:nth-child(15) { transform: rotateX(180.00deg); }
  .helix span:nth-child(16) { transform: rotateX(192.86deg); }
  .helix span:nth-child(17) { transform: rotateX(205.71deg); }
  .helix span:nth-child(18) { transform: rotateX(218.57deg); }
  .helix span:nth-child(19) { transform: rotateX(231.43deg); }
  .helix span:nth-child(20) { transform: rotateX(244.29deg); }
  .helix span:nth-child(21) { transform: rotateX(257.14deg); }
  .helix span:nth-child(22) { transform: rotateX(270.00deg); }
  .helix span:nth-child(23) { transform: rotateX(282.86deg); }
  .helix span:nth-child(24) { transform: rotateX(295.71deg); }
  .helix span:nth-child(25) { transform: rotateX(308.57deg); }
  .helix span:nth-child(26) { transform: rotateX(321.43deg); }
  .helix span:nth-child(27) { transform: rotateX(334.29deg); }
  .helix span:nth-child(28) { transform: rotateX(347.14deg); }
}

/* ── mesh ────────────────────────────────────────────────────────────────
 * Graphic element 03, for the flagship line. An agent wired into the systems
 * it operates, with work moving on the wires — "agents that work, not
 * slideware", drawn as the working rather than as a diagram of one.
 *
 * The travelling packet is a short bright dash on a second copy of each wire.
 * A motion path would have needed a per-element inline style, which this
 * site's CSP forbids; dashes stay in the stylesheet, which is also what lets
 * them pause off screen and answer reduced motion like everything else.
 *
 * Geometry from scripts/gen-mesh.mjs. No colour and no words in the markup.
 */
.mesh {
  display: block;
  width: 100%;
  height: auto;
}

/* Four accents, not three. This is the section people arrive for, and a run
 * that is mostly one blue reads as monochrome no matter how bright it is —
 * the lift comes from RANGE first, intensity second. */
.mesh .mh-a { color: rgb(var(--blue-500-rgb)); }
.mesh .mh-b { color: rgb(var(--teal-500-rgb)); }
.mesh .mh-c { color: rgb(var(--amber-500-rgb)); }
.mesh .mh-d { color: rgb(var(--blue-300-rgb)); }

/* The canvas the whole thing sits on. Faint enough to read as texture, which
 * is what an operations canvas looks like when it is not the subject. */
.mesh .mh-dot { fill: rgb(var(--blue-500-rgb) / 0.62); }
.mesh .mh-grid { opacity: 0.4; }

.mesh .mh-wire {
  stroke: currentColor;
  stroke-width: 1.8;
  opacity: 0.58;
}

/* Halo and core on the same route with the same timing — a lit signal without
 * a filter, which on an element whose dash offset changes every frame would
 * mean re-rendering the filter every frame too. */
/* The halo carries the wire's own colour and the core burns near-white on
 * top of it. That pairing is what makes a signal read as LIT rather than as a
 * thicker line — and it is why the packet pops without a filter. */
.mesh .mh-halo {
  stroke: currentColor;
  stroke-width: 13;
  stroke-linecap: round;
  opacity: 0.45;
}

.mesh .mh-pulse {
  stroke: var(--blue-100);
  stroke-width: 3.4;
  stroke-linecap: round;
  opacity: 1;
}

.mesh .mh-port {
  fill: currentColor;
  opacity: 0.95;
}

/* The systems. Card, then the mark inside it — the mark is what says which
 * system, so it carries the accent and the card stays neutral. */
.mesh .mh-card {
  fill: rgb(var(--navy-0-rgb) / 0.62);
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-opacity: 0.78;
}

.mesh .mh-mark {
  fill: currentColor;
  opacity: 1;
}

.mesh .mh-mark-line {
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  opacity: 1;
}

/* The agent. Bigger, lit, and the only thing with a halo — every wire on the
 * canvas ends here, which is the claim being made. */
.mesh .mh-hub-card {
  fill: rgb(var(--navy-0-rgb) / 0.8);
  stroke: var(--blue-100);
  stroke-width: 2.2;
  stroke-opacity: 0.95;
}

.mesh .mh-hub-glow {
  fill: none;
  stroke: rgb(var(--blue-300-rgb));
  stroke-width: 2.2;
  stroke-opacity: 0.72;
}

.mesh .mh-agent {
  color: rgb(var(--blue-300-rgb));
}

/* The hub's label is bars, not type: a background that says something in
 * words competes with the copy in front of it. */
.mesh .mh-label-bar {
  fill: rgb(var(--blue-300-rgb));
  opacity: 0.6;
}

/* ── motion ──────────────────────────────────────────────────────────────
 * Three speeds, eight phases — the same discipline fan and weave use. */
.mesh .mh-pulse,
.mesh .mh-halo {
  /* 6 + 300 = 306: the keyframe below has to match, or the packet jumps as
   * the loop wraps. */
  stroke-dasharray: 6 300;
  animation: mh-run var(--mh-run, 3.4s) linear infinite;
}

.mesh .mh-flow-1 { --mh-run: 2.6s; }
.mesh .mh-flow-2 { --mh-run: 3.4s; }
.mesh .mh-flow-3 { --mh-run: 4.6s; }

.mesh .mh-node,
.mesh .mh-port,
.mesh .mh-label-bar {
  animation: mh-live calc(var(--motion-loop) * 2) ease-in-out infinite;
}

.mesh .mh-hub-glow {
  transform-box: fill-box;
  transform-origin: center;
  animation: mh-halo calc(var(--motion-loop) * 2.5) ease-in-out infinite;
}

.mesh .mh-ph-1 { animation-delay: -0.3s; }
.mesh .mh-ph-2 { animation-delay: -0.9s; }
.mesh .mh-ph-3 { animation-delay: -1.5s; }
.mesh .mh-ph-4 { animation-delay: -2.2s; }
.mesh .mh-ph-5 { animation-delay: -2.8s; }
.mesh .mh-ph-6 { animation-delay: -3.5s; }
.mesh .mh-ph-7 { animation-delay: -4.3s; }
.mesh .mh-ph-8 { animation-delay: -5.1s; }

@keyframes mh-run {
  to { stroke-dashoffset: -306; }
}

/* Opacity only, and never to zero — a node that blinks out reads as a system
 * going down, which is the opposite of the claim. */
@keyframes mh-live {
  0%, 100% { opacity: 0.62; }
  50% { opacity: 1; }
}

@keyframes mh-halo {
  0%, 100% { transform: scale(1); stroke-opacity: 0.45; }
  50% { transform: scale(1.045); stroke-opacity: 0.9; }
}

.offstage .helix span,
.offstage .mesh .mh-pulse,
.offstage .mesh .mh-halo,
.offstage .mesh .mh-node,
.offstage .mesh .mh-port,
.offstage .mesh .mh-label-bar,
.offstage .mesh .mh-hub-glow {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  /* Replace, don't freeze: the packets become evenly spaced marks sitting
   * still on the wires, and everything that pulsed settles lit. The canvas
   * still reads as wired and working — it just is not moving. */
  .mesh .mh-pulse,
  .mesh .mh-halo,
  .mesh .mh-node,
  .mesh .mh-port,
  .mesh .mh-label-bar,
  .mesh .mh-hub-glow {
    animation: none;
  }

  .mesh .mh-pulse,
  .mesh .mh-halo {
    stroke-dasharray: 6 44;
  }

  .mesh .mh-halo { opacity: 0.2; }

  .mesh .mh-node,
  .mesh .mh-port,
  .mesh .mh-label-bar {
    opacity: 0.85;
  }

  .mesh .mh-hub-glow {
    stroke-opacity: 0.45;
  }
}

/* ── weave ───────────────────────────────────────────────────────────────
 * Graphic element 02. The Data Foundations card told this story small — three
 * inputs, a pipeline, one readout; this is the same sentence at illustration
 * scale, for the line whose job is turning data you have into decisions you
 * trust. Geometry comes from scripts/gen-weave.mjs, which emits class names
 * and never a colour, so every hue below is the only place it is decided.
 */
.weave {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}

/* The three accents. Amber is the site's alert hue, so the generator rations
 * it and it lands on the fewest marks here too. */
.weave .wv-a { color: rgb(var(--blue-500-rgb)); }
.weave .wv-b { color: rgb(var(--teal-500-rgb)); }
.weave .wv-c { color: rgb(var(--amber-500-rgb)); }

.weave text {
  fill: currentColor;
  font-size: 18px;
  letter-spacing: 1.5px;
}

.weave .wv-row {
  font-size: 17px;
  fill: rgb(var(--blue-500-rgb));
}

/* A single glyph placed at an exact x. Letter-spacing adds its width AFTER the
 * character, so on a one-character node it shifts the glyph off the coordinate
 * the generator put it on — visible as a drift along each stream. */
.weave .wv-bit {
  letter-spacing: 0;
}

.weave .wv-thread {
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
}

.weave .wv-gate-mark { fill: currentColor; }
.weave .wv-lit { font-size: 17px; font-weight: 600; }

/* Five steps of presence, so no two neighbouring rows read as one block. */
.weave .wv-fade-1 { opacity: 1; }
.weave .wv-fade-2 { opacity: 0.88; }
.weave .wv-fade-3 { opacity: 0.74; }
.weave .wv-fade-4 { opacity: 0.58; }
.weave .wv-fade-5 { opacity: 0.42; }

/* ── motion ──────────────────────────────────────────────────────────────
 * Three speeds and six phases, the same discipline the fan uses: variety comes
 * from WHICH token a mark gets and from when it starts, never from inventing a
 * new duration. Every loop here is infinite, so every loop is stage-gated. */
.weave .wv-thread {
  stroke-dasharray: 34 10;
  animation: wv-flow var(--wv-flow, 2.4s) linear infinite;
}

.weave .wv-flow-1 { --wv-flow: 1.8s; }
.weave .wv-flow-2 { --wv-flow: 2.6s; }
.weave .wv-flow-3 { --wv-flow: 3.6s; }

.weave .wv-row,
.weave .wv-lit,
.weave .wv-gate-mark {
  animation: wv-breathe calc(var(--motion-loop) * 2) ease-in-out infinite;
}

.weave .wv-stream {
  animation: wv-breathe calc(var(--motion-loop) * 2.5) ease-in-out infinite;
}

/* Negative delays: every mark is already mid-cycle on the first frame, so the
 * illustration never starts as one synchronised blink. */
.weave .wv-ph-1 { animation-delay: -0.4s; }
.weave .wv-ph-2 { animation-delay: -1.1s; }
.weave .wv-ph-3 { animation-delay: -1.9s; }
.weave .wv-ph-4 { animation-delay: -2.7s; }
.weave .wv-ph-5 { animation-delay: -3.6s; }
.weave .wv-ph-6 { animation-delay: -4.4s; }

@keyframes wv-flow {
  to { stroke-dashoffset: -44; }
}

/* One cycle is dash + gap, so the loop is seamless only if the offset equals
 * their sum. 34 + 10 = 44 — change either and this has to move with it. */

/* Opacity only, and never to zero — a field that blinks out reads as broken
 * rather than as live. */
@keyframes wv-breathe {
  0%, 100% { opacity: 0.58; }
  50% { opacity: 1; }
}

.offstage .weave .wv-thread,
.offstage .weave .wv-row,
.offstage .weave .wv-lit,
.offstage .weave .wv-gate-mark,
.offstage .weave .wv-stream {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  /* Replace, don't freeze. The threads keep their dashes but stop travelling,
   * and everything that breathed simply sits at its lit value — the picture is
   * complete and still, not parked mid-fade. */
  .weave .wv-thread,
  .weave .wv-row,
  .weave .wv-lit,
  .weave .wv-gate-mark,
  .weave .wv-stream {
    animation: none;
  }

  .weave .wv-row,
  .weave .wv-lit,
  .weave .wv-gate-mark,
  .weave .wv-stream {
    opacity: 0.82;
  }
}

/* ── Data Foundation's cloud ─────────────────────────────────────────────
 * A circuit-board cloud raining binary — the section's own subject, drawn.
 *
 * It is a glow on black, so it is composited with SCREEN rather than cut out:
 * black screens to exactly whatever is behind it, which means the "background
 * removal" is mathematically exact and costs no keying halo around the glow.
 * Measured on the source: the top and sides sit at 2–5/255, invisible once
 * screened; only the bottom carries content to the edge, which the mask fades.
 */
.svc-line--cloud {
  position: relative;
}

.svc-line--cloud::before {
  content: "";
  position: absolute;
  /* The box carries the PHOTO'S OWN ratio so `cover` fills it exactly. With a
   * taller box and `contain` the picture floated in the middle of it, and the
   * mask — which runs on the box — faded empty air while the picture's real
   * top edge sat in the clear, showing as a hard line. */
  top: 50%;
  right: 0;
  width: min(46rem, 52%);
  aspect-ratio: 1000 / 667;
  transform: translateY(-50%);
  background: url("/assets/cloud-circuit.webp") center / cover no-repeat;
  mix-blend-mode: screen;
  opacity: 0.72;
  /* Every edge of the frame has lit haze on it, so every edge fades. The left
   * one runs deepest — that is the side the copy is on. */
  mask-image: linear-gradient(to right, transparent 0%, rgb(0 0 0) 46%),
    linear-gradient(to left, transparent 0%, rgb(0 0 0) 12%),
    linear-gradient(to bottom, transparent 0%, rgb(0 0 0) 16%, rgb(0 0 0) 82%, transparent 100%);
  mask-composite: intersect;
  pointer-events: none;
  z-index: -1;
}

/* The copy holds its own measure so it never runs under the cloud. */
.svc-line--cloud .svc-tagline,
.svc-line--cloud .svc-intro,
.svc-line--cloud .svc-deliver {
  max-width: min(46ch, 100%);
}

@media (max-width: 61.99rem) {
  /* Stacked, the copy takes the full width and there is nowhere for the cloud
   * to sit beside it — it becomes a wash across the top instead. */
  .svc-line--cloud::before {
    /* The desktop rule anchors by the vertical centre; that has to be released
     * here or the box is pulled half its height off the top of the section.
     * The photo's ratio is KEPT, so the full cloud shows as a band — cropping
     * it to a taller box left only circuit texture, which read as noise. */
    top: 0;
    transform: none;
    inset: 0 0 auto 0;
    width: auto;
    height: auto;
    background-position: center top;
    opacity: 0.28;
    mask-image: linear-gradient(to bottom, rgb(0 0 0) 34%, transparent 94%);
    mask-composite: add;
  }

  .svc-line--cloud .svc-tagline,
  .svc-line--cloud .svc-intro,
  .svc-line--cloud .svc-deliver {
    max-width: none;
  }
}

/* ── the flagship ────────────────────────────────────────────────────────
 * The brief asks for more visual weight here than on the other four, and for
 * the "1 of 8" proof to sit above the intro — a reader who stops after one
 * line should still have met the strongest claim on the page. */
.svc-line--flagship {
  position: relative;
  margin-inline: clamp(1.5rem, 5.5vw, 5rem);
  padding-inline: clamp(1.5rem, 4vw, 3.5rem);
  border: 1px solid var(--line-blue);
  border-radius: var(--r-lg, 20px);
  background: linear-gradient(
    160deg,
    rgb(var(--blue-700-rgb) / 0.16) 0%,
    rgb(var(--blue-700-rgb) / 0.05) 45%,
    transparent 100%
  );
}

.svc-line--flagship .svc-tagline {
  font-size: var(--text-3xl);
  max-width: 20ch;
}

.svc-proof {
  margin: clamp(1.1rem, 3vh, 1.6rem) 0 0;
  max-width: 62ch;
  padding: 0.85rem 1.1rem;
  border-left: 3px solid var(--amber-500);
  border-radius: 0 var(--r-sm, 10px) var(--r-sm, 10px) 0;
  background: rgb(var(--amber-500-rgb) / 0.08);
  font-size: var(--text-base, 1rem);
  line-height: var(--lh-body, 1.6);
  color: var(--text-hi);
}

/* ── healthcare ──────────────────────────────────────────────────────────
 * A quieter register than the others, per the brief: this section is about
 * care taken, not speed achieved, so the accent steps back to teal.
 *
 * It used to carry a 2px teal rule down its left edge as well. The section is
 * full-bleed, so that rule sat hard against the window edge — a 931px green
 * line with nothing to align to, reading as a rendering artefact rather than
 * an accent. The teal on the eyebrow, the list marks and the CTA already
 * carries the register; the rule only had to be removed. */
.svc-line--careful .eyebrow span,
.svc-line--careful .svc-deliver li::before,
.svc-line--careful .svc-cta {
  color: var(--teal-500);
}

.svc-line--careful .svc-deliver li::before {
  background: var(--teal-500);
}

/* ── how we work ─────────────────────────────────────────────────────── */

.svc-how-lede {
  margin: 0 0 clamp(2rem, 5vh, 3rem);
  max-width: var(--measure-lede);
  font-size: var(--text-lg);
  line-height: var(--lh-body, 1.6);
  color: var(--text-body);
}

/* Four abreast on desktop, per the brief. The shared .beats rules keep the
 * numbering and rhythm; only the axis changes. */
@media (min-width: 62rem) {
  .svc-steps {
    display: grid;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: clamp(1.5rem, 3vw, 2.5rem);
  }

  .svc-steps .beat {
    /* On About the beats run full width, so a wide numeral column costs
     * nothing. At a quarter of that it took a third of the measure and left
     * 15 characters a line — so the numeral stacks above the text instead. */
    grid-template-columns: minmax(0, 1fr);
    gap: 0.5rem;
    border-top: 1px solid var(--line-navy);
    border-bottom: 0;
    padding: 1.25rem 0 0;
  }
}

/* ── why ─────────────────────────────────────────────────────────────── */

.svc-why-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: clamp(1rem, 2.5vw, 1.5rem);
  margin: 0;
  padding: 0;
  list-style: none;
  max-width: 72rem;
}

@media (min-width: 46rem) {
  .svc-why-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

.svc-why-card {
  padding: clamp(1.25rem, 3vw, 1.9rem);
  border: 1px solid var(--line-navy);
  border-radius: var(--r-lg, 20px);
  background: var(--surface-raised);
}

.svc-why-card h3 {
  margin: 0 0 0.6rem;
  font-size: var(--text-xl, 1.25rem);
  font-weight: 600;
  line-height: var(--lh-heading, 1.2);
  letter-spacing: -0.015em;
  color: var(--text-hi);
}

.svc-why-card p {
  margin: 0;
  font-size: var(--text-base, 1rem);
  line-height: var(--lh-body, 1.6);
  color: var(--text-body);
}

@media (max-width: 61.99rem) {
  .svc-line--flagship {
    margin-inline: 0;
    border-radius: 0;
    border-inline: 0;
  }
}

/* ── Star field — the night sky with 0s and 1s sitting on its own stars ───
 * Full-bleed, fixed 600px. The photograph is an <img> rather than a CSS
 * background because the script has to read its pixels to find the stars;
 * object-fit gives it the same crop a background would have. */
.sky-panel {
  position: relative;
  height: 600px;
  overflow: hidden;
  background: var(--navy-1);
}

/* The far plane. Oversized and pulled back by half the overshoot on each
 * side, so there is real photograph to reveal when the pointer slides it —
 * a plane sized to the panel would show the panel's own background at the
 * edge the moment it moved. */
.sky-far {
  position: absolute;
  inset: calc(var(--sky-far-shift, 12px) * -1);
  will-change: transform;
}

.sky-photo,
.sky-canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

.sky-photo {
  object-fit: cover;
  object-position: center;
}

/* The seams where the photo meets the sections above and below. The sky is
 * darkest at its edges anyway, so a short fade is enough to lose the join. */
.sky-panel::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    rgb(var(--navy-0-rgb) / 0.9) 0%,
    rgb(var(--navy-0-rgb) / 0) 20%,
    rgb(var(--navy-0-rgb) / 0) 80%,
    rgb(var(--navy-0-rgb) / 0.9) 100%
  );
}

/* Above the photo, so the digits read as part of the sky rather than as
 * something laid over a photograph of one. */
.sky-canvas {
  z-index: 2;
}

/* The near plane: the child stands in front of the sky, on the right, so the
 * arm points up and across toward the slogan. Bottom-anchored — he is
 * standing on the panel's floor, not floating in it. */
.sky-figure {
  position: absolute;
  z-index: 2;
  right: clamp(1.5rem, 8vw, 8rem);
  bottom: 0;
  width: auto;
  height: 86%;
  will-change: transform;
}

/* Mirrored variant (Services): child on the left, slogan on the right.
 *
 * He faces the other way here by loading a mirrored FILE, not by flipping the
 * element. Both CSS routes are wrong on this one:
 *   transform: scaleX(-1) — the parallax writes style.transform every frame,
 *     and an inline declaration beats the stylesheet, so the flip is wiped the
 *     first time the pointer moves;
 *   scale: -1 1 — survives that, but composes as the OUTER operation, so it
 *     mirrors the parallax translation too. Measured: a +60px shift rendered
 *     as 73px to the left. He would slide the wrong way against a sky that is
 *     not mirrored, which reads as broken depth rather than as parallax.
 * A second PNG costs 90KB and leaves the motion identical to About's. */
.sky-panel.is-mirrored .sky-figure {
  right: auto;
  left: clamp(1.5rem, 8vw, 8rem);
}

/* Both sides of the swap are set explicitly — `left: auto` is what actually
 * releases the base rule's edge, and these outrank it on specificity, so the
 * pair holds wherever they sit in the file. */
.sky-panel.is-mirrored .sky-slogan {
  left: auto;
  right: clamp(1.5rem, 5.5vw, 5rem);
  text-align: right;
}

/* An aurora carries far more light than a plain night sky, and the snowfield
 * under it more still. Dimming the photograph is what lets the slogan and the
 * digits sit on it — on the <img> alone, so the canvas above keeps its full
 * brightness and the 0/1 read as light rather than as ink. app.js samples the
 * source file, not the rendered element, so the star positions are unmoved. */
.sky-panel.is-dimmed .sky-photo {
  filter: brightness(0.52) saturate(0.9);
}

.sky-slogan {
  position: absolute;
  z-index: 4;
  left: clamp(1.5rem, 5.5vw, 5rem);
  top: 50%;
  translate: 0 -50%;
  max-width: 13ch;
  margin: 0;
  font-size: var(--text-3xl);
  font-weight: 700;
  line-height: 1.08;
  letter-spacing: -0.022em;
  text-wrap: balance;
  color: var(--text-hi);
  /* The sky behind it is a photograph, so its brightness cannot be relied on
   * the way a flat token can — the shadow is what guarantees the line stays
   * readable wherever the crop happens to put the Milky Way. */
  text-shadow: 0 2px 24px rgb(var(--navy-0-rgb) / 0.95), 0 1px 4px rgb(var(--navy-0-rgb) / 0.8);
}

@media (max-width: 760px) {
  .sky-panel {
    height: 420px;
  }

  /* The child drops back and the line takes the width — at this size they
   * cannot share a row without one of them being too small to register. */
  .sky-figure {
    height: 64%;
    right: -2%;
    opacity: 0.72;
  }

  /* Mirrored, he hangs off the other edge. Stated here too because the rule
   * above sets `right`, and the variant's `right: auto` would otherwise leave
   * him wherever the width happened to put him. */
  .sky-panel.is-mirrored .sky-figure {
    left: -2%;
  }

  .sky-slogan {
    max-width: 9ch;
    font-size: var(--text-2xl);
  }
}

/* ── Wordmark band — the same field, one line tall, above every footer ────
 * Same engine and same markup contract as .binary-panel (canvas +
 * [data-binary-line]); it just carries the marquee line alone and is short
 * enough that the field spells one word instead of a four-line slab. */
.binary-band {
  position: relative;
  height: 150px;
  overflow: hidden;
  display: grid;
  align-content: center;
  justify-items: start;
  /* Same no-JS fallback tile as the panel — replaced by the canvas on load. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20'%3E%3Cg font-family='monospace' font-size='9' fill='%23a9cbe8' fill-opacity='0.22'%3E%3Ctext x='5' y='8' text-anchor='middle'%3E1%3C/text%3E%3Ctext x='15' y='8' text-anchor='middle'%3E0%3C/text%3E%3Ctext x='5' y='18' text-anchor='middle'%3E0%3C/text%3E%3Ctext x='15' y='18' text-anchor='middle'%3E1%3C/text%3E%3C/g%3E%3C/svg%3E");
  background-size: 20px 20px;
}

.js .binary-band {
  background-image: none;
}

/* The panel's slab type is sized for a full screen; in a 150px strip the
 * DOM fallback (no-JS, and phone widths where the cell grid is too coarse
 * to spell) needs its own, much smaller measure. */
.binary-band .binary-line {
  max-width: none;
  font-size: clamp(1.25rem, 4vw, 2.1rem);
}

/* The coarse divider runs about twice as slow as the fine one — on the homepage
 * it sits under the cards where the eye lingers, so it should barely drift.
 *
 * `.band--coarse` is the canonical name the styleguide already documents
 * ("absorbs: band-first → band--coarse"); `.band-first` is the legacy alias the
 * homepage still uses, and it keeps working. New pages take the canonical name:
 * "first" describes a position on one page, not a variant of a component. */
.band--coarse .band-row-0,
.band-first .band-row-0 { animation-duration: 228s; }
.band--coarse .band-row-1,
.band-first .band-row-1 { animation-duration: 320s; }
.band--coarse .band-row-2,
.band-first .band-row-2 { animation-duration: 182s; }
.band--coarse .band-row-3,
.band-first .band-row-3 { animation-duration: 266s; }
.band--coarse .band-row-4,
.band-first .band-row-4 { animation-duration: 350s; }
.band--coarse .band-row-5,
.band-first .band-row-5 { animation-duration: 209s; }
.band--coarse .band-row-6,
.band-first .band-row-6 { animation-duration: 289s; }

/* A shorter band, for a plain seam between two text sections rather than a
 * surface other sections lap over.
 *
 * It redefines --band-h on the instance instead of setting `height` directly.
 * That is the token's own contract — "overlaps ride ratios of this, never raw
 * pixels" — so anything that ever positions itself against this band computes
 * off 120 rather than silently against the global 300. */
.band--slim {
  --band-h: 120px;
}

/* Second divider, drawn finer. The section above laps 50% over its top and
 * Technology partners climbs 45% over its bottom — a thin seam of it shows. */
.band.band-fine {
  margin-top: calc(var(--band-h) * -0.5);
  z-index: 0;
  /* Fades out downward as well as sideways: Technology partners climbs over
   * the lower third, and its eyebrow and heading have to stay readable. */
  -webkit-mask-image:
    linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%),
    linear-gradient(180deg, #000 0%, #000 30%, transparent 62%);
  mask-image:
    linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%),
    linear-gradient(180deg, #000 0%, #000 30%, transparent 62%);
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
}

.partners {
  position: relative;
  z-index: 1;
  margin-top: calc(var(--band-h) * -0.45);
}

@media (max-width: 760px) {

}

/* ── Technology partners (hairline logo grid, reference-style) ──────────── */

.partners {
  padding: clamp(3.5rem, 8vh, 6rem) clamp(1.5rem, 5.5vw, 5rem);
}

.partners-head {
  margin-bottom: clamp(2rem, 5vh, 3rem);
}

.partners-head h2 {
  margin: 0;
  /* Wide enough for the whole sentence on one desktop line; when it does
   * have to wrap, balance splits it evenly instead of orphaning "on." */
  max-width: 36ch;
  text-wrap: balance;
  font-size: var(--text-2xl);
  font-weight: 700;
  line-height: 1.14;
  letter-spacing: -0.015em;
  color: var(--text-hi);
}

/* What the grid can't say on its own: implementer *and* reseller *and*
 * advisor, plus where the depth actually is. */
.partners-lede {
  margin: 1.1rem 0 0;
  /* Three full lines on desktop; pretty keeps the last one from ending on a
   * lone "AI." */
  max-width: 76ch;
  text-wrap: pretty;
  font-size: var(--text-base);
  line-height: 1.6;
  color: var(--muted);
}

.partners-lede strong {
  font-weight: 600;
  color: var(--blue-100);
}

/* 1px gaps over a hairline backdrop draw the reference's ruled mesh, with
 * the same corner radius as the service cards so the two blocks rhyme. */
.partners-grid {
  display: grid;
  overflow: hidden;
  border-radius: 24px;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 1px;
  margin: 0;
  padding: 0;
  list-style: none;
  /* The rules read as a dark navy seam, not a grey one — just enough to
   * tell the cells apart on an almost-black field. */
  background: rgb(var(--blue-700-rgb) / 0.18);
  border: 1px solid rgb(var(--blue-700-rgb) / 0.18);
}

/* The mesh is never perfectly flat: a prime-spaced cycle of near-black
 * navies lands a slightly different tone on every cell without per-cell
 * markup (the CSP forbids inline style attributes). Overlapping cycles of
 * 3/5/7/11 mean neighbours never share a shade in any column count. */
.partner-cell {
  --cell-tone: var(--navy-0);
  --cell-hover: var(--navy-3);
  position: relative;
  display: grid;
  place-items: center;
  min-height: clamp(96px, 12vw, 128px);
  padding: 1rem;
  border: 1px solid transparent;
  background: var(--cell-tone);
  text-align: center;
  font-size: var(--text-sm);
  font-weight: 500;
  letter-spacing: 0.04em;
  color: var(--muted);
  transition: color 0.15s ease, background-color 0.15s ease;
}

.partner-cell:nth-child(3n) {
  --cell-tone: var(--navy-2);
  --logo-fade: 0.84;
}

.partner-cell:nth-child(5n + 2) {
  --cell-tone: var(--navy-1);
}

.partner-cell:nth-child(7n + 3) {
  --cell-tone: #050c1e;
  --logo-fade: 0.66;
}

.partner-cell:nth-child(11n + 5) {
  --cell-tone: #030714;
}

/* The four clouds we specialise in sit on a livelier navy so they read
 * ahead of the service marks — Google Cloud brightest (owner ranking).
 * Placed after the tone cycle so the cascade lands on these. */
.partner-cell.partner-brand {
  --cell-tone: var(--navy-4);
  --cell-hover: var(--navy-5);
}

.partner-cell.partner-lead {
  --cell-tone: var(--navy-5);
  --cell-hover: var(--navy-6);
}

/* Four cells cycle through product names instead of holding one capability
 * label — the point is that the grid can't hold everything we work with.
 * The first term is real markup, so the cell still says something with the
 * script off; app.js turns it into a typewriter. */
.partner-type {
  gap: 0.5rem;
  align-content: center;
  padding-inline: 0.6rem;
}

.type-eyebrow {
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--blue-500);
}

.type-line {
  /* Two lines are always reserved: the longest terms wrap, and the row must
   * not jump every time the typewriter reaches one. */
  min-height: 2.6em;
  font-size: var(--text-sm);
  font-weight: 500;
  letter-spacing: 0.01em;
  line-height: 1.3;
  color: var(--blue-100);
}

/* The caret only makes sense once the script is actually typing. */
.js .type-line::after {
  content: "|";
  margin-left: 0.1em;
  color: var(--blue-500);
  animation: type-caret 1.05s steps(1) infinite;
}

@keyframes type-caret {
  0%, 45% { opacity: 1; }
  50%, 100% { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .js .type-line::after {
    animation: none;
  }
}

/* Reseller mark — rides the cell's bottom edge so a visitor can tell the
 * clouds we *sell* from the ones we merely build on. It sits only on the
 * three the owner confirmed (Google Cloud, Alibaba Cloud, BytePlus); the
 * word stays plain "Reseller" until tier documents land. */
.partner-reseller {
  padding-bottom: 2rem;
}

.partner-badge {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  display: inline-flex;
  align-items: center;
  gap: 0.32rem;
  padding: 0.18rem 0.58rem;
  /* Deliberately quiet: it is a footnote to the logo, not a second mark.
   * The outline is barely there — the words carry it. */
  border: 1px solid rgb(var(--blue-500-rgb) / 0.09);
  border-bottom: none;
  border-radius: 7px 7px 0 0;
  background: rgb(var(--blue-700-rgb) / 0.1);
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--text-accent-dim);
  white-space: nowrap;
}

.partner-badge span {
  font-size: var(--text-2xs);
  color: var(--text-accent-dim);
}

/* It steps forward only when the visitor is already on the cell. */
.partner-cell:hover .partner-badge {
  border-color: rgb(var(--blue-500-rgb) / 0.26);
  background: rgb(var(--blue-700-rgb) / 0.2);
  color: rgba(219, 233, 246, 0.9);
}

.partner-cell:hover .partner-badge span {
  color: var(--blue-300);
}

/* Rollover frame — the same rotating streak the "Say Hello" CTA wears: a
 * solid padding-box face over a conic-gradient border-box, spun through
 * --cell-angle. It only runs while the cell is hovered. */
@property --cell-angle {
  syntax: "<angle>";
  initial-value: 0deg;
  inherits: false;
}

.partner-cell:hover {
  color: var(--ink);
  background:
    linear-gradient(var(--cell-hover), var(--cell-hover)) padding-box,
    conic-gradient(
      from var(--cell-angle),
      transparent 0%,
      var(--blue-500) 8%,
      var(--blue-100) 14%,
      var(--blue-500) 20%,
      transparent 32%
    ) border-box;
  animation: cell-glow 3.2s linear infinite;
}

@keyframes cell-glow {
  to { --cell-angle: 360deg; }
}

.partners-center {
  grid-column: 3 / span 2;
  grid-row: 2 / span 2;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: 0.9rem;
  padding: clamp(1.25rem, 2.5vw, 2rem);
  background: var(--navy-0);
  text-align: center;
}

.partners-center h3 {
  margin: 0;
  font-size: var(--text-xl);
  font-weight: 700;
  letter-spacing: -0.01em;
  color: var(--text-hi);
}

.partners-center p {
  margin: 0;
  max-width: 34ch;
  font-size: var(--text-base);
  line-height: 1.55;
  color: var(--muted);
}

/* Same rotating streak as the "Say Hello" CTA — the two are the page's
 * only real calls to action, so they wear the same badge. */
.partners-cta {
  display: inline-block;
  margin-top: 0.35rem;
  padding: 0.62rem 1.35rem;
  border-radius: 999px;
  border: 2px solid transparent;
  background:
    linear-gradient(var(--surface-inverse), var(--surface-inverse)) padding-box,
    conic-gradient(
      from var(--cta-angle),
      transparent 0%,
      var(--blue-500) 8%,
      var(--blue-100) 14%,
      var(--blue-500) 20%,
      transparent 32%
    ) border-box;
  box-shadow: 0 0 18px rgb(var(--blue-500-rgb) / 0.35);
  color: var(--text-on-inverse);
  font-size: var(--text-sm);
  font-weight: 600;
  text-decoration: none;
  transition: transform 0.18s ease;
  animation: cta-glow 3.2s linear infinite;
}

.partners-cta:hover,
.partners-cta:focus-visible {
  transform: scale(1.05);
}

@media (max-width: 1100px) {
  .partners-grid {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }

  .partners-center {
    grid-column: 2 / span 2;
  }
}

@media (max-width: 760px) {
  .partners-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  /* The invitation reads first on small screens; the mesh follows. */
  .partners-center {
    order: -1;
    grid-column: 1 / -1;
    grid-row: auto;
  }

  .partner-cell {
    min-height: 84px;
  }

  /* The badge eats into the cell, so the badged ones stay taller. */
  .partner-reseller {
    min-height: 108px;
  }
}

/* Logo weight follows the owner's ranking. The clouds we specialise in keep
 * their brand colour — Google Cloud leads, the other three sit a step back
 * (smaller, slightly desaturated). Every other mark is flat white ink,
 * tight-cropped like the client marquee, with a touch of shade spread so
 * the grid reads calm rather than uniform. Wide wordmarks are bounded by
 * max-width; squat ones by height. */
.partner-cell img {
  width: auto;
  object-fit: contain;
  transition: opacity 0.15s ease, filter 0.15s ease;
}

/* The flat marks carry the same shade spread as the cells — held in a
 * custom property so the hover rule still wins on specificity. */
.logo-mono {
  max-width: 72%;
  height: clamp(32px, 3.6vw, 44px);
  opacity: var(--logo-fade, 0.74);
}

.logo-brand {
  max-width: 78%;
  height: clamp(34px, 3.9vw, 46px);
  opacity: 0.88;
  filter: saturate(0.82);
}

.logo-lead {
  max-width: 94%;
  height: clamp(38px, 4.4vw, 56px);
  opacity: 1;
  filter: none;
}

/* BigQuery's hexagon and the AWS square are the squattest lockups in the
 * set, so at a shared height they out-weigh their neighbours — 10% off
 * evens the row out (owner call). */
.partner-cell img[src*="bigquery"] {
  height: clamp(29px, 3.24vw, 40px);
}

.partner-cell img[src*="aws"] {
  height: clamp(31px, 3.51vw, 41px);
}

.partner-cell:hover img {
  opacity: 1;
  filter: none;
}

@media (max-width: 760px) {
  .logo-mono {
    height: 34px;
  }

  .logo-brand {
    height: 38px;
  }

  .logo-lead {
    height: 42px;
  }

  .partner-cell img[src*="bigquery"] {
    height: 31px;
  }

  .partner-cell img[src*="aws"] {
    height: 34px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .partner-cell:hover,
  .partners-cta {
    animation: none;
  }
}

/* ── Our Move (corporate updates, reference-style column rules) ─────────── */

.moves {
  display: grid;
  grid-template-columns: minmax(0, 22rem) minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 3.5rem);
  padding: clamp(3.5rem, 8vh, 6rem) clamp(1.5rem, 5.5vw, 5rem);
}

.moves-head {
  display: grid;
  align-content: start;
  gap: 1rem;
}

.moves-head h2 {
  margin: 0;
  font-size: var(--text-display);
  font-weight: 700;
  line-height: 0.94;
  letter-spacing: -0.03em;
  color: var(--text-hi);
}

.moves-nav {
  display: flex;
  gap: 0.7rem;
  margin-top: clamp(1rem, 6vh, 3.5rem);
}

.moves-arrow {
  display: grid;
  place-items: center;
  width: 46px;
  height: 46px;
  border: 1px solid rgb(var(--blue-500-rgb) / 0.32);
  border-radius: 50%;
  background: none;
  color: var(--blue-100);
  font-size: var(--text-base);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease, transform 0.15s ease;
}

.moves-arrow:hover,
.moves-arrow:focus-visible {
  border-color: rgb(var(--blue-300-rgb) / 0.7);
  background: rgb(var(--blue-700-rgb) / 0.22);
  transform: scale(1.06);
}

/* One row of cards divided by hairline rules, exactly as in the reference. */
.moves-track {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0;
  margin: 0;
  padding: 0;
  list-style: none;
  opacity: 1;
  transition: opacity 0.22s ease;
}

.moves-track.is-fading {
  opacity: 0;
}

.move-card {
  border-left: 1px solid rgb(var(--blue-700-rgb) / 0.28);
}

/* Was a <button>, which inherited its colour; as a link it would take the
 * browser's blue and an underline across the whole card. */
a.move-open {
  color: inherit;
  text-decoration: none;
}

.move-open {
  display: grid;
  gap: 1.15rem;
  width: 100%;
  padding: 0.2rem clamp(1rem, 2.2vw, 2rem) 0.6rem;
  border: 0;
  background: none;
  text-align: left;
  cursor: pointer;
}

.move-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
}

.move-kind {
  padding: 0.3rem 0.85rem;
  border: 1px solid rgb(var(--blue-500-rgb) / 0.3);
  border-radius: 999px;
  font-size: var(--text-xs);
  color: var(--blue-100);
}

.move-meta time {
  font-size: var(--text-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-hint);
}

.move-title {
  font-size: var(--text-lg);
  font-weight: 600;
  line-height: 1.24;
  letter-spacing: -0.012em;
  color: var(--text-hi);
  transition: color 0.15s ease;
}

.move-excerpt {
  font-size: var(--text-base);
  line-height: 1.6;
  color: var(--muted);
}

.move-open:hover .move-title,
.move-open:focus-visible .move-title {
  color: var(--blue-300);
}

@media (max-width: 1100px) {
  .moves {
    grid-template-columns: minmax(0, 1fr);
  }

  .moves-track {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .moves-nav {
    margin-top: 0.5rem;
  }
}

@media (max-width: 760px) {
  .moves-track {
    grid-template-columns: minmax(0, 1fr);
  }

  .move-card {
    border-left: 0;
    border-top: 1px solid rgb(var(--blue-700-rgb) / 0.28);
    padding-top: 1.25rem;
  }

  .move-open {
    padding-inline: 0;
  }
}

/* ── Drawer — the whole piece, read in place ────────────────────────────── */

.drawer {
  position: fixed;
  inset: 0;
  z-index: 30;
}

.drawer-scrim {
  position: absolute;
  inset: 0;
  background: rgb(var(--navy-0-rgb) / 0.72);
  animation: drawer-fade 0.2s ease;
}

.drawer-panel {
  position: absolute;
  inset-block: 0;
  right: 0;
  width: min(46rem, 100%);
  overflow-y: auto;
  padding: clamp(1.5rem, 4vw, 3rem);
  background: var(--surface-overlay);
  border-left: 1px solid rgb(var(--blue-700-rgb) / 0.35);
  box-shadow: -30px 0 70px rgba(0, 0, 0, 0.55);
  animation: drawer-in 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);
}

@keyframes drawer-fade {
  from { opacity: 0; }
}

@keyframes drawer-in {
  from { transform: translateX(4%); opacity: 0; }
}

.drawer-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1.6rem;
}

.drawer-meta {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  margin: 0;
  font-size: var(--text-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-hint);
}

.drawer-close {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border: 1px solid rgb(var(--blue-500-rgb) / 0.28);
  border-radius: 50%;
  background: none;
  color: var(--blue-100);
  font-size: var(--text-sm);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease;
}

.drawer-close:hover,
.drawer-close:focus-visible {
  border-color: rgb(var(--blue-300-rgb) / 0.7);
  background: rgb(var(--blue-700-rgb) / 0.22);
}

.drawer-panel h3 {
  margin: 0 0 1.4rem;
  max-width: 26ch;
  font-size: var(--text-2xl);
  font-weight: 700;
  line-height: 1.16;
  letter-spacing: -0.02em;
  color: var(--text-hi);
}

.drawer-body p {
  margin: 0 0 1.1rem;
  max-width: 62ch;
  font-size: var(--text-base);
  line-height: 1.72;
  color: var(--muted);
}

@media (max-width: 760px) {
  /* A phone gets a sheet rather than a side panel. */
  .drawer-panel {
    inset-block: auto 0;
    width: 100%;
    max-height: 88svh;
    border-left: 0;
    border-top: 1px solid rgb(var(--blue-700-rgb) / 0.35);
    border-radius: 18px 18px 0 0;
    animation: drawer-up 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);
  }

  @keyframes drawer-up {
    from { transform: translateY(6%); opacity: 0; }
  }
}

@media (prefers-reduced-motion: reduce) {
  .moves-track,
  .drawer-scrim,
  .drawer-panel {
    animation: none;
    transition: none;
  }
}

.drawer-more {
  display: inline-block;
  margin-top: 0.4rem;
  padding: 0.6rem 1.3rem;
  border: 1px solid rgb(var(--blue-500-rgb) / 0.35);
  border-radius: 999px;
  color: var(--blue-100);
  font-size: var(--text-sm);
  font-weight: 600;
  text-decoration: none;
  transition: border-color 0.15s ease, background-color 0.15s ease;
}

.drawer-more:hover,
.drawer-more:focus-visible {
  border-color: rgb(var(--blue-300-rgb) / 0.7);
  background: rgb(var(--blue-700-rgb) / 0.22);
}

/* Cover art for an update — placeholder SVGs in the site's own language
 * until real editorial images land. */
.drawer-figure {
  margin: 0 0 1.6rem;
  overflow: hidden;
  border: 1px solid rgb(var(--blue-700-rgb) / 0.35);
  border-radius: 14px;
}

.drawer-figure img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

/* A class that sets `display` outranks the browser's own [hidden] rule, so
 * the two elements toggled by attribute say it again themselves. */
.drawer-more[hidden],
.drawer-figure[hidden] {
  display: none;
}

/* ── Section divider — the hero's rings, unrolled into parallel lines ───── */

.band {
  position: relative;
  height: var(--band-h);
  overflow: hidden;
  border-block: 1px solid rgb(var(--blue-700-rgb) / 0.24);
  /* Both edges dissolve so the band reads as a passing stream, not a slab. */
  -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%);
  mask-image: linear-gradient(90deg, transparent 0%, #000 9%, #000 91%, transparent 100%);
}

.band-art {
  display: block;
  width: 100%;
  height: 100%;
}

/* Every row carries two identical halves, so sliding one full width loops
 * seamlessly. Alternate rows run the other way at their own pace — the
 * hero's counter-rotating rings, laid out flat. */
.band-row-0 { animation: band-left 120s linear infinite; }
.band-row-1 { animation: band-right 168s linear infinite; }
.band-row-2 { animation: band-left 96s linear infinite; }
.band-row-3 { animation: band-right 140s linear infinite; }
.band-row-4 { animation: band-left 184s linear infinite; }
.band-row-5 { animation: band-right 110s linear infinite; }
.band-row-6 { animation: band-left 152s linear infinite; }

@keyframes band-left {
  to { transform: translateX(-1600px); }
}

@keyframes band-right {
  from { transform: translateX(-1600px); }
}

/* One knob drives every overlap: the 760 block flips --band-h and the
 * three ratios follow — the 761–900px window can no longer desync. */
@media (max-width: 760px) {
  :root {
    --band-h: 200px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .band-row {
    animation: none;
  }

  .top-card,
  .top-art .art {
    transition: none;
  }

  .art-flow,
  .art-stage,
  .art-beat,
  .art-glow,
  .art-grow,
  .art-draw {
    animation: none;
  }

  .top-card:hover,
  .top-card:focus-within {
    transform: none;
  }

  .top-card:hover .top-art .art,
  .top-card:focus-within .top-art .art {
    transform: none;
  }
}

/* ── Top service and products ──────────────────────────────────────────── */

.top-services {
  padding-bottom: clamp(3rem, 7vh, 5rem);
}

.top-head {
  padding: clamp(3.5rem, 8vh, 6rem) clamp(1.5rem, 5.5vw, 5rem) clamp(1.5rem, 4vh, 2.5rem);
}

.top-head h2 {
  margin: 0;
  max-width: 24ch;
  font-size: var(--text-3xl);
  font-weight: 700;
  line-height: 1.08;
  letter-spacing: -0.025em;
  text-wrap: balance;
  color: var(--text-hi);
}

/* The cards ride up over the band — 65% of its 300px. */
.top-grid {
  position: relative;
  z-index: 2;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(1rem, 2vw, 1.6rem);
  margin: calc(var(--band-h) * -0.65) 0 0;
  padding: 0 clamp(1.5rem, 5.5vw, 5rem);
  list-style: none;
}

.top-card {
  display: grid;
  align-content: start;
  gap: 0.9rem;
  padding: clamp(1rem, 1.8vw, 1.5rem);
  border: 1px solid rgb(var(--blue-700-rgb) / 0.34);
  border-radius: 24px;
  background: var(--surface-raised);
  box-shadow: 0 26px 60px rgba(0, 0, 0, 0.5);
  transition:
    transform 0.24s cubic-bezier(0.22, 0.61, 0.36, 1),
    border-color 0.24s ease,
    box-shadow 0.24s ease;
}

/* Hover (and keyboard focus inside) lifts the card, warms its edge and
 * pushes the artwork a touch closer. */
.top-card:hover,
.top-card:focus-within {
  transform: translateY(-6px);
  border-color: rgb(var(--blue-500-rgb) / 0.6);
  box-shadow:
    0 34px 74px rgba(0, 0, 0, 0.62),
    0 0 30px rgb(var(--blue-700-rgb) / 0.28);
}

.top-card:hover .top-art .art,
.top-card:focus-within .top-art .art {
  transform: scale(1.035);
}

.top-card:hover .top-eyebrow,
.top-card:focus-within .top-eyebrow {
  color: var(--blue-300);
}

.top-art {
  margin-bottom: 0.6rem;
  overflow: hidden;
  border: 1px solid rgb(var(--blue-700-rgb) / 0.26);
  border-radius: 16px;
  background: rgb(var(--ink-rgb) / 0.02);
}

.top-art .art {
  display: block;
  width: 100%;
  height: auto;
  transition: transform 0.3s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* The artwork runs a loop of its own: data moves along the connectors, each
 * stage lights up in turn, the volumes breathe. Inline SVG rather than an
 * <img> so these rules — and the reduced-motion switch — actually reach it
 * (the CSP forbids a <style> block inside the asset). */
.art-flow {
  stroke-dasharray: 9 13;
  animation: art-flow 1.5s linear infinite;
}

.art-flow.art-slow {
  animation-duration: 2.4s;
}

.art-flow.art-rev {
  animation-name: art-flow-rev;
}

.art-stage {
  stroke-opacity: 0.42;
  animation: art-stage 6s ease-in-out infinite;
}

.art-beat {
  fill-opacity: 0.55;
  animation: art-beat 6s ease-in-out infinite;
}

.art-glow {
  transform-box: fill-box;
  transform-origin: center;
  animation: art-glow 6s ease-in-out infinite;
}

.art-grow {
  transform-box: fill-box;
  transform-origin: left center;
  animation: art-grow 5s ease-in-out infinite;
}

.art-draw {
  stroke-dasharray: 210;
  animation: art-draw 5s ease-in-out infinite;
}

.art-d2 { animation-delay: 2s; }
.art-d3 { animation-delay: 4s; }
.art-d4 { animation-delay: 1.4s; }

.art-grow.art-d2 { animation-delay: 0.6s; }
.art-grow.art-d3 { animation-delay: 1.2s; }
.art-grow.art-d4 { animation-delay: 1.8s; }

@keyframes art-flow {
  to { stroke-dashoffset: -44; }
}

@keyframes art-flow-rev {
  to { stroke-dashoffset: 44; }
}

@keyframes art-stage {
  0%, 22%, 100% { stroke-opacity: 0.42; }
  8%, 14% { stroke-opacity: 0.95; }
}

@keyframes art-beat {
  0%, 22%, 100% { fill-opacity: 0.55; }
  8%, 14% { fill-opacity: 1; }
}

@keyframes art-glow {
  0%, 70%, 100% { transform: scale(1); fill-opacity: 0.78; }
  82% { transform: scale(1.07); fill-opacity: 0.98; }
}

@keyframes art-grow {
  0%, 100% { transform: scaleX(0.55); }
  50% { transform: scaleX(1); }
}

@keyframes art-draw {
  0% { stroke-dashoffset: 210; }
  45%, 100% { stroke-dashoffset: 0; }
}

.top-eyebrow {
  margin: 0;
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--blue-500);
}

.top-card h3 {
  margin: 0;
  max-width: 22ch;
  font-size: var(--text-xl);
  font-weight: 700;
  line-height: 1.18;
  letter-spacing: -0.018em;
  color: var(--text-hi);
}

.top-copy {
  margin: 0;
  max-width: 58ch;
  font-size: var(--text-sm);
  line-height: 1.64;
  color: var(--muted);
}

.top-link {
  justify-self: start;
  margin-top: 0.35rem;
  padding: 0.55rem 1.2rem;
  border: 1px solid rgb(var(--blue-500-rgb) / 0.35);
  border-radius: 999px;
  color: var(--blue-100);
  font-size: var(--text-sm);
  font-weight: 600;
  text-decoration: none;
  transition: border-color 0.15s ease, background-color 0.15s ease;
}

.top-link:hover,
.top-link:focus-visible {
  border-color: rgb(var(--blue-300-rgb) / 0.7);
  background: rgb(var(--blue-700-rgb) / 0.22);
}

@media (max-width: 900px) {
  .top-grid {
    grid-template-columns: minmax(0, 1fr);
    /* 65% of the band's phone height. */
    margin-top: calc(var(--band-h) * -0.65);
  }
}

/* Second row: three shorter cards — a line of copy and a way through. */
.top-grid.is-slim {
  margin-top: clamp(1rem, 2vw, 1.6rem);
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

.top-card.is-slim {
  position: relative;
  gap: 0.7rem;
  padding-top: clamp(1.15rem, 2vw, 1.7rem);
  overflow: hidden;
}

/* A hairline of colour on the top edge stands in for the artwork the tall
 * cards carry, without costing any height. */
.top-card.is-slim::before {
  content: "";
  position: absolute;
  inset: 0 0 auto;
  height: 3px;
  background: linear-gradient(90deg, var(--tone, var(--blue-500)), transparent 78%);
}

.tone-1 { --tone: #5e97d0; }
.tone-2 { --tone: #3fb6a8; }
.tone-3 { --tone: #e8b84b; }

.top-card.is-slim h3 {
  font-size: var(--text-lg);
}

.top-card.is-slim .top-copy {
  font-size: var(--text-sm);
  line-height: 1.62;
}

@media (max-width: 900px) {
  .top-grid.is-slim {
    grid-template-columns: minmax(0, 1fr);
    margin-top: 1rem;
  }
}

/* ── Scroll reveal ──────────────────────────────────────────────────────── */
/* app.js publishes --p on every revealed element: 0 while it is still below
 * its entry point, 1 once it has settled, scrubbed from scroll position in
 * between. Cards in a grid row share a top edge, so a single threshold fires
 * for the whole row at once and a time-based stagger disappears behind the
 * transition; instead each card's window opens a little further down the page
 * than its left-hand neighbour's, so the row cascades across as the reader
 * scrolls rather than arriving in one piece.
 *
 * Uses the standalone `translate` and `rotate` properties, never `transform`
 * — the cards need `transform` free for their hover lift, and the two
 * compose instead of fighting.
 *
 * Everything after the base rule is wrapped in `:where()` so it stays at the
 * base rule's specificity. That is load-bearing: the reduced-motion reset at
 * the bottom is a plain `.js [data-reveal]`, and a media query contributes no
 * specificity of its own, so the reset can only do its job if nothing here
 * outranks it. */

@property --p {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}

.js [data-reveal] {
  --p: 0;
  /* Opacity finishes at the halfway mark, so a card is readable well before
   * it stops moving. No transition: the damping lives in the rAF loop, and a
   * transition layered on top of a per-frame write only smears it. */
  opacity: clamp(0, calc(var(--p) * 2), 1);
  translate: 0 calc(26px * (1 - var(--p)));
}

/* Technology partners arrives off-axis and straightens as it settles. The
 * tilt is on the grid as ONE rigid plane, never on the tiles: those 1px rules
 * are the container's own background showing through `gap`, so offsetting a
 * tile drags it out of a container that cannot follow — it opens a bare wedge
 * along the top edge and `overflow: hidden` chops the bottom row. Tilting the
 * container maps tiles, gutters, background and the 24px radius clip through
 * a single projection, so the mesh has no way to come apart. */
.partners {
  perspective: 1400px;
}

.js :where(.partners-head, .partners-grid)[data-reveal] {
  translate: 0 calc(40px * (1 - var(--p)));
  transform-origin: 50% 0;
}

/* The tilt only where the mesh is wide and short. Stacked to two columns the
 * grid runs past 1100px tall, and the same 7deg then keystones it ~11% — it
 * crowds the viewport edge, and the off-axis read is lost anyway when only
 * part of the plane is ever on screen. Measured: 1282x522 and 4.8% at
 * 1440px wide, 272x1189 and 11.5% at 320px. */
@media (min-width: 901px) {
  .js :where(.partners-head, .partners-grid)[data-reveal] {
    rotate: x calc(7deg * (1 - var(--p)));
  }
}

/* The tiles fade, and only fade — they must never move relative to the mesh
 * they sit in. They keep their stagger, it just rides opacity now.
 *
 * Deliberately NOT wrapped in `:where()`, unlike the rules above. It declares
 * `translate: none`, which is exactly what the reduced-motion reset wants, so
 * out-ranking that reset costs nothing — and keeping the specificity high
 * means that in a browser that does not understand `:where()` the rules above
 * are dropped while this one survives: the grid simply does not tilt, and the
 * mesh stays rigid instead of tearing. */
.js .partners-grid [data-reveal] {
  translate: none;
}

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal] {
    opacity: 1;
    translate: none;
    rotate: none;
  }
}

/* The heads sit over the drifting bands, so they carry their own shadow. */
.top-head h2,
.partners-head h2 {
  text-shadow:
    0 2px 4px rgb(var(--navy-0-rgb) / 0.9),
    0 6px 26px rgb(var(--navy-0-rgb) / 0.85);
}

.top-kicker,
.partners-eyebrow,
.partners-lede {
  text-shadow: 0 2px 14px rgb(var(--navy-0-rgb) / 0.9);
}

/* ── Motion governance ──────────────────────────────────────────────────── */

/* Ambient loops pause while their section is off screen — app.js flags the
 * section with .offstage via one shared IntersectionObserver, matching the
 * discipline the canvas and typewriter loops already have. */
.offstage .ring,
.offstage .word-ring,
.offstage .band-row,
.offstage .clients-track,
.offstage .marquee-track,
.offstage .art-flow,
.offstage .art-stage,
.offstage .art-beat,
.offstage .art-glow,
.offstage .art-grow,
.offstage .art-draw,
.offstage .mark svg {
  animation-play-state: paused;
}

/* The two conic-glow CTAs ran outside every reduced-motion block — the one
 * infinite animation the policy missed. */
@media (prefers-reduced-motion: reduce) {
  .nav-cta,
  .partners-cta {
    animation: none;
  }
}

/* ══ Service-detail template (.sd-) ══════════════════════════════════════
 * The master pattern for the /services/<slug> SEO family, first instance
 * /services/cloud-migration. Built on the services vocabulary — .story-head,
 * .beats/.svc-steps, .svc-why-card, .partners-cta, .svc-cta — these rules add
 * only what a detail page needs and nothing the hub already owns. Every
 * colour is a token; sections carry the hub's padding clamp so the page keeps
 * the hub's 1:1 section rhythm. */

.crumbs {
  position: relative;
  z-index: 1;
  padding: 1.1rem var(--gutter) 0;
  font-size: var(--text-sm);
  color: var(--text-dim);
}

.crumbs ol {
  display: flex;
  flex-wrap: wrap;
  gap: 0.45rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.crumbs li {
  display: flex;
  align-items: center;
  gap: 0.45rem;
}

.crumbs li + li::before {
  content: "/";
  color: var(--text-hint);
}

.crumbs a {
  color: var(--text-dim);
  text-decoration: none;
}

.crumbs a:hover,
.crumbs a:focus-visible {
  color: var(--text-hi);
  text-decoration: underline;
}

.crumbs [aria-current="page"] {
  color: var(--text-hi);
}

/* Hero and blocks share the hub's section padding, so the ink-to-ink rhythm
 * measured across /services carries over unchanged. */
.sd-hero,
.sd-block {
  position: relative;
  z-index: 1;
  padding: clamp(3.5rem, 9vh, 7rem) var(--gutter);
}

.sd-hero {
  /* The breadcrumb above already opens the page; the hero needs less roof
   * than a mid-page section. */
  padding-top: clamp(2rem, 5vh, 3.5rem);
}

/* The one --text-display on the page, matching .svc-hero h1 on the hub. The
 * step is a token, not a bespoke clamp: a detail page that invents its own
 * display size is how a type scale stops being a scale. */
.sd-hero h1 {
  margin: 0.4rem 0 0;
  max-width: var(--measure-slab);
  font-size: var(--text-display);
  font-weight: 700;
  line-height: 1.02;
  letter-spacing: -0.03em;
  color: var(--text-hi);
  text-wrap: balance;
}

.sd-hero-sub {
  margin: clamp(1.1rem, 3vh, 1.6rem) 0 0;
  max-width: var(--measure-lede);
  font-size: var(--text-lg);
  line-height: var(--lh-relaxed);
  color: var(--text-body);
}

.sd-cta-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: clamp(0.8rem, 2vw, 1.25rem);
  margin: clamp(1.5rem, 4vh, 2rem) 0 0;
}

/* The soft CTA: same pill as .partners-cta, drawn in outline so the pair
 * reads hard + soft, not twins. */
.sd-cta-ghost {
  display: inline-block;
  padding: 0.7rem 1.4rem;
  /* Not --line-blue: a hairline tint reads as decoration, and this boundary
   * IS the control. blue-300 at 0.55 clears 3:1 against the page. */
  border: 1px solid rgb(var(--blue-300-rgb) / 0.55);
  border-radius: var(--r-pill);
  font-size: var(--text-base);
  font-weight: 600;
  text-decoration: none;
  color: var(--text-hi);
}

.sd-cta-ghost:hover,
.sd-cta-ghost:focus-visible {
  border-color: var(--line-hover);
  background: var(--fill-hover);
}

/* The closing section's CTA row is a <p>, which puts it under
 * `.about-cta p, .svc-close p { max-width: 46ch }` — the measure for PROSE.
 * A 544px pair of buttons inside a 464px cap wrapped onto two lines. A row of
 * controls is not prose: it takes the width it needs, and it has to centre
 * itself because flex does not inherit the section's text-align. Two classes,
 * because `.svc-close p` (class + type) outranks a lone `.sd-cta-row`. */
.svc-close .sd-cta-row {
  max-width: none;
  justify-content: center;
}

/* The closing pair is the page's last ask, so it carries 20% more presence
 * than the same pills do inline. Type AND padding scale together — growing
 * only the box makes a fat pill around small text, not a bigger button.
 * Each scales from its OWN base, so this is a true +20% on each rather than
 * a re-levelling of the two. (They sit on different type steps to begin
 * with — .partners-cta on --text-sm, the ghost on --text-base — which is
 * worth settling separately; scaling does not hide it.) */
.svc-close .partners-cta {
  /* margin-top: 0.35rem comes from the pill's inline use, where it follows a
   * line of text. In a flex row align-items centres the MARGIN box, so that
   * 5.6px pushed the primary 2.8px below the ghost's centre line. */
  margin-top: 0;
  padding: calc(0.62rem * 1.2) calc(1.35rem * 1.2);
  font-size: calc(var(--text-sm) * 1.2);
}

.svc-close .sd-cta-ghost {
  padding: calc(0.7rem * 1.2) calc(1.4rem * 1.2);
  font-size: calc(var(--text-base) * 1.2);
}

/* Card grid for pain points and capabilities. One column on phones, two
 * across from tablet; an odd last card spans the row rather than sitting
 * next to a hole. */
.sd-cards {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: clamp(1rem, 2.5vw, 1.5rem);
  margin: 0;
  padding: 0;
  list-style: none;
}

.sd-cards h3 {
  margin: 0 0 0.45rem;
  font-size: var(--text-lg);
  color: var(--text-hi);
}

.sd-cards p {
  margin: 0;
  color: var(--text-body);
  line-height: var(--lh-body);
}

@media (min-width: 48rem) {
  .sd-cards {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .sd-cards > :last-child:nth-child(odd) {
    grid-column: 1 / -1;
  }
}

/* Three across on desktop, for a set that is genuinely three — the routing
 * hub's "how we work" moves and its recognition strip. The 2-up default would
 * leave the third card spanning a full row and reading as more important than
 * its siblings. */
@media (min-width: 62rem) {
  .sd-cards--three {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  .sd-cards--three > :last-child:nth-child(odd) {
    grid-column: auto;
  }
}

/* The hero's claim line: smaller than the lede, but never the smallest thing
 * on the page — the Design System's claim-sourcing rule puts a floor of
 * 0.68rem under any statement of commercial fact. */
.sd-proof-strip {
  margin: clamp(1.5rem, 4vh, 2rem) 0 0;
  max-width: var(--measure-lede);
  padding: 0.9rem 1.2rem;
  border-radius: var(--r-md, 16px);
  font-size: var(--text-sm);
  line-height: var(--lh-body);
  color: var(--text-body);
}

.sd-proof-strip .sd-proof-note {
  display: block;
}

/* Microcopy under a pre-launch CTA: quieter than body copy, but still above
 * the 10px floor — it sets an expectation, so it has to be readable. */
.sd-studio-note {
  margin: 0.9rem 0 0;
  font-size: var(--text-sm);
  color: var(--text-dim);
}

/* A service whose page is not built yet says so, rather than shipping a link
 * the route table would 404. */
.sd-soon {
  display: inline-block;
  margin-top: 0.5rem;
  font-size: var(--text-xs);
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--text-hint);
}

.sd-card-flag {
  margin: 0 0 0.4rem;
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--text-accent);
}

/* Same dashed-amber grammar as .svc-todo and .sd-proof-card: on this site a
 * dashed amber edge means "not verified, not for publication". */
/* Comparison tables (6R, packages). The wrapper scrolls so the page body
 * never does; the table itself keeps the card idiom — raised surface, navy
 * hairlines. */
.sd-table-wrap {
  overflow-x: auto;
  border: 1px solid var(--line-navy);
  border-radius: var(--r-lg, 20px);
  background: var(--surface-raised);
}

.sd-table {
  width: 100%;
  min-width: 44rem;
  border-collapse: collapse;
  font-size: var(--text-base);
  line-height: var(--lh-body);
}

.sd-table th,
.sd-table td {
  padding: 0.85rem 1.1rem;
  text-align: left;
  vertical-align: top;
  border-top: 1px solid var(--line-navy-faint);
}

.sd-table thead th {
  border-top: 0;
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--text-hint);
}

.sd-table tbody th {
  font-weight: 600;
  color: var(--text-hi);
  white-space: nowrap;
}

.sd-table td {
  color: var(--text-body);
}

.sd-soft {
  font-weight: 400;
  color: var(--text-dim);
}

/* Stage chip in the packages table — the svc-chip idiom, uppercased. */
.sd-stage {
  display: inline-block;
  padding: 0.15rem 0.6rem;
  border: 1px solid var(--line-blue);
  border-radius: var(--r-pill);
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--text-accent);
}

/* Methodology extras riding inside the shared beats. */
.sd-weeks {
  margin: 0.1rem 0 0.45rem;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--text-accent);
}

.sd-beat-deliver {
  margin: 0.6rem 0 0;
  padding: 0.6rem 0 0 1.1rem;
  border-top: 1px solid var(--line-navy-faint);
  list-style: none;
  color: var(--text-dim);
  font-size: var(--text-sm);
  line-height: var(--lh-body);
}

.sd-beat-deliver li {
  position: relative;
  margin-top: 0.25rem;
}

.sd-beat-deliver li::before {
  content: "";
  position: absolute;
  left: -1.1rem;
  top: 0.55em;
  width: 0.5rem;
  height: 2px;
  border-radius: 1px;
  background: var(--teal-500);
}

/* A one-line aside that earns its keep — accent rule on the left, raised
 * surface, never a heading. */
.sd-callout {
  margin: clamp(1.5rem, 4vh, 2rem) 0 0;
  max-width: var(--measure-lede);
  padding: 0.9rem 1.2rem;
  border-left: 3px solid var(--teal-500);
  border-radius: 0 var(--r-md, 16px) var(--r-md, 16px) 0;
  background: var(--surface-raised);
  color: var(--text-body);
  line-height: var(--lh-body);
}

/* Held copy: dashed amber says "not cleared for publication". This is a
 * MODIFIER, so it has to sit after every base it modifies.
 *
 * It used to sit ~100 lines above .sd-callout, at the same specificity, which
 * meant `class="sd-callout sd-unverified"` lost the cascade and rendered as an
 * ordinary solid teal callout. Nine pages combine those two classes, so on
 * every one of them the draft signal was invisible while the markup said it
 * was there — the worst version of this bug, because it looks handled. */
.sd-unverified {
  border-style: dashed;
  border-color: rgb(var(--amber-500-rgb) / 0.55);
}

/* Proof cards. Dashed amber = the site's placeholder grammar (.svc-todo):
 * these carry example figures until real, client-approved case studies land,
 * and the draft-gate test keeps the page noindex while they do. */
.sd-proof-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: clamp(1rem, 2.5vw, 1.5rem);
  margin: 0;
  padding: 0;
  list-style: none;
}

@media (min-width: 48rem) {
  .sd-proof-grid {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

.sd-proof-card {
  padding: clamp(1.25rem, 3vw, 1.9rem);
  border: 1px dashed rgb(var(--amber-500-rgb) / 0.55);
  border-radius: var(--r-lg, 20px);
  background: var(--surface-raised);
}

.sd-proof-card > p {
  margin: 0;
  color: var(--text-body);
}

.sd-proof-logo {
  font-size: var(--text-xs);
  letter-spacing: var(--track-caps);
  color: var(--text-hint);
}

/* The "weigh" role. A lead figure is the one thing on a corporate page that
 * has earned emphasis — it is the claim, compressed — and at --text-3xl/700 it
 * carries the accent without the accent carrying it. Body copy around it stays
 * white, so the number reads as the exception rather than as decoration. */
.sd-proof-figure {
  margin: 0.5rem 0 0.25rem;
  font-size: var(--text-3xl);
  font-weight: 700;
  color: var(--text-accent);
}

.sd-proof-note {
  margin-top: 0.7rem;
  font-size: var(--text-xs);
  font-style: italic;
  color: rgb(var(--amber-500-rgb) / 0.85);
}

/* FAQ — native details/summary, styled as hairline rows. The marker is the
 * site's » chevron, rotating on open; no JS anywhere. */
.sd-faq {
  border-top: 1px solid var(--line-navy);
}

.sd-faq-item {
  border-bottom: 1px solid var(--line-navy);
}

.sd-faq-item summary {
  /* display:block, not flex — WebKit drops the expand/collapse announcement
   * on flex summaries; the chevron is absolutely placed instead. */
  display: block;
  position: relative;
  padding: 1rem 0.2rem 1rem 1.55rem;
  font-weight: 600;
  color: var(--text-hi);
  cursor: pointer;
  list-style: none;
}

.sd-faq-item summary::-webkit-details-marker {
  display: none;
}

.sd-faq-item summary::before {
  /* The slash syntax gives the glyph empty alternative text, so screen
   * readers do not announce a chevron before every question. */
  content: "\00BB" / "";
  position: absolute;
  left: 0.2rem;
  top: 1rem;
  color: var(--text-accent);
  transition: rotate var(--motion-micro) var(--ease-out);
}

.sd-faq-item[open] summary::before {
  rotate: 90deg;
}

.sd-faq-item summary:hover,
.sd-faq-item summary:focus-visible {
  color: var(--text-accent);
}

.sd-faq-item > p {
  margin: 0 0 1.1rem;
  padding: 0 0.2rem 0 1.55rem;
  max-width: var(--measure-body);
  color: var(--text-body);
  line-height: var(--lh-relaxed);
}

@media (prefers-reduced-motion: reduce) {
  .sd-faq-item summary::before {
    transition: none;
  }
}

/* The cross-link wedge: one bordered card, one question, one way through. */
.sd-wedge {
  max-width: 46rem;
  padding: clamp(1.5rem, 4vw, 2.4rem);
  border: 1px solid var(--line-blue);
  border-radius: var(--r-lg, 20px);
  background: var(--surface-raised);
}

.sd-wedge h2 {
  margin: 0;
  font-size: var(--text-2xl);
  color: var(--text-hi);
}

.sd-wedge p {
  margin: 0.8rem 0 1.2rem;
  color: var(--text-body);
  line-height: var(--lh-relaxed);
}

/* ══ Nav mega-menu ═══════════════════════════════════════════════════════
 * The Service item opens a panel of labelled columns. The trigger is a
 * BUTTON, not a link — a link that also opens a panel gives a keyboard user
 * two conflicting jobs on one control — so the panel carries "All services"
 * as its own link and /services stays reachable from the nav. */

.nav-item-menu {
  position: relative;
}

.nav-menu-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  cursor: pointer;
}

/* The chevron turns when the panel is open — the only affordance that says
 * this item does something the others do not. */
.nav-chev {
  width: 0.44em;
  height: 0.44em;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  rotate: 45deg;
  translate: 0 -0.12em;
  transition: rotate var(--motion-micro) var(--ease-out);
}

.nav-menu-toggle[aria-expanded="true"] .nav-chev {
  rotate: 225deg;
  translate: 0 0.1em;
}

.nav-menu-toggle[aria-expanded="true"],
.nav-menu-toggle:hover {
  color: var(--text-hi);
}

/* The panel. Anchored to the bar rather than to the item, so three columns
 * are free to be wider than the word "Service". */
.nav-menu {
  /* The visual gap between the trigger and the card. Declared once, because
   * the invisible bridge below has to be exactly this tall — if the two ever
   * disagree, the menu closes while the pointer is travelling to it. */
  --nav-menu-gap: 0.9rem;
  position: absolute;
  top: calc(100% + var(--nav-menu-gap));
  left: 50%;
  translate: -50% 0;
  z-index: 60;
  display: grid;
  grid-template-columns: repeat(3, minmax(13.5rem, 1fr));
  gap: clamp(1.2rem, 2.5vw, 2.4rem);
  width: max-content;
  max-width: min(56rem, calc(100vw - 2rem));
  padding: clamp(1.2rem, 2.2vw, 1.8rem);
  border: 1px solid var(--line-navy);
  border-radius: var(--r-lg, 20px);
  background: var(--surface-overlay);
  box-shadow: 0 24px 60px rgb(0 0 0 / 0.45);
}

.nav-menu[hidden] {
  display: none;
}

/* An invisible strip filling that gap. It is a CHILD of the panel, so the
 * pointer never leaves the menu item while crossing it — which is what makes
 * the panel reachable at all. It spans the panel's full width, so a diagonal
 * move toward a far column stays covered too. */
.nav-menu::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  height: var(--nav-menu-gap);
}

.nav-menu-head {
  margin: 0 0 0.15rem;
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: var(--track-caps-wide);
  text-transform: uppercase;
  color: var(--text-hint);
}

.nav-menu-note {
  margin: 0 0 0.7rem;
  font-size: var(--text-xs);
  color: var(--text-dim);
}

.nav-menu-col ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

.nav-menu-link {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.42rem 0.5rem;
  border-radius: var(--r-sm, 10px);
  font-size: var(--text-sm);
  text-decoration: none;
  color: var(--text-body);
}

.nav-menu-link:hover,
.nav-menu-link:focus-visible {
  background: var(--fill-hover);
  color: var(--text-hi);
}

.nav-menu-icon {
  flex: none;
  width: 1.15rem;
  height: 1.15rem;
  color: var(--text-accent);
}

/* Children of the row above — indented to the icon's text edge, so the
 * hierarchy reads without a second icon column. */
.nav-menu-sub {
  display: block;
  padding: 0.3rem 0.5rem 0.3rem 2.25rem;
  border-radius: var(--r-sm, 10px);
  font-size: var(--text-sm);
  text-decoration: none;
  color: var(--text-dim);
}

.nav-menu-sub:hover,
.nav-menu-sub:focus-visible {
  background: var(--fill-hover);
  color: var(--text-hi);
}

.nav-menu-badge {
  margin-left: auto;
  padding: 0.05rem 0.4rem;
  border-radius: var(--r-pill);
  background: rgb(var(--amber-500-rgb) / 0.16);
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: var(--track-caps);
  text-transform: uppercase;
  color: var(--amber-500);
}

.nav-menu-all {
  grid-column: 1 / -1;
  margin: 0.3rem 0 0;
  padding-top: 0.9rem;
  border-top: 1px solid var(--line-navy-faint);
}

.nav-menu-all a {
  font-size: var(--text-sm);
  font-weight: 600;
  text-decoration: none;
  color: var(--text-accent);
}

.nav-menu-all a:hover,
.nav-menu-all a:focus-visible {
  color: var(--text-hi);
}

/* Below the desktop bar the nav is already a stacked disclosure, so the
 * panel stops floating and becomes part of that stack. Three columns in a
 * phone-width sheet would be three columns of one word each. */
/* ── Mobile: the sheet ───────────────────────────────────────────────────
 * Below the bar's breakpoint the nav becomes a right-hand sheet and the menu
 * is ALREADY OPEN inside it — everything on one scroll, nothing to tap twice.
 * The trigger button is removed rather than hidden: on a sheet that is always
 * expanded, a control saying "expanded: false" would be a lie to a screen
 * reader, and a second thing to tab through for no gain. The column headings
 * do the labelling instead.
 *
 * Same breakpoint as the bar itself. They were 900 and 760 for a while, which
 * left a band where the bar was horizontal but the panel had already gone
 * static — the panel simply fell out of the bar. Three places carry this
 * number: the bar's block, this one, and `sheet` in app.js. They move
 * together or that band comes back. */
@media (max-width: 1119px) {
  .nav-menu-toggle {
    display: none;
  }

  /* The panel is part of the sheet's flow, and `hidden` does not apply here:
   * the sheet's own disclosure is what opens and closes it. */
  .nav-menu,
  .nav-menu[hidden] {
    position: static;
    translate: none;
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: 1.4rem;
    width: auto;
    max-width: none;
    margin: 0;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
    box-shadow: none;
  }

  /* Hairline rules between rows, as the reference has them — the sheet is a
   * long list and the rules are what stop it reading as one block. */
  /* No gap to bridge when the panel is part of the sheet's flow. */
  .nav-menu::after {
    content: none;
  }

  .nav-menu-col li + li,
  .nav-menu-col ul {
    border-top: 1px solid var(--line-navy-faint);
  }

  .nav-menu-link,
  .nav-menu-sub {
    padding-block: 0.75rem;
    border-radius: 0;
    font-size: var(--text-base);
  }

  .nav-menu-note {
    margin-bottom: 0.4rem;
  }

  .nav-menu-all {
    padding-top: 1rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .nav-chev {
    transition: none;
  }
}

/* While the nav sheet is open the page behind it must not scroll — the sheet
 * has its own scroll, and two scrollers at once loses the reader's place. */
body.nav-sheet-open {
  overflow: hidden;
}

/* ══ hero art ═══════════════════════════════════════════════════════════════
 * One animated background per service-detail page. Same contract as fan,
 * weave and mesh: the generator emits geometry and CLASS NAMES only, and every
 * hue resolves here — so a token change moves all fourteen drawings at once.
 *
 * The vocabulary is CLOSED. Three accents, five fades, three speeds, six
 * phases, five motion verbs. Variety comes from which token a mark receives
 * and from when it starts, never from a new value; a drawing that needs a
 * fourth speed is a drawing that has not been designed yet. */

.sd-hero {
  position: relative;
}

/* The copy rides above the drawing without needing a z-index of its own —
 * a positioned sibling beats a static one at the same stacking level. The
 * ShopSCAPE marquee is excluded for the same reason .sd-hero-art is: it is
 * ground, and ground takes its position from its own rule. */
.sd-hero > :not(.sd-hero-art, .ss-mq, .st-hero-art) {
  position: relative;
}

.sd-hero-art {
  /* How far the drawing runs past the hero's bottom edge, so the artwork ends
   * on its own curve rather than on a straight cut. Same idea as the hub's
   * --bleed, a third of the size because this hero is a third of the height. */
  --bleed: 7rem;
  position: absolute;
  inset: 0 0 calc(var(--bleed) * -1) 0;
  z-index: 0;
  pointer-events: none;
  /* Dark at both ends: the drawing must not collide with the breadcrumb above
   * it or hit a hard horizontal edge where the next section begins. The #000
   * stops are alpha, not ink — a mask reads only the channel — which is the
   * same declared exemption the marquee and band masks already run under. */
  -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 62%, transparent 100%);
  mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 62%, transparent 100%);
}

.hero-art {
  /* Three speeds. Nothing else. */
  --g-s1: 1.8s;
  --g-s2: 3.4s;
  --g-s3: 6.2s;

  display: block;
  width: 100%;
  height: 100%;
}

/* Amber is the site's alert hue, so the generators ration it: it lands on
 * whatever a drawing is singling out, never on its bulk. */
.hero-art .g-a { color: rgb(var(--blue-500-rgb)); }
.hero-art .g-b { color: rgb(var(--teal-500-rgb)); }
.hero-art .g-c { color: rgb(var(--amber-500-rgb)); }

.hero-art .g-line {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
}

.hero-art .g-fill,
.hero-art .g-node {
  fill: currentColor;
  stroke: none;
}

.hero-art .g-ring {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.3;
  stroke-linecap: round;
}

.hero-art .g-bit {
  fill: currentColor;
  font-size: 18px;
  letter-spacing: 0;
}

/* Five steps of presence, so no two neighbouring marks read as one block. */
.hero-art .g-f1 { opacity: 1; }
.hero-art .g-f2 { opacity: 0.88; }
.hero-art .g-f3 { opacity: 0.74; }
.hero-art .g-f4 { opacity: 0.58; }
.hero-art .g-f5 { opacity: 0.42; }

.hero-art .g-s1 { --g-dur: var(--g-s1); }
.hero-art .g-s2 { --g-dur: var(--g-s2); }
.hero-art .g-s3 { --g-dur: var(--g-s3); }

/* ── motion verbs ─────────────────────────────────────────────────────── */
.hero-art .g-flow {
  stroke-dasharray: 34 10;
  animation: g-flow var(--g-dur, 3.4s) linear infinite;
}
@keyframes g-flow { to { stroke-dashoffset: -44; } }

.hero-art .g-pulse { animation: g-pulse var(--g-dur, 3.4s) ease-in-out infinite; }
@keyframes g-pulse { 0%, 100% { opacity: 0.35; } 50% { opacity: 1; } }

.hero-art .g-drift { animation: g-drift var(--g-dur, 3.4s) ease-in-out infinite; }
@keyframes g-drift { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(14px); } }

.hero-art .g-rise { animation: g-rise var(--g-dur, 3.4s) ease-in-out infinite; }
@keyframes g-rise { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-11px); } }

.hero-art .g-spin {
  transform-box: fill-box;
  transform-origin: center;
  animation: g-spin var(--g-dur, 3.4s) linear infinite;
}
@keyframes g-spin { to { transform: rotate(360deg); } }

/* ── phases ───────────────────────────────────────────────────────────────
 * These MUST stay below the motion verbs. Each verb sets `animation`, and the
 * shorthand resets animation-delay to 0 — a phase declared above them is
 * silently overwritten and every mark starts on the same frame, which is the
 * one thing this system exists to prevent. `weave` orders it the same way.
 *
 * The delays are negative, so a drawing is already mid-motion on its first
 * frame instead of visibly winding up as the page settles. */
.hero-art .g-p1 { animation-delay: -0.4s; }
.hero-art .g-p2 { animation-delay: -0.9s; }
.hero-art .g-p3 { animation-delay: -1.5s; }
.hero-art .g-p4 { animation-delay: -2.2s; }
.hero-art .g-p5 { animation-delay: -3.1s; }
.hero-art .g-p6 { animation-delay: -4.3s; }

/* ── the two off switches ─────────────────────────────────────────────────
 * A hero scrolled out of view must stop animating, and a reader who asked for
 * stillness gets stillness. Both are site rules, not options. */
.offstage .hero-art * {
  animation-play-state: paused;
}

@media (prefers-reduced-motion: reduce) {
  .hero-art * {
    animation: none;
    transform: none;
  }
}

/* Narrow screens: the drawings are composed at 1200×760 with their structure
 * on the right, and at phone width that half is off-canvas anyway. The rings
 * take the same exit at 1100px. */
@media (max-width: 760px) {
  .sd-hero-art {
    display: none;
  }
}

/* The healthcare hero borrows the hub's DNA rather than drawing a second one:
 * .helix-stage / .helix / .helix span are global rules, so the strand here is
 * the same component, same turn, same bases as /services line 04. Only its
 * placement is local — it sits where the binary field used to be, inside
 * .sd-hero-art so it inherits the mask, the stage gate and the mobile exit. */
.hero-helix {
  position: absolute;
  right: 23%;
  bottom: 44%;
  translate: 50% 50%;
  pointer-events: none;
}

/* Some drawings have no empty quadrant to retreat into. A network that runs
 * edge to edge — system-integration is the case — says its sentence BY spanning
 * the full width, so moving or shrinking it cannot open a clear corner for the
 * headline; wires cross every band at every height.
 *
 * For those, the copy column is cleared with a second mask layer instead. The
 * artwork keeps its whole composition and simply stops existing where the text
 * is. This is the wheel's own trick on the hub (.svc-hero-art), restated here.
 * Both stops are pure alpha, never a tint: partial-alpha hex is exactly what
 * the .sd- token guard forbids, and it would be a palette fork wearing a mask. */
.sd-hero-art--clear-copy {
  -webkit-mask-image:
    linear-gradient(180deg, transparent 0%, #000 18%, #000 62%, transparent 100%),
    linear-gradient(90deg, transparent 0%, transparent 52%, #000 80%);
  mask-image:
    linear-gradient(180deg, transparent 0%, #000 18%, #000 62%, transparent 100%),
    linear-gradient(90deg, transparent 0%, transparent 52%, #000 80%);
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
}

/* The hero lede stops before the artwork starts. At the full --measure-lede a
 * 76ch paragraph runs to ~991px on a 1280 viewport, which is 380px INTO the
 * drawing — the copy ends up sitting on the densest part of it and both lose.
 * 52% of the hero's own width lands the last character just clear of where the
 * bottom-right artwork begins, and being a percentage it tracks the drawing
 * instead of drifting away from it at other widths.
 *
 * Scoped to `.sd-hero` on purpose: that is the structure the service-detail
 * pages share and the Services hub does not have, so the hub keeps its own
 * full-width lede without needing to be named as an exception. Below 761px the
 * artwork is hidden entirely, so the lede takes its full measure back. */
@media (min-width: 761px) {
  .sd-hero .sd-hero-sub {
    max-width: min(var(--measure-lede), 52%);
  }
}

/* The platform page's jump nav follows the reader. It is the only page long
 * enough to need it — nineteen sections, and the anchors are how a buyer gets
 * to the one that answers their question. Scoped to `.sd-hero` so the Services
 * hub's own jump list, which sits in a short hero and is read once, stays put. */
@media (min-width: 761px) {
  .sd-hero .svc-jump {
    position: sticky;
    top: 0;
    z-index: var(--z-nav);
    padding-block: 0.5rem;
    backdrop-filter: blur(6px);
  }
}

/* ── vendor brand artwork on the platform pages ───────────────────────────
 * Both platform pages — Google Cloud and Alibaba Cloud — carry a vendor lockup
 * in the hero and a vendor-coloured icon beside every product row. These keep
 * their own colours on purpose: the token palette governs OUR marks, and a
 * vendor icon repainted in our blues would be both wrong and, for a partner,
 * not ours to do. So they are sized here and left alone otherwise. Provenance
 * is in docs/image-credits.md.
 *
 * The `gc-` prefix is historical — the Google page needed these first. They are
 * PLATFORM-PAGE components, not Google ones, and the Alibaba page uses the same
 * four rather than growing a parallel `ac-` set that would style-for-style
 * duplicate them. */
.gc-lockup {
  display: block;
  width: auto;
  height: clamp(1.75rem, 3.4vw, 2.5rem);
  margin: 0 0 clamp(1rem, 2.5vh, 1.6rem);
}


.gc-row,
.gc-head {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

/* Square, and never allowed to squash: a table cell that runs out of width
 * shrinks the label, not the mark. */
.gc-icon {
  flex: 0 0 auto;
  width: 1.5rem;
  height: 1.5rem;
}

.gc-head .gc-icon {
  width: 1.85rem;
  height: 1.85rem;
}

/* The platform hero's digits are a surface, not a subject. Their own fade
 * classes already put them at 0.42–0.58; the group multiplies that down again,
 * so the eye lands on the mark and the headline and never counts zeroes. */
.hero-art .gc-bits {
  opacity: 0.45;
}

/* Alibaba's mark reaches the hero as a PATH, not a raster, so unlike Google's
 * its colour has to be declared somewhere — and the generator's rule is that no
 * colour reaches the markup. It lands here.
 *
 * Deliberately a literal and NOT a token: #FF6A00 is Alibaba's orange, sampled
 * off the vendor's own logo. Promoting it to --something-rgb would file another
 * company's brand colour in our palette, where the next drawing would be free
 * to pick it up. It is the vendor's, it is used once, and it stays a literal. */
.hero-art .ac-mark {
  fill: #ff6a00;
}

/* ── a run of tables that must read as one ────────────────────────────────
 * A stack of same-shaped tables sizes each first column to its OWN longest
 * label, so the description column starts somewhere different in every table
 * and the eye has to re-find it on the way down. On the platform page's product
 * catalogue that stagger measured 161px across five tables.
 *
 * `table-layout: fixed` takes the width from the rule instead of the content,
 * which is the whole point: the set shares a geometry rather than each member
 * inventing one. The two variants exist because the sets differ in kind —
 * `--catalogue` holds short product names, `--terms` holds phrases — and forcing
 * one width on both would only trade a stagger for a wrapping problem.
 *
 * Genuine two-sided comparisons are deliberately NOT in here: "✓ Move it /
 * ! Think twice" and "Direct / Through Digithun" weigh their columns equally on
 * purpose, and pinning a narrow first column would break the comparison. */
.sd-table--catalogue,
.sd-table--terms {
  table-layout: fixed;
}

/* A PLAIN length, not min(23.5rem, 40%): tested in the browser, a min() mixing
 * a length and a percentage is silently dropped on a table-cell width and the
 * cell falls back to an even split — which looks like the rule working while
 * doing nothing. The narrow case is already covered: .sd-table carries
 * min-width 44rem and .sd-table-wrap scrolls, so below that the whole table
 * scrolls rather than this column crushing.
 *
 * 23.5rem, not the 21rem this started at. `.sd-table tbody th` sets nowrap, so
 * a label that outgrows the column does not wrap — it runs straight into the
 * description text with no visible break. 21rem fitted the Google catalogue;
 * the Alibaba one added "Container Service for Kubernetes (ACK)" beside an icon,
 * which overran by 17px. The width is measured against the longest label in
 * EITHER set on purpose: the rule's whole job is that the run shares one
 * geometry, so it has to be sized for the run, not for one table. */
.sd-table--catalogue :is(th, td):first-child {
  width: 23.5rem;
}

.sd-table--terms :is(th, td):first-child {
  width: 24rem;
}

/* ── Google certification badges ──────────────────────────────────────────
 * Issued by Google, so they keep Google's artwork exactly as issued. A badge
 * is a claim about a credential; restyling one would be editing the claim. */
.gc-badges {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  flex-wrap: wrap;
  margin: 0.7rem 0 0;
}

.gc-badges--lead {
  margin-top: clamp(0.9rem, 2vh, 1.4rem);
}

.gc-badge {
  flex: 0 0 auto;
  width: 3.25rem;
  height: 3.25rem;
}

.gc-badges--lead .gc-badge {
  width: 4.25rem;
  height: 4.25rem;
}

.gc-badges-note {
  flex: 1 1 18rem;
  font-size: var(--text-sm);
  color: var(--text-dim);
}

/* ══ ShopSCAPE branding band ═════════════════════════════════════════════
 * /products/shopscape is a PRODUCT page, and the owner's call is that it may
 * lean on ShopSCAPE's own identity rather than hold strictly to the site's.
 * This band is where that happens, and it is deliberately the only place: the
 * teals live on `.ss-band` as scoped custom properties, not in :root, so a
 * second product's brand colour can never be picked up by a drawing elsewhere
 * on the site the way a global token would be.
 *
 * Colours sampled off the artwork the owner supplied (Layer_1.pdf): the centre
 * reads rgb(0,50,51) and the outer edge rgb(5,97,99). That is a radial ramp
 * from dark to bright OUTWARD, which is the opposite of the usual vignette and
 * is what gives the band its glow.
 *
 * Text inside stays on the site's own tokens. The brand supplies the ground,
 * not the ink — and --text-hi over #003233 measures 15.6:1. */
/* The product's palette, declared ONCE on the page and inherited from there.
 * It sits on `.ss-brand` (the <main> of /products/shopscape) rather than in
 * :root so it cannot reach any other page — a second product's brand would
 * collide with it the moment either became a global token. */
.ss-brand {
  --ss-core: #003233;
  --ss-mid: #04494b;
  --ss-edge: #056163;

  /* The heading ink, owner's decision 2026-08-17: the section titles on this
   * page carry ShopSCAPE's teal instead of the site's white.
   *
   * ⚠ MEASURED: #056163 against this site's ground (#010209) is 2.86:1. WCAG
   * needs 3:1 for large text and 4.5:1 for the rest, so these headings do not
   * meet either. It is the owner's brand call on a page that is noindex and
   * pre-G3, and it is recorded here rather than quietly softened.
   *
   * If it should pass instead, this is the only line to change — the same hue,
   * lifted: #12999c is 5.98:1, #2fa8aa is 7.2:1. */
  --ss-title: #056163;
}

/* Every title this page owns. The band's own h2 is excluded on purpose: it sits
 * ON the teal, and teal ink on a teal ground is not a colour decision, it is a
 * disappearance. Shared chrome — Our Move, the newsletter — is excluded too:
 * it belongs to the site, not to the product. */
.ss-brand .sd-hero h1,
.ss-brand .sd-block h2,
.ss-brand .svc-close h2 {
  color: var(--ss-title);
}

/* The sibling product, /products/shopscape-studio — purple, not teal.
 *
 * These are not a designer's guess at "a purple". ShopSCAPE Studio is a running
 * product with its own design tokens, and these come from its stylesheet:
 * --plum #5b2a86 and --violet #8b5cf6, read out of the dark theme the product
 * actually ships. The page and the software a reader signs into afterwards are
 * therefore the same colour, which is the entire point of a brand token.
 *
 * The ramp runs dark to light like the teal one it replaces, and lands on the
 * product's own plum at the edge. --ss-core and --ss-mid are steps toward it.
 *
 * MEASURED, so the next person does not have to:
 *   ink   --ss-title #8b5cf6 on the site ground #010209 → 4.89:1, over the
 *         4.5:1 body-text bar. The teal it replaces was 2.86:1 and under both.
 *   band  white #f4f8fd on core/mid/edge → 16.27 / 12.71 / 9.29:1, all of them
 *         better than the teal band's 13.09 and 6.80.
 * Change any of these four and re-measure; do not assume a sibling hue behaves
 * like the one it replaced. */
.st-brand {
  --ss-core: #24103a;
  --ss-mid: #401c5f;
  --ss-edge: #5b2a86;
  --ss-title: #8b5cf6;

  /* The lighter step of the same ramp, --violet-soft in the product's own
   * theme. It carries the small accents: the eyebrow above each heading and
   * the rule down the side of a callout, both of which arrive from the site's
   * blue-and-teal defaults and read as a different brand next to purple
   * headings. 7.61:1 on the site ground. */
  --ss-accent: #a78bfa;

  /* The positive-delta green from the dashboard the fold illustrates. It is the
   * one hue on this page that is not purple, and it earns that by carrying
   * meaning the shape does not: an arrow points either way, a colour says which
   * way is good. Declared HERE rather than in :root — the site's palette has no
   * green and should not gain one because a single illustration wanted it. */
  --ss-up: #3f9d6b;
}

.st-brand .sd-hero h1,
.st-brand .sd-block h2,
.st-brand .svc-close h2 {
  color: var(--ss-title);
}

/* The page's own accents, scoped the same way the headings are: content only.
 * The nav, Our Move and the footer keep the site's blue on purpose — they are
 * the site's chrome, not the product's, and a reader crossing from one page to
 * another should not see the furniture change colour. */
.st-brand .sd-hero > .eyebrow,
.st-brand .sd-block .eyebrow {
  color: var(--ss-accent);
}

/* The // marker keeps its old relationship to the label beside it: more
 * saturated, so it reads as a mark rather than as the first word. */
.st-brand .sd-hero > .eyebrow span,
.st-brand .sd-block .eyebrow span {
  color: var(--ss-title);
}

/* :not(.sd-unverified) is load-bearing. Held copy is a dashed amber rule, and
 * that colour IS the signal that the paragraph is not cleared for publication.
 * This selector is (0,3,0) against .sd-unverified's (0,1,0), so without the
 * exclusion it would repaint every placeholder on the page purple and the
 * draft grammar would disappear without anything failing. */
.st-brand .sd-hero .sd-callout:not(.sd-unverified),
.st-brand .sd-block .sd-callout:not(.sd-unverified) {
  border-left-color: var(--ss-title);
}

/* ══ NEWS SLIDER ═════════════════════════════════════════════════════════════
 * Composition ported from CodingNepal's "Responsive Image Slider" tutorial:
 * a full-bleed panel, copy arriving in staggered beats, and a row of labelled
 * tabs under it with an underline that slides to the one in play.
 *
 * Rebuilt rather than borrowed. The original runs on Swiper 11 from a CDN with
 * its icons from Material Symbols and its type from Google Fonts, all three of
 * which this site's CSP refuses; and its five photographs are unlicensed stock.
 * The grounds are our own generators instead. */
.ns-slider {
  position: relative;
  /* Flush to the band above: the banner opens the page, so a gap there reads as
   * the band having failed to reach it rather than as breathing room. */
  margin: 0;
  /* How much of the banner's foot the article rail covers. The copy reserves
   * exactly this, so the two are set in one place instead of drifting apart. */
  --ns-rail-h: 11.5rem;
}

/* The banner itself. The height lives here rather than on the section, because
 * the article rail underneath is part of the slider and must not be squeezed
 * into it. */
.ns-stage {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  /* Tall enough to be a panel, never taller than the window it sits in. The
   * rem figure is the original pair less 30%, plus the two the owner asked for
   * on top of it — 50px, then 30px. Kept as an addition so the number says
   * where it came from, and kept inside min() so the viewport cap still bites
   * on a short window. */
  height: min(82svh, calc(34rem + 80px));
}

.ns-track {
  display: grid;
  height: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Every panel in the same grid cell, so the fade is a cross-fade rather than
 * two elements taking turns to exist. `hidden` is the accessible state and
 * carries the visual one with it — they cannot disagree. */
.ns-slide {
  grid-area: 1 / 1;
  position: relative;
  display: flex;
  align-items: center;
  opacity: 0;
  transition: opacity 900ms var(--ease-out, ease);
}

/* `display: flex` overrides the UA stylesheet's `[hidden] { display: none }`,
 * because the grid cell has to stay or the panel's height collapses. That
 * override has a cost that is invisible on screen: a panel left at `opacity: 0`
 * is still read aloud, since assistive tech ignores opacity — all five
 * headlines at once. `visibility` is what actually takes a panel out of the
 * accessibility tree while leaving its cell in place, and the zero-length
 * transition delayed by the length of the fade lets the outgoing panel finish
 * fading before it goes. */
.ns-slide[hidden] {
  display: flex;
  visibility: hidden;
  pointer-events: none;
  transition:
    opacity 900ms var(--ease-out, ease),
    visibility 0s linear 900ms;
}

.ns-slide:not([hidden]) {
  opacity: 1;
  visibility: visible;
}

/* No fade to wait for, so nothing to delay. */
@media (prefers-reduced-motion: reduce) {
  .ns-slide[hidden] {
    transition: none;
  }
}

.ns-slide-art {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
}

.ns-slide-photo {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Every one of these frames puts its subject on the right and leaves the left
   * empty, which is where the copy goes — so hold the right edge when the panel
   * is narrower than the photograph. */
  object-position: 70% center;
}

/* A wash over the artwork so the copy has something to sit on that is darker
 * on the side the copy is on. Without it the headline competes with whatever
 * the generator happened to draw behind it. */
.ns-slide-art::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    100deg,
    rgb(1 2 9 / 0.9) 0%,
    rgb(1 2 9 / 0.62) 42%,
    rgb(1 2 9 / 0.1) 100%
  );
}

/* Left-aligned against the page gutter, not centred in a reading column: this
 * is a full-bleed statement panel, and .binary-panel already sets that pattern
 * on the same inset. Centring it spent a fifth of the panel before the first
 * word, and pushed the copy toward whoever is standing in the frame. */
.ns-slide-copy {
  width: 100%;
  /* The bottom inset clears the rail laid over the banner's foot. It is the
   * same variable the rail is sized in, set once on the section, so the reserve
   * and the thing reserved for cannot drift — and when the rail drops out into
   * a block of its own the variable shrinks and the panel gets the space back. */
  padding: 0 var(--gutter) var(--ns-rail-h);
}

/* The category, in the design system's inverse pill: white ground, dark type.
 * It is the first thing in the stack because it is the first thing the reader
 * needs — which piece of the site this belongs to. */
.ns-slide-kind {
  /* inline-block, not align-self: the copy column is a plain block, so a <p>
   * would take the whole width and the pill would render as a white bar. */
  display: inline-block;
  margin: 0 0 1rem;
  padding: 0.4rem 0.9rem;
  border-radius: var(--r-pill);
  background: var(--surface-inverse);
  color: var(--text-on-inverse);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.ns-slide-title {
  max-width: min(34rem, 44%);
  margin: 0;
  font-size: var(--text-3xl);
  font-weight: 800;
  line-height: 1.12;
  letter-spacing: -0.015em;
  color: var(--text-hi);
  text-wrap: balance;
}

/* The headline is a link now — it is how a reader opens the piece. It has to
 * stay the headline while it is one: without this it renders in the browser's
 * link colour with an underline, on top of a photograph, at display size. */
.ns-slide-title a {
  color: inherit;
  text-decoration: none;
}

.ns-slide-title a:hover {
  text-decoration: underline;
  text-underline-offset: 0.12em;
}

.ns-slide-title a:focus-visible {
  outline: 2px solid var(--text-accent);
  outline-offset: 4px;
}

.ns-slide-byline {
  margin: 1rem 0 0;
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--text-hi);
}

.ns-slide-desc {
  max-width: min(34rem, 44%);
  margin: 0.75rem 0 0;
  font-size: var(--text-base);
  line-height: var(--lh-body);
  color: var(--text-body);
}

/* Below this the panel is too narrow for anything to sit beside anything else,
 * so the arrangement turns ninety degrees: copy along the bottom, subject above
 * it, and the wash runs bottom-to-top instead of left-to-right. Centred copy on
 * a phone lands squarely on the face of whoever is in the frame. */
@media (max-width: 63.99rem) {
  .ns-slide {
    align-items: flex-end;
  }

  .ns-slide-title,
  .ns-slide-desc {
    max-width: 34rem;
  }

  .ns-slide-art::after {
    background: linear-gradient(
      to top,
      rgb(1 2 9 / 0.94) 0%,
      rgb(1 2 9 / 0.78) 38%,
      rgb(1 2 9 / 0.22) 100%
    );
  }
}

/* The staggered entrance, which is the part of the original worth keeping: the
 * eyebrow arrives, then the headline, rather than the whole panel appearing at
 * once. Only the panel in play animates. */
.ns-slide:not([hidden]) .ns-slide-kind { animation: ns-rise 600ms 250ms var(--ease-out, ease) backwards; }
.ns-slide:not([hidden]) .ns-slide-title { animation: ns-rise 600ms 430ms var(--ease-out, ease) backwards; }
.ns-slide:not([hidden]) .ns-slide-byline { animation: ns-rise 600ms 610ms var(--ease-out, ease) backwards; }
.ns-slide:not([hidden]) .ns-slide-desc { animation: ns-rise 600ms 790ms var(--ease-out, ease) backwards; }

@keyframes ns-rise {
  from { opacity: 0; translate: 0 1.4rem; }
}

/* ── the article rail ─────────────────────────────────────────────────────
 * Buttons in a tablist, not links: these choose which panel is showing, they
 * do not navigate anywhere, and the arrow keys are expected to move between
 * them.
 *
 * It sits under the banner rather than inside it. Overlaid, the rail was
 * spending panel height and clipping its own labels against the arrows; below,
 * each entry can carry the category and the headline it stands for, which is
 * what makes it worth clicking. */
.ns-controls {
  /* Laid over the foot of the banner. It is a child of the section rather than
   * of the stage, so it is not clipped by the stage's overflow and it can drop
   * out into its own block on a narrow screen by going back to static. */
  position: absolute;
  z-index: 2;
  right: 0;
  bottom: 0;
  left: 0;
  padding: 0 var(--gutter) clamp(1.1rem, 2.5vh, 1.6rem);
}

/* Out from under the banner and into a block of its own — which is where a
 * list of five headlines belongs on a screen this narrow. Static, so the
 * banner's height stops applying to it, and nothing is laid over the picture
 * any more so nothing has to be reserved for it.
 *
 * This has to sit AFTER the rule it overrides: same specificity, and the block
 * it used to live in is 200 lines further up the file, where it lost every
 * time and the rail stayed pinned over the copy on every phone. */
@media (max-width: 63.99rem) {
  .ns-controls {
    position: static;
    padding: clamp(1.5rem, 4vh, 2.25rem) var(--gutter) 0;
  }

  .ns-slider {
    --ns-rail-h: 2rem;
  }
}

.ns-controls-label {
  margin: 0 0 0.85rem;
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-hi);
}

.ns-tabs {
  display: grid;
  /* One column per slide while they fit, then as many as will — a rail that
   * wraps beats a rail that scrolls out of sight. */
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
  gap: 0;
  border-top: 1px solid rgb(var(--blue-700-rgb) / 0.35);
}

.ns-tab {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
  align-items: start;
  padding: 1.1rem clamp(0.9rem, 1.6vw, 1.5rem) 0;
  border: 0;
  background: none;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background-color var(--motion-hover) var(--ease-out);
}

/* The first column starts at the page gutter, in line with the copy above it. */
.ns-tab:first-child {
  padding-left: 0;
}

/* No box on hover — the owner asked for the type to brighten and nothing else.
 * A tinted panel behind one column of a five-column rail reads as a selection
 * that has already happened, which is the state next to it. */
.ns-tab:hover .ns-tab-title {
  color: var(--text-hi);
}

.ns-tab:focus-visible {
  outline: 2px solid var(--text-accent);
  outline-offset: -2px;
}

/* The category chip is .move-kind — the one the Our Move cards and the update
 * grid already use. Re-declaring its padding, border and colour here would be a
 * second definition of the same component, which is how two chips end up
 * looking almost the same. */

/* Stacked, the rules have to turn ninety degrees with the layout: a border-left
 * on a single column draws one long line down the side of the whole rail rather
 * than separating anything. The grid is pinned to one column here so the
 * breakpoint and the rule can never disagree about which way it wrapped. */
@media (max-width: 40rem) {
  .ns-tabs {
    grid-template-columns: 1fr;
  }

  .ns-tab {
    padding-right: 0;
    padding-left: 0;
    border-top: 1px solid rgb(var(--blue-700-rgb) / 0.35);
    border-left: 0;
  }

  .ns-tab:first-child {
    border-top: 0;
  }
}

.ns-tab-title {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  overflow: hidden;
  font-size: var(--text-sm);
  line-height: 1.45;
  color: var(--text-dim);
  transition: color var(--motion-hover) var(--ease-out);
}

/* aria-selected is the state the button announces, so it is the state the
 * stylesheet reads. */
.ns-tab[aria-selected="true"] .ns-tab-title {
  color: var(--text-hi);
}

.ns-tab[aria-selected="true"] .move-kind {
  border-color: var(--line-hover);
  background: var(--fill-hover);
}

/* ══ ONE UPDATE'S OWN PAGE ═══════════════════════════════════════════════
 * /news/<slug>. This is the piece's real address: the one a search engine
 * indexes, an answer engine quotes and a reader shares. The drawer on /news and
 * /about reads the same words; this is where they live. */
/* The same column the More-updates grid below it uses, so the piece and the
 * cards line up on one left edge instead of the article sitting in a narrower
 * box inside a wider page. */
.post {
  max-width: 76rem;
  margin: 0 auto;
  /* The 0/1 band above already clears the absolutely-positioned top bar, so
   * this is breathing room under the band rather than an offset for the bar. */
  padding: clamp(2.5rem, 6vh, 4rem) var(--gutter) clamp(2rem, 6vh, 3.5rem);
}

.post-head {
  margin: 0 0 clamp(1.75rem, 4vh, 2.5rem);
}

/* Same inverse pill the banner leads with, so a reader arriving from the grid
 * lands on something they recognise. */
.post-kind {
  display: inline-block;
  margin: 0 0 1.1rem;
  padding: 0.4rem 0.9rem;
  border-radius: var(--r-pill);
  background: var(--surface-inverse);
  color: var(--text-on-inverse);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}

.post h1 {
  margin: 0;
  font-size: var(--text-3xl);
  font-weight: 800;
  line-height: 1.12;
  letter-spacing: -0.015em;
  color: var(--text-hi);
  text-wrap: balance;
}

.post-byline {
  margin: 1rem 0 0;
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--text-hi);
}

.post-standfirst {
  margin: 0.9rem 0 0;
  font-size: var(--text-lg);
  line-height: var(--lh-body);
  color: var(--text-body);
}

.post-cover {
  margin: 0 0 clamp(1.75rem, 4vh, 2.5rem);
}

/* The picture keeps its own shape here. The card grid crops to 16:9 because a
 * row of cards has to line up; a feature photograph does not, and cropping this
 * one to a letterbox cut the top off the screen that names who is on stage. */
.post-cover img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--r-md, 16px);
}

.post-cover figcaption {
  margin: 0.75rem 0 0;
  font-size: var(--text-xs);
  line-height: var(--lh-body);
  color: var(--text-hint);
}

.post-body > p,
.post-body li {
  margin: 0 0 1.15rem;
  line-height: var(--lh-body);
  color: var(--text-body);
}

.post-body h2 {
  margin: clamp(2rem, 5vh, 2.75rem) 0 0.9rem;
  font-size: var(--text-xl);
  font-weight: 700;
  line-height: 1.25;
  color: var(--text-hi);
}

.post-body ul {
  margin: 0 0 1.15rem;
  padding-left: 1.2rem;
}

.post-body li {
  margin-bottom: 0.5rem;
}

/* Wide content gets its own scroll rather than pushing the page sideways. */
.post-table {
  margin: 0 0 1.6rem;
  overflow-x: auto;
}

.post-table table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-sm);
}

.post-table th,
.post-table td {
  padding: 0.65rem 0.9rem;
  border-bottom: 1px solid rgb(var(--blue-700-rgb) / 0.3);
  text-align: left;
  color: var(--text-body);
}

.post-table th {
  color: var(--text-hi);
  font-weight: 600;
}

/* Illustrations inside the piece. */
.post-figure {
  margin: clamp(1.75rem, 4vh, 2.5rem) 0;
}

/* max-width, not width: a picture narrower than the column renders at its own
 * size and centres, rather than being stretched to fill. Upscaling adds no
 * detail and takes some away — the awards group shot is 864px on a 1075px
 * column, and stretched it just looks soft. */
.post-figure img {
  display: block;
  max-width: 100%;
  height: auto;
  margin-inline: auto;
  border-radius: var(--r-md, 16px);
}

.post-figure figcaption {
  margin: 0.75rem 0 0;
  font-size: var(--text-xs);
  line-height: var(--lh-body);
  color: var(--text-hint);
}

/* The questions, written out. The FAQPage markup says the same thing to a
 * machine — never one without the other. */
.post-faq {
  margin: clamp(2.5rem, 6vh, 3.5rem) 0 0;
  padding-top: clamp(1.5rem, 4vh, 2rem);
  border-top: 1px solid rgb(var(--blue-700-rgb) / 0.35);
}

.post-faq-head {
  margin: 0 0 1.4rem;
  font-size: var(--text-xl);
  font-weight: 700;
  color: var(--text-hi);
}

.post-faq dt {
  margin: 0 0 0.5rem;
  font-weight: 600;
  color: var(--text-hi);
}

.post-faq dd {
  margin: 0 0 1.5rem;
  line-height: var(--lh-body);
  color: var(--text-body);
}

/* Where the checkable facts came from. Set smaller and dimmer than the body:
 * it is an audit trail, not part of the argument. */
.post-source {
  margin: clamp(1.75rem, 4vh, 2.5rem) 0 0;
  padding-top: 1.1rem;
  border-top: 1px solid rgb(var(--blue-700-rgb) / 0.3);
  font-size: var(--text-xs);
  line-height: var(--lh-body);
  color: var(--text-hint);
}

.post-back {
  margin: clamp(2rem, 5vh, 3rem) 0 0;
  font-size: var(--text-sm);
}

/* More updates, in the same cards the grid uses — a reader who finished one
 * piece is the likeliest person on the site to read a second. */
.post-more {
  max-width: 76rem;
  margin: 0 auto;
  padding: 0 var(--gutter) clamp(3rem, 8vh, 5rem);
}

.post-more-head {
  margin: 0 0 clamp(1.25rem, 3vh, 1.75rem);
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-hi);
}

/* ── Latest updates — the filterable card grid under the banner ─────────
 *
 * A row of category pills over a grid of cover-image cards. The pills are the
 * same shape as .move-kind, which is already the site's category chip; the
 * cards take the raised surface, the 16px radius and the blue hairline every
 * other panel uses. */
.nu {
  /* The heading block above already carries its own bottom inset, so the two
   * used to stack to about 130px of nothing between the page's title and the
   * first control. This side gives most of that back. */
  padding: clamp(0.5rem, 1.5vh, 1rem) var(--gutter) clamp(3rem, 8vh, 5rem);
}

/* …and this finishes the job the rule above starts: 20px from the baseline of
 * "News update" to the filter row, at the owner's word.
 *
 * Scoped with :has() because `.about-open` is the shared opening block on
 * /about and /services too, and neither of those wants its bottom inset taken
 * away. `.nu` only ever follows it on /news.
 *
 * The 46px is measured, not arithmetic, and that is worth explaining or someone
 * will "correct" it to 20px. `.about-open` does not tightly wrap its content:
 * with its bottom padding at zero the h1's box still ends 26px BELOW the
 * section's own bottom edge. That overhang is a constant — it measures the same
 * at 375px and at 1165px, where the display type differs by 23px — so one flat
 * value holds across the range rather than needing a clamp. 46 − 26 = the 20 we
 * were asked for. If the opening block's internals ever change, re-measure:
 * pills.getBoundingClientRect().top − h1.getBoundingClientRect().bottom. */
.about-open:has(+ .nu) {
  padding-bottom: 0;
}

.about-open:has(+ .nu) + .nu {
  padding-top: 46px;
}

.nu-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin: 0 0 clamp(1.5rem, 4vh, 2.25rem);
}

.nu-filter {
  padding: 0.45rem 1.1rem;
  border: 1px solid rgb(var(--blue-500-rgb) / 0.3);
  border-radius: var(--r-pill);
  background: none;
  font-size: var(--text-sm);
  color: var(--text-dim);
  cursor: pointer;
  transition:
    color 200ms var(--ease-out, ease),
    border-color 200ms var(--ease-out, ease),
    background-color 200ms var(--ease-out, ease);
}

.nu-filter:hover {
  border-color: var(--line-hover);
  color: var(--text-hi);
}

/* aria-pressed is the state, so it is also the style hook — the two cannot
 * drift apart into a class that says one thing and a label that says another. */
.nu-filter[aria-pressed="true"] {
  border-color: var(--line-hover);
  background: var(--fill-hover);
  color: var(--text-hi);
}

.nu-filter:focus-visible {
  outline: 2px solid var(--text-accent);
  outline-offset: 2px;
}

.nu-grid {
  display: grid;
  /* Three across on a desktop, two on a tablet, one on a phone — by measuring
   * the card rather than by counting breakpoints. */
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
  gap: clamp(1.25rem, 2.5vw, 2rem);
  margin: 0;
  padding: 0;
  list-style: none;
}

.nu-card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: 1px solid rgb(var(--blue-700-rgb) / 0.28);
  border-radius: var(--r-md, 16px);
  background: var(--surface-raised);
}

/* `display: flex` above outranks the UA stylesheet's `[hidden] { display: none }`,
 * so a card the filter hides would stay on screen. The same trap the slider's
 * panels sit in, one component along. */
.nu-card[hidden] {
  display: none;
}

.nu-cover {
  display: block;
  width: 100%;
  /* The width/height attributes are there to reserve the space before the file
   * arrives, but a specified height beats aspect-ratio — without `auto` the
   * cover renders 450px tall and the card is nearly twice the height it should
   * be. */
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  border-bottom: 1px solid rgb(var(--blue-700-rgb) / 0.28);
}

/* The whole card is one link now. The <a> takes over the column so the cover,
 * the copy and the footer all sit inside the hit area — a card where only the
 * headline is clickable is a card people click and nothing happens. */
.nu-card-link {
  display: flex;
  flex: 1;
  flex-direction: column;
  color: inherit;
  text-decoration: none;
}

.nu-card-link:focus-visible {
  outline: 2px solid var(--text-accent);
  outline-offset: -2px;
}

.nu-card:hover {
  border-color: var(--line-hover);
}

.nu-card:hover .nu-card-title {
  color: var(--text-hi);
}

.nu-card-body {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 0.7rem;
  padding: clamp(1.1rem, 2vw, 1.5rem);
}

/* Clamped so a long headline cannot push one card taller than the row. The
 * whole item lives behind the card, not in it. */
.nu-card-title,
.nu-card-excerpt {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.nu-card-title {
  -webkit-line-clamp: 2;
  margin: 0;
  font-size: var(--text-lg);
  font-weight: 700;
  line-height: 1.3;
  color: var(--text-hi);
}

.nu-card-excerpt {
  -webkit-line-clamp: 3;
  margin: 0;
  font-size: var(--text-sm);
  line-height: var(--lh-body);
  color: var(--text-dim);
}

/* Pushed to the bottom, so the source line and the category sit on one baseline
 * across the row however long the copy above them runs. */
.nu-card-foot {
  display: flex;
  gap: 0.75rem;
  align-items: center;
  justify-content: space-between;
  margin: auto 0 0;
  padding-top: 0.4rem;
}

.nu-source {
  font-size: var(--text-xs);
  color: var(--text-hint);
}

.nu-empty {
  margin: clamp(1.5rem, 4vh, 2.25rem) 0 0;
  font-size: var(--text-sm);
  color: var(--text-hint);
}

/* ── prev / next ──────────────────────────────────────────────────────── */
/* Top right, as a pair. On the edges at half height they sat over the copy on
 * every narrow viewport; in the corner they are out of the reading path and
 * still the first place a hand goes. */
.ns-nav {
  position: absolute;
  z-index: 3;
  top: clamp(1rem, 3vh, 1.75rem);
  right: var(--gutter);
  display: flex;
  gap: 0.5rem;
}

.ns-arrow {
  display: grid;
  place-items: center;
  width: 2.6rem;
  height: 2.6rem;
  /* Square, not round: the pair reads as one control split in two. */
  border: 1px solid rgb(var(--ink-rgb) / 0.28);
  border-radius: var(--r-sm, 10px);
  background: rgb(var(--navy-0-rgb) / 0.55);
  color: var(--text-hi);
  cursor: pointer;
  transition:
    background-color var(--motion-hover) var(--ease-out),
    border-color var(--motion-hover) var(--ease-out);
}

.ns-arrow svg { width: 1.25rem; height: 1.25rem; }

.ns-arrow:hover { background: rgb(30 24 56 / 0.9); }

.ns-arrow:focus-visible {
  outline: 2px solid var(--text-accent);
  outline-offset: 2px;
}

/* A second, tighter step for phones. The height is the old pair less the same
 * 30%; the copy's spacing is handled by the wider breakpoint above and must not
 * be set again here, or it overrides it at exactly the sizes with least room. */
@media (max-width: 720px) {
  .ns-stage { height: min(64svh, calc(27rem + 80px)); }

  /* The stack tightens on a phone rather than losing a part: nothing is hidden
   * here that a desktop is trusted with. */
  .ns-slide-title { font-size: var(--text-xl); }

  .ns-slide-byline { margin-top: 0.75rem; }

  .ns-slide-desc {
    margin-top: 0.6rem;
    font-size: var(--text-sm);
  }
}

/* Every staggered part, not the two that happened to exist when this was
 * written: a part added later would keep moving for the reader who asked
 * everything to stop, and nothing about the page would look wrong. */
@media (prefers-reduced-motion: reduce) {
  .ns-slide,
  .ns-tab,
  .ns-arrow { transition: none; }

  .ns-slide:not([hidden]) .ns-slide-kind,
  .ns-slide:not([hidden]) .ns-slide-title,
  .ns-slide:not([hidden]) .ns-slide-byline,
  .ns-slide:not([hidden]) .ns-slide-desc { animation: none; }
}

/* ══ SECTION NAVIGATION ══════════════════════════════════════════════════════
 * A bar of section links that starts in the flow and sticks to the top once
 * the reader passes it. The main .topbar is position: absolute, so this owns
 * the top edge alone and needs no header offset.
 *
 * The glass edge is adapted from "Liquid Glass Switcher. CSS" by Vadim Matveev
 * (https://codepen.io/fooontic/pen/KwpRaGr). What is borrowed is the recipe:
 * a very low-alpha fill, and a stack of inset shadows that light the top-left
 * and shade the bottom-right so the panel reads as a thick pane rather than a
 * flat translucent rectangle. Two things from the original are deliberately
 * NOT here — its backdrop-filter also runs url(#…) through an feDisplacementMap
 * fed by a 40KB base64 WebP the author drew, which is their asset rather than a
 * technique, and Safari ignores url() in backdrop-filter anyway, so the pen
 * ships the same fallback this uses. */
/* One pixel, not zero. A zero-height target never reports isIntersecting: true
 * — there is no area to intersect — so the observer fires once and then has no
 * state change left to report, and the bar's stuck class never toggles. The
 * negative margin gives the pixel back to the layout. */
.sub-nav-sentinel,
.sub-nav-end {
  height: 1px;
  margin-bottom: -1px;
}

.sub-nav {
  position: sticky;
  top: 0;
  z-index: var(--z-nav);

  /* The tabs' own ink. The site's --text-dim is blue, which is right for the
   * pages it was made for and wrong beside a purple pill — it reads as a third
   * brand rather than as quiet text. A near-neutral grey, faintly cool so it
   * sits with the purple instead of against it. 11.8:1 on the pill's ground. */
  --sub-nav-ink: #c8c8d2;
  /* Edge to edge. The inset is a hair rather than the page gutter so the pill
   * reads as reaching the sides while its rounded ends stay visible — flush at
   * exactly 0 would clip them against the viewport and look like a mistake. */
  display: flex;
  padding: 0.75rem;
  transition:
    background-color var(--motion-enter) var(--ease-out),
    box-shadow var(--motion-enter) var(--ease-out),
    padding var(--motion-enter) var(--ease-out);
}

/* Stuck: the pill floats over the page on its own.
 *
 * It used to sit on a full-width band — an opaque strip with its own blur and a
 * hairline under it. The owner took that off: the strip was a second surface
 * doing the same job as the glass inside it, and everything to the left and
 * right of the pill was a bar that carried nothing.
 *
 * So the only thing that changes on sticking is the shadow under the pill.
 * Floating over text needs more separation than sitting in the flow does, and
 * that lift is what tells the reader the bar has left the page and is now on
 * top of it. */
.sub-nav.is-stuck {
  /* 20px of air above the pill once it is floating, at the owner's word. In the
   * flow it keeps the tighter 0.75rem all round — the gap only has a job when
   * there is a viewport edge for the pill to be off. */
  padding: 20px 0.75rem 0.75rem;
}

.sub-nav-glass {
  --glass-light: var(--white);
  --glass-dark: #000;
  position: relative;
  isolation: isolate;
  /* Full width, with the tabs packed left and the one control that is not a
   * tab pushed to the far right. */
  width: 100%;
  /* Measured off .pill-nav, which renders 58px. Two bars at the top of the
   * same page that are nearly-but-not-quite the same height read as a mistake,
   * so this is pinned rather than left to fall out of the link padding. */
  min-height: 3.625rem;
  display: flex;
  align-items: center;
  border-radius: 99em;
  background-color: color-mix(in srgb, var(--white) 7%, transparent);
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--glass-light) 10%, transparent),
    inset 1.8px 3px 0 -2px color-mix(in srgb, var(--glass-light) 42%, transparent),
    inset -2px -2px 0 -2px color-mix(in srgb, var(--glass-light) 34%, transparent),
    inset -3px -8px 1px -6px color-mix(in srgb, var(--glass-light) 22%, transparent),
    inset -0.3px -1px 4px 0 color-mix(in srgb, var(--glass-dark) 12%, transparent),
    inset -1.5px 2.5px 0 -2px color-mix(in srgb, var(--glass-dark) 20%, transparent),
    inset 0 3px 4px -2px color-mix(in srgb, var(--glass-dark) 20%, transparent),
    inset 2px -6.5px 1px -4px color-mix(in srgb, var(--glass-dark) 10%, transparent),
    0 1px 5px 0 color-mix(in srgb, var(--glass-dark) 22%, transparent),
    0 6px 16px 0 color-mix(in srgb, var(--glass-dark) 18%, transparent);
  transition: box-shadow var(--motion-enter) var(--ease-out);
}

/* The glass edge stays at full strength now that nothing sits behind it. It
 * used to flatten here because a lit rim inside a lit band read as a bevel;
 * with the band gone, flattening would just make the pill vaguer exactly when
 * it has the most to be distinguished from. Only the drop shadow deepens. */
.sub-nav.is-stuck .sub-nav-glass {
  box-shadow:
    inset 0 0 0 1px color-mix(in srgb, var(--glass-light) 10%, transparent),
    inset 1.8px 3px 0 -2px color-mix(in srgb, var(--glass-light) 42%, transparent),
    inset -2px -2px 0 -2px color-mix(in srgb, var(--glass-light) 34%, transparent),
    inset -3px -8px 1px -6px color-mix(in srgb, var(--glass-light) 22%, transparent),
    inset -0.3px -1px 4px 0 color-mix(in srgb, var(--glass-dark) 12%, transparent),
    inset -1.5px 2.5px 0 -2px color-mix(in srgb, var(--glass-dark) 20%, transparent),
    inset 0 3px 4px -2px color-mix(in srgb, var(--glass-dark) 20%, transparent),
    inset 2px -6.5px 1px -4px color-mix(in srgb, var(--glass-dark) 10%, transparent),
    0 2px 10px 0 color-mix(in srgb, var(--glass-dark) 38%, transparent),
    0 12px 34px 0 color-mix(in srgb, var(--glass-dark) 32%, transparent);
}

/* ── refraction ────────────────────────────────────────────────────────────
 * The technique is from liquid-glass-react by Chris Coyier's pen (MIT,
 * https://codepen.io/editor/chriscoyier/pen/01984ccd-a23c-76b2-a3c5-e8b27cc85dfe
 * → npm liquid-glass-react). Nothing was copied: that package is a React
 * component that pulls React 19 from a CDN, which this site's CSP blocks and
 * this repository has no dependencies to hold. What was taken is the shape of
 * the idea — draw a displacement map, hand it to feImage, bend the blurred
 * backdrop through feDisplacementMap.
 *
 * The original regenerates its map on every mousemove, which is a per-pixel
 * JavaScript loop over ~270,000 pixels at this bar's size. This one draws the
 * map once per resize. The glass stops following the cursor and stops costing
 * anything while the reader scrolls the page it is stuck to.
 *
 * On its own layer, not on the pill: filter on the pill would bend the links
 * as well, and text read through a lens is a lens, not a navigation bar. */
.sub-nav-refract {
  position: absolute;
  z-index: -1;
  inset: 0;
  border-radius: inherit;
  backdrop-filter: blur(8px) saturate(150%);
  -webkit-backdrop-filter: blur(8px) saturate(150%);
}

/* The filter is added by the script once the map exists. Until then the layer
 * is plain blur — which is what Safari and Firefox get if url() filters on a
 * backdrop turn out to behave differently there, and it is a complete look on
 * its own rather than a broken one. */
.sub-nav-refract.is-lensed {
  filter: url("#sub-nav-lens");
}

/* The rail scrolls sideways at every width, not only on a phone: twelve labels
 * do not fit a laptop either, and a bar that scrolls on one device and clips on
 * another is two behaviours to reason about. The scrollbar is hidden because
 * the overflow is a gesture, not a control — the active tab scrolls itself into
 * view, so nothing depends on the reader finding a thumb. */
.sub-nav-list {
  display: flex;
  gap: 0.15rem;
  margin: 0;
  padding: 0.35rem;
  list-style: none;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}

.sub-nav-list::-webkit-scrollbar {
  display: none;
}

/* .sub-nav-tab, not a bare `a`. The site styles unclassed links inside list
 * items as prose links — `:is(p, li, td, th, dd, figcaption) > a:not([class])`
 * at (0,1,2) — which beat `.sub-nav-list a` at (0,1,1) and painted every tab
 * the site's link blue no matter what this rule said. The class both outranks
 * that selector and drops out of its :not(), which is the honest fix: these
 * are not prose links. */
.sub-nav-list .sub-nav-tab {
  display: block;
  padding: 0.5rem 0.9rem;
  border-radius: 99em;
  color: var(--sub-nav-ink);
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: 1.2;
  text-decoration: none;
  white-space: nowrap;
  transition:
    color var(--motion-hover) var(--ease-out),
    background-color var(--motion-hover) var(--ease-out);
}

.sub-nav-list .sub-nav-tab:hover {
  color: var(--text-hi);
  background-color: color-mix(in srgb, var(--white) 8%, transparent);
}

/* aria-current is the source of truth for "you are here", so the styling hangs
 * off it rather than off a class. The two cannot disagree. */
/* White on the purple, at the owner's word.
 *
 * MEASURED, because it is the one pair here that does not clear the bar: white
 * #f4f8fd on #8b5cf6 is 3.97:1, and 14px text wants 4.5:1. What it replaces was
 * no better — #24103a on the same ground is 4.10:1 — so this is a change of
 * look, not a regression, and it reads far more clearly at a glance.
 *
 * The one-line fix if it should pass: move the ground to #7847e0, which is the
 * same product's --violet in its light theme, and white lands at 5.21:1. */
.sub-nav-list .sub-nav-tab[aria-current="location"] {
  color: var(--white);
  background-color: var(--ss-title);
}

.sub-nav-list .sub-nav-tab:focus-visible {
  outline: 2px solid var(--ss-title);
  outline-offset: 2px;
}

/* Back to top. Outside the scrolling rail, so it holds its place while the
 * tabs move under the reader's thumb. */
.sub-nav-top {
  display: grid;
  place-items: center;
  flex: none;
  width: 2.25rem;
  height: 2.25rem;
  /* Pushed to the right edge, away from the tabs. It is the only thing in here
   * that does not go to a section, and putting it at the far end says so. */
  margin-left: auto;
  margin-right: 0.5rem;
  border-radius: 99em;
  color: var(--sub-nav-ink);
  transition:
    color var(--motion-hover) var(--ease-out),
    background-color var(--motion-hover) var(--ease-out);
}

.sub-nav-top svg {
  width: 1.1rem;
  height: 1.1rem;
}

.sub-nav-top:hover {
  color: var(--text-hi);
  background-color: color-mix(in srgb, var(--white) 8%, transparent);
}

.sub-nav-top:focus-visible {
  outline: 2px solid var(--ss-title);
  outline-offset: 2px;
}

/* Every section has to clear the stuck bar when it is scrolled to, or the
 * heading lands underneath it. One declaration, applied by the browser to
 * whatever the anchor resolves to — no offset arithmetic in the script.
 *
 * --sub-nav-h lives on :root, not on .sub-nav. The sections are the bar's
 * SIBLINGS, so a value declared on the bar never reaches them: this quietly
 * fell back to 4.25rem while the bar grew to 5.625rem, and the only reason
 * headings still cleared it was the section's own top padding making up the
 * difference. That is luck, and it runs out the next time either number moves. */
.st-brand .sd-block,
.st-brand .svc-close,
.ss-brand .sd-block,
.ss-brand .svc-close {
  scroll-margin-top: calc(var(--sub-nav-h) + 1.25rem);
}

@media (prefers-reduced-motion: reduce) {
  .sub-nav,
  .sub-nav-glass,
  .sub-nav-list .sub-nav-tab,
  .sub-nav-top {
    transition: none;
  }
}

/* ══ THE HERO'S ARTWORK ══════════════════════════════════════════════════════
 * The globe from ShopSCAPE Studio's own landing page. It arrived here as a
 * full-height splash of its own, the way the source page opens; the owner then
 * asked for it beside the headline instead, so it is now the hero's ground
 * rather than a section.
 *
 * That move cost the composed wordmark and the Thai line under it, deliberately:
 * this hero already carries the lockup and an h1, and a third naming of the
 * product in the same viewport is a watermark. */
/* clip, not hidden: hidden would make the hero a scroll container and take the
 * sticky nav with it. The artwork is meant to run off the right edge — what is
 * not meant to happen is the document growing to fit it, which is what a bleed
 * does if nothing crops it. */
.st-hero {
  overflow-x: clip;
}

/* The copy stops before the globe starts. Without this the h1 wraps into the
 * artwork's column and the two overlap by about 20px at 1440 — enough to read
 * as a mistake, not enough to notice while writing the rule. */
@media (min-width: 981px) {
  .st-hero > :not(.st-hero-art) {
    max-width: 56%;
  }
}

/* The product's name, composed and turning, centred on the globe.
 *
 * It is sized to FIT that circle rather than to span the page the way the
 * source page's splash does: the artwork is 44% of the hero here, and a line
 * wider than the sphere it is centred on stops reading as its label and starts
 * reading as a headline competing with the real one. At the top of the clamp
 * the longest state — "Customer Service" — measures about 0.75 of the art box,
 * so it holds one line at every width the box has. */
.st-name {
  position: absolute;
  z-index: 2;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.18em;
  width: 96%;
  margin: 0;
  font-weight: 700;
  /* Up 30% with the sphere, then another 20% on top at the owner's word:
   * clamp(1rem, 2.1vw, 1.75rem) → 1.3/2.7/2.28 → here. Measured at the widest
   * state the line ever holds — "Customer Service Studio" — it still sits
   * inside the artwork at every width, which is the constraint that decides
   * how big this is allowed to get. */
  font-size: clamp(1.56rem, 3.24vw, 2.74rem);
  line-height: 1.05;
  letter-spacing: -0.025em;
  text-align: center;
  color: var(--text-hi);
  text-shadow: 0 2px 14px rgb(0 0 0 / 0.55);
}

/* The mark stands over the words rather than in front of them: it is the brand,
 * and the line below is which room of it you are in. Stacking also buys the
 * cycling word its full width back — "Customer Service" no longer has to share
 * a line with the wordmark, so nothing wraps at any width the box has. */
.st-name-line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.3em;
}

.st-name-wm {
  display: inline-block;
  width: auto;
  height: 0.82em;
}

.st-name-type {
  display: inline-flex;
  align-items: center;
  min-height: 1em;
}

/* The gradient is the product's own --brand ramp, clipped to the glyphs. It is
 * the one place on this page a hue moves across a word, and it earns that by
 * being the word that keeps changing. */
.st-name-word {
  background: linear-gradient(120deg, #a78bfa 0%, #8b5cf6 42%, #6366f1 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  white-space: nowrap;
}

.st-name-caret {
  display: inline-block;
  width: 0.05em;
  min-width: 3px;
  height: 0.92em;
  margin-left: 0.07em;
  border-radius: 2px;
  background: var(--ss-title);
  animation: st-caret 1.05s steps(1) infinite;
}

@keyframes st-caret { 50% { opacity: 0; } }

.st-hero-art {
  position: absolute;
  z-index: 0;
  top: 50%;
  right: calc(var(--gutter) * -0.35);
  translate: 0 -50%;
  /* Up 30% at the owner's word, from min(44%, 34rem). The copy column is
   * measure-bound and does not move, so the extra width is spent on the right
   * and the sphere's left edge now passes behind the last lines of the lede.
   * That is fine and is why the copy is a positioned sibling: the globe is
   * ground at 0.82 opacity, and text sits in front of it. */
  width: min(57%, 44rem);
  aspect-ratio: 1;
  pointer-events: none;
}

/* Centred in the art box, and sized from it — one source of truth for how big
 * the globe is, which the script reads back off the canvas. */
.st-fold-globe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  transition: opacity 800ms var(--ease-out, ease);
}

.st-fold-globe.is-on { opacity: 0.82; }

/* ── the floating figures ──────────────────────────────────────────────────
 * Invented numbers, illustrating the dashboard the product actually shows.
 * They are aria-hidden, and this page is noindex: unlabelled they may sit on a
 * draft, and the decision to publish them is one to take at G3 rather than one
 * already taken here. tests/site/shopscape-studio.test.mjs holds that pairing. */
.st-fold-stat {
  position: absolute;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  padding: 0.5rem 0.86rem;
  border: 1px solid var(--line-navy);
  border-radius: 11px;
  background: rgb(12 9 28 / 0.82);
  box-shadow: 0 10px 28px rgb(0 0 0 / 0.45);
  font-family: var(--font-mono, ui-monospace, Menlo, monospace);
  font-size: var(--text-sm);
  white-space: nowrap;
  opacity: 0;
  transition: opacity 800ms var(--ease-out, ease);
  /* 14px over half of --motion-loop is 8.75px/s. The source floats 9px over
   * 2.75s — 3.3px/s — under this system's 8px/s floor, where motion reads as a
   * fault rather than as life. Same gesture, fast enough to be one, and no new
   * period: --motion-loop already exists. */
  animation: st-float var(--motion-loop) var(--ease-out, ease) infinite;
}

.st-fold-globe.is-on ~ .st-fold-stat { opacity: 0.72; }

@keyframes st-float {
  0%, 100% { translate: 0 0; }
  50% { translate: 0 -14px; }
}

.st-fold-stat b { color: var(--text-body); font-weight: 700; }
.st-fold-stat em { color: var(--ss-up); font-weight: 700; font-style: normal; }

.st-fold-dot {
  position: absolute;
  left: 14px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--ss-up);
  box-shadow: 0 0 9px rgb(63 157 107 / 0.6);
}

.st-fold-dot--below { bottom: -16px; }
.st-fold-dot--above { top: -16px; }

/* Ringed around the globe rather than scattered across a page-wide splash.
 *
 * Every left is POSITIVE, and that is load-bearing. Two of them used to hang
 * off the left edge at -4% and -8%, which was clear of the copy while the box
 * was 44% of the hero; at 57% the same percentages resolved 30-60px further
 * left and parked a chip on the last line of the lede. A chip that overlaps
 * running text is not ambience, it is a defect. Keep them on the sphere.
 *
 * Negative delays so the five are already out of phase on the first frame. */
.st-fold-stat--a { top: 8%; left: 16%; animation-delay: -1s; }
.st-fold-stat--b { top: 18%; left: 62%; animation-delay: -3.2s; }
.st-fold-stat--c { top: 74%; left: 56%; animation-delay: -2.4s; }
.st-fold-stat--d { top: 66%; left: 8%; animation-delay: -4.4s; }
.st-fold-stat--e { top: 88%; left: 30%; animation-delay: -5.8s; }

/* Below the breakpoint the section artwork already retires at, the hero is one
 * column and a globe pinned to the right has nothing to be beside. It drops
 * under the copy at full width, and the two chips furthest from the globe go —
 * five labels around a small sphere on a phone is a crowd. */
@media (max-width: 980px) {
  .st-hero-art {
    position: relative;
    top: auto;
    right: auto;
    translate: none;
    width: min(100%, 26rem);
    margin: clamp(1.5rem, 4vh, 2.5rem) auto 0;
  }

  /* The 30% the owner asked for was for the desktop composition, where the
   * sphere has room to hold bigger labels. At 375 the same chips run past the
   * right edge and get cut by the hero's overflow clip — a half a chip reads
   * as breakage, not as depth. They go back to the smaller step here, and the
   * two on the right come in far enough to clear the edge at that size. */
  .st-fold-stat {
    padding: 0.38rem 0.66rem;
    font-size: var(--text-xs);
  }

  .st-fold-stat--a { left: 2%; }
  .st-fold-stat--b { left: 42%; }
  .st-fold-stat--c { left: 40%; }
  .st-fold-stat--d { left: 0; }
  .st-fold-stat--e { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .st-fold-stat { animation: none; }
  .st-name-caret { animation: none; }
}

.ss-band {
  /* How far the ripple is allowed out of this section. It has to be a share of
   * the WIDTH, not the height: .rp-art covers by `slice`, so at 1440 the art
   * renders 966 tall in a ~530 band and overhangs ~217px, but at 2560 it scales
   * 1.78x and overhangs ~593. A height-based margin was short by 11px at 1920
   * and by 100px at 2560 — the rings came back cut at exactly the widths a
   * desktop reader has. 40vw clears it everywhere with room to spare, and
   * clip-path on .rp-art is what actually decides where the rings stop. */
  --rp-bleed: 40vw;

  position: relative;
  isolation: isolate;
  overflow: clip;
  overflow-clip-margin: var(--rp-bleed);
  /* Full-bleed. `.sd-block` siblings pad themselves off --gutter and sit in the
   * document flow at full width already, so the band only has to drop the
   * gutter and take the whole measure. */
  padding: clamp(4.5rem, 12vh, 9rem) var(--gutter);
  background: radial-gradient(ellipse 64% 80% at 50% 46.6%, var(--ss-core) 0%, var(--ss-mid) 48%, var(--ss-edge) 100%);
  text-align: center;
}

.ss-band > * {
  position: relative;
  z-index: 1;
}

.rp-art {
  position: absolute;
  /* The rings are bigger than the band and were being sliced flat where the
   * two sections meet. `overflow: visible` on the SVG lets the family finish
   * outside its own viewport, and .ss-band's `overflow: clip` carries a margin
   * that lets it out of the section too.
   *
   * That releases the rings in BOTH directions, and downward is wrong: below
   * the band the ground is the page's near-black and the arcs ran straight
   * through the next section's headline. Chrome has no per-side clip margin
   * (overflow-clip-margin-top is unsupported as of 148), so the mask does it:
   * sized one bleed taller than the box and shifted up by exactly that, it
   * covers the whole upward overrun and simply STOPS at the band's bottom
   * edge — with no-repeat, anything past the mask is not painted at all. The
   * last 12% fades so the rings dissolve into the seam rather than ending on
   * a line. Change the 34svh here and the one in .ss-band together. */
  overflow: visible;
  /* A NEGATIVE inset, which is the whole trick: the region starts one bleed
   * above the box and ends at its bottom edge, so the rings finish upward into
   * the teal and stop dead at the seam.
   *
   * Not a mask. A mask was tried first and cannot do this — mask-clip defaults
   * to border-box, so the mask re-clipped away the very overflow it was added
   * to shape, and `mask-clip: no-clip` did not restore it in Chrome 148.
   * clip-path takes a region instead of clipping to the box, so a negative
   * inset simply works. Same --rp-bleed as the section's clip margin, so the
   * two cannot drift: the margin lets the rings out, this decides where they
   * stop. */
  clip-path: inset(calc(-1 * var(--rp-bleed)) 0 0 0);
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* One hairline, five weights. The artwork's rings sit only 2–6 luminance steps
 * above their own background — they are a texture in the ground, not a diagram
 * drawn on top of it, and anything heavier turns the band into a target. */
.rp-ring {
  fill: none;
  stroke: #ffffff;
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

.rp-f1 { stroke-opacity: 0.16; }
.rp-f2 { stroke-opacity: 0.13; }
.rp-f3 { stroke-opacity: 0.1; }
.rp-f4 { stroke-opacity: 0.08; }
.rp-f5 { stroke-opacity: 0.11; }

.ss-band-mark {
  display: block;
  width: auto;
  height: clamp(2rem, 4.2vw, 3.1rem);
  margin: 0 auto clamp(1.1rem, 2.6vh, 1.8rem);
}

.ss-band h2 {
  max-width: 22ch;
  margin: 0 auto clamp(1rem, 2.4vh, 1.5rem);
  font-size: var(--text-2xl);
  font-weight: 700;
  line-height: 1.25;
  letter-spacing: -0.015em;
  text-wrap: balance;
  color: var(--text-hi);
}

.ss-band p {
  max-width: 62ch;
  margin: 0 auto;
  font-size: var(--text-base);
  line-height: var(--lh-body, 1.6);
  color: var(--text-body);
}

.ss-band p strong {
  font-weight: 600;
  color: var(--text-hi);
}

/* A one-column mega-menu. `.nav-menu` lays out three tracks of at least 13.5rem
 * because the Service card fills all three; the Product card holds one list, and
 * on the shared rule it would open a panel two-thirds empty. Same component,
 * same card, one track. */
.nav-menu--one {
  grid-template-columns: minmax(15rem, max-content);
}

/* ══ the laptop in the ShopSCAPE hero ═════════════════════════════════════
 * A MacBook shell with the live Pantip MALL storefront inside it, and the
 * screen genuinely scrolls — the page it shows is 2400px tall behind a 577px
 * aperture, so there is real travel in there.
 *
 * The aperture percentages are measured off the shell, not guessed: scanning
 * the mockup for its flat grey placeholder puts the screen at 208,152 by
 * 888x577 on its 1300x922 canvas. Expressed as percentages they survive every
 * width the shell is rendered at, which a pixel offset would not.
 *
 * The shell's own screen is painted flat #0b0f14 in the asset. That is not a
 * saving, it is a safeguard: this mockup shipped with "SHOWCASE YOUR INCREDIBLE
 * WORK IN THIS MOCKUP — Designdeck.io / DOWNLOAD NOW" across its screen, and if
 * the storefront image were ever slow or missing, that advert is what a visitor
 * would read on our hero. Now the worst case is a dark screen.
 *
 * This replaced an earlier mockups-design.com shell whose white background had
 * to be flood-filled away; this one ships with real alpha, so nothing is cut. */
.mb {
  position: relative;
  width: 100%;
  /* 60rem, not 40. This cap — not the desktop `width` — is what actually sized
   * the laptop: `min(58%, 50rem)` resolved to 800px and then this clamped it to
   * 640. Raising the width alone would have changed nothing on screen. */
  max-width: 60rem;
  margin-inline: auto;
}

.mb-shell {
  display: block;
  width: 100%;
  height: auto;
}

.mb-screen {
  position: absolute;
  left: 16%;
  top: 16.486%;
  width: 68.308%;
  height: 62.581%;
  /* No scrolling by hand: the page inside moves on its own, so the box is a
   * window, not a control. */
  overflow: hidden;
  background: #0b0f14;
  /* The screen's corners are rounded by ~10px on a 888px-wide aperture. Given
   * as two percentages — of the width and of the height — so the curve stays
   * circular at every size instead of stretching into an oval, which a single
   * percentage on a 1.54:1 box would do. */
  border-radius: 1.13% / 1.73%;
  /* Thin, because a laptop screen showing a web page is exactly where a
   * scrollbar belongs — hiding it would remove the only cue that the thing
   * scrolls at all. */
}

/* The notch. It is part of the shell artwork, but it sits INSIDE the screen
 * rectangle, so painting that rectangle out took it with it — and the storefront
 * would cover it anyway. Redrawn here over the top, outside the scrolling box so
 * it stays put while the page inside moves, which is how a real one behaves.
 * 110x20 on the 888x577 aperture, dead centre. */
.mb::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 16.486%;
  translate: -50% 0;
  width: 8.46%;
  height: 2.17%;
  border-radius: 0 0 0.6rem 0.6rem;
  background: #0b0f14;
  pointer-events: none;
}

.mb-screen img {
  display: block;
  width: 100%;
  height: auto;
  /* 61.01% is the whole page, not a guess: the aperture is 888x577 of the
   * shell, the storefront is drawn to that width so its height becomes 1480,
   * and 1 - 577/1480 is exactly how far it has to travel for its last row to
   * reach the bottom of the screen. Alternating, so it browses down and back up
   * rather than snapping to the top; the flat holds at each end are the pause a
   * reader takes before scrolling on. */
  animation: mb-browse 26s ease-in-out infinite alternate;
}

@keyframes mb-browse {
  0%, 6% { translate: 0 0; }
  94%, 100% { translate: 0 -61.01%; }
}

/* Nothing on this site moves for someone who has asked it not to. The storefront
 * rests at the top, which is the part with the brand and the campaigns on it. */
@media (prefers-reduced-motion: reduce) {
  .mb-screen img {
    animation: none;
  }
}

/* Desktop: the laptop takes the right half, opposite the copy. Below this
 * breakpoint the hero is one column and the laptop follows the text instead of
 * fighting it.
 *
 * 1400 is arithmetic, and it has moved once already. At 640px wide the laptop
 * cleared the copy from about 1140px up, so the rule said 1200. At 960px it does
 * not: measured at a 1200 viewport the machine ran from x 540 while the copy
 * reached 736 — a 196px overlap. The copy's right edge is pinned near 747 by
 * --measure-lede whatever the window does, so side-by-side needs 747 + a 40px
 * gap + 960 = 1747px of right edge, and with the 25% bleed that arrives at a
 * 1398px viewport. Resize the laptop again and this number moves again. */
@media (min-width: 1400px) {
  /* The window has to do the cropping, or the bleed below just makes the
   * document 350px wider than the viewport and the whole page pans sideways —
   * which is not a crop, it is a laptop you have to go looking for.
   *
   * `clip`, not `hidden`, because `hidden` would make the hero a scroll
   * container and this hero holds a `position: sticky` jump nav. Measured both
   * ways here and the nav behaves identically — it is not sticking on this page
   * for a reason that predates the laptop — so the choice is insurance, not a
   * fix. Horizontal axis only: the reveals move vertically. */
  .sd-hero:has(.mb) {
    overflow-x: clip;
  }

  .sd-hero .mb {
    position: absolute;
    /* Off the right edge on purpose. A laptop cropped by the window reads as a
     * screen you are standing next to; the same laptop sitting whole in the
     * margin reads as a product shot.
     *
     * The bleed is derived, not chosen. The widest thing in the left column
     * reaches ~747px, so a 960px machine has to start past ~787 to keep a 40px
     * gap — which puts 22rem of it beyond the right edge at the 1400 where this
     * rule starts.
     *
     * In rem, NOT the percentage this used to be. `right` resolves a percentage
     * against the HERO, not the laptop, so -25% grew with the window: measured
     * 350px off at 1400 and 480px at 1920, which cropped half the machine on a
     * wide monitor while claiming in this very comment to stay proportional.
     * The shell is a flat 60rem up here, so a flat 22rem crops the same 37% of
     * it at every width. */
    right: -22rem;
    bottom: clamp(1rem, 3vh, 3rem);
    width: min(87%, 60rem);
    margin-inline: 0;
  }
}

@media (max-width: 1399px) {
  .mb {
    margin-top: clamp(2rem, 5vh, 3rem);
  }
}

/* Between 761 and 1399 the laptop is stacked BELOW the copy, not beside it —
 * but the shared hero rule is still holding the lede to 52% to leave room for
 * artwork that is not there, so the copy runs at 416px inside a 900px hero with
 * 434px of nothing next to it.
 *
 * That rule's own reasoning is "the lede stops before the artwork starts", and
 * below 761 it already releases the measure because the art is hidden. This is
 * the same case one breakpoint up, and it is scoped to this page: the platform
 * pages DO have art beside the copy at these widths and must keep the hold. */
@media (min-width: 761px) and (max-width: 1399px) {
  .ss-brand .sd-hero .sd-hero-sub {
    max-width: var(--measure-lede);
  }
}


/* At >=1400 the laptop is out of flow on the right, and the hero's jump nav is
 * the one block still running the full width underneath it: measured at 1400,
 * the last pill reaches 908 while the laptop starts at 786. The nav is also
 * sticky with z-index var(--z-nav), so it wins the paint — the pill is not
 * merely close to the laptop, it is drawn on top of its screen, and it drags a
 * 6px backdrop blur across the storefront every time the hero scrolls past.
 *
 * Held to the same measure as the lede, so the whole left column — heading,
 * lede, proof strip, CTAs, jump nav — stops at one edge and the laptop has the
 * other side to itself. */
@media (min-width: 1400px) {
  .ss-brand .sd-hero .svc-jump {
    max-width: min(var(--measure-lede), 52%);
  }
}

/* ── ShopSCAPE hero · the feature marquee ─────────────────────────────────
 * Eighteen rows of commerce icons drifting past each other behind the hero. The
 * page's argument is that this is one platform carrying a great many features;
 * the field says "many" without a number, on a page where every number has to
 * be sourced before it can be printed.
 *
 * Built as ground, not as content. One masked strip tiles across every row —
 * .ss-mq-track is a coloured box wearing the icon sheet as a mask, which is why
 * the teal lives here in the stylesheet and not baked into the asset. Twelve
 * rows of individual icons would have been about 1,200 elements; this is 37. */
.ss-mq {
  /* Row height, and through it everything else. The strip is 72 square cells,
   * so at `mask-size: auto 100%` one tile is exactly 72 rows wide — that is the
   * distance a row must travel to loop seamlessly, and the only place the 72
   * appears twice is here and in the generator that emits the cells.
   *
   * 3.25rem gives a 31px icon in a 52px row — the owner's size, and the row gap
   * comes free with it because the strip insets each icon to 60% of its cell.
   * The field is NOT stretched to the hero any more: twelve rows are 624px and
   * they start at the headline, so what sets the height is the count. */
  --mq-row: 3.25rem;
  --mq-tile: calc(var(--mq-row) * 72);

  /* Shown at every width now, but not in the same place. Above 1400 the hero is
   * two columns and this is the right-hand one, beside the copy. Below that the
   * hero stacks — copy, then the laptop under it — and the empty ground moves
   * with it, so the field moves too; see the media query at the end of this
   * block. Hiding it below 1400 was right only while the mask was written for
   * the two-column case. */
  position: absolute;
  /* No `top`. An absolutely positioned box with `top: auto` sits at its static
   * position — where it would have been in the flow — and in the markup it is
   * placed between the eyebrow and the h1. So the first row lines up with the
   * first line of the headline, and stays lined up when the hero's roof changes
   * with the viewport (its padding is a 5vh clamp, so a fixed offset here would
   * be right at one window height and wrong at every other).
   *
   * No `bottom` either: the height is twelve rows and nothing else. The field
   * runs from the headline to just short of the CTA and stops. */
  left: 0;
  right: 0;
  overflow: hidden;
  /* Faint on purpose, and the number is measured rather than picked.
   *
   * ⚠ The h1 above is ALSO #056163 (see --ss-title and the 2.86:1 note there),
   * so where a field icon passes behind the headline the headline is teal on
   * teal. Sampled with the headline hidden, at this strength the worst ground
   * under it is rgb(3 23 32) — the h1 reads 2.52:1 there instead of 2.86:1, and
   * only 3.2% of the headline's band is lifted off the page ground at all.
   * Dimming barely buys anything back: 0.38 gives 2.63:1 and 0.30 gives 2.68:1,
   * both at roughly half the field. So the field stays and the cost is written
   * down. Like the heading colour itself, this is an owner brand call on a page
   * that is noindex and pre-G3. */
  opacity: 0.55;

  /* The two fades the composition needs.
   *
   * Left to right: NOTHING until past the copy. The reading column's right edge
   * measures 747px at a 1400 window and 995px at 1920 — 52% both times, because
   * --measure-lede caps it — so the mask holds fully transparent to 54% and only
   * reaches full at 88%. The owner's instruction was that it must not run as far
   * as the text; a ramp that merely STARTS at the text still prints a ghost
   * through the last words of every line, which is what the first cut did.
   *
   * Top to bottom: barely any fade at the top, because the top is now an
   * ALIGNMENT — it meets the headline, and a 100px ramp there would blur the
   * one edge that is supposed to read as deliberate. The bottom still dissolves,
   * because nothing lines up with it. Neither edge clips an icon in half: the
   * box is a whole number of whole rows. */
  /* Three layers, and the order is the argument.
   *
   * 1 · the horizontal hold — nothing until past the reading column.
   * 2 · ADDED to it, a full-width release for the bottom of the field. The
   *     reason for the hold is the copy, and the copy ends at about 88% of this
   *     field's height (measured: the jump nav's last row ends at 966 of
   *     139..1075 at 1440, and 982 of 151..1087 at 1920 — 88.4% both times, so
   *     the release starts at 89 and not a point earlier; at 84 the icons came
   *     up behind the CTA buttons and the nav pills, which is the exact thing
   *     the hold exists for). Below that there is nothing to keep clear of, so
   *     the field takes
   *     the whole row and carries into the section below at full width instead
   *     of leaving the left half of the seam as black.
   * 3 · INTERSECTED with both, the vertical envelope, so it still arrives and
   *     leaves rather than starting on a line. Its hold runs to 92% now, not
   *     74%: the release above happens in the last tenth of the field, and an
   *     envelope already fading there would have released a band nobody could
   *     see. */
  /* ORDER MATTERS, and not in the direction it reads. Mask layers composite
   * from the BOTTOM of the list upward, and each layer's operator applies to
   * everything beneath it — so the envelope has to be listed FIRST to be the
   * one doing the intersecting. Written the other way round, its `add` unioned
   * the envelope in with the rest and the field released across the whole hero:
   * icons behind the CTA buttons and the nav pills, which is the exact thing
   * the horizontal hold exists to prevent.
   *
   * Bottom up, what this says is: (the horizontal hold OR the bottom release)
   * AND the vertical envelope. */
  mask-image:
    linear-gradient(to bottom, transparent 0%, rgb(0 0 0) 4%, rgb(0 0 0) 92%, transparent 100%),
    linear-gradient(to right, transparent 0%, transparent 54%, rgb(0 0 0) 88%),
    linear-gradient(to bottom, transparent 89%, rgb(0 0 0) 99%);
  mask-composite: intersect, add, add;
}

/* The bridge between the hero and the rings.
 *
 * The hero ends on a field of drifting icons; the rings' stage opened on flat
 * --navy-0 with its own words still at zero opacity, so the two sections met on
 * a hard edge with nothing carrying across it — measured at 1440, the stage was
 * painting over the marquee's last 27px and then 300px of empty black.
 *
 * Same component, eight rows, pinned to the top of that stage.
 *
 * FULL WIDTH, unlike the field above it. That field is weighted to the right
 * because the reading column is on the left and icons must not print through
 * it; down here the copy has ended, so the reason has gone with it and the
 * whole row is the owner's — a band that stopped at 54% left the left half of
 * the seam as empty black. The vertical envelope is kept: it still has to
 * arrive and leave rather than start on a line. */
.ss-mq--bridge {
  inset: 0 0 auto 0;
  height: calc(var(--mq-row) * 8);
  /* SCALED, not replaced. The base rule carries 0.55 and a first cut set this
   * to the bare ramp, which took the band to full strength — a wall of teal
   * icons on black where the hero has a texture. */
  opacity: calc(0.55 * clamp(0, 1 - var(--circ-p, 0) / 0.1, 1));
  mask-image: linear-gradient(to bottom, rgb(0 0 0) 0%, rgb(0 0 0) 58%, transparent 100%);
}

@media (max-width: 1399px) {
  /* The base rule's narrow-window move does not apply here — this band is the
   * seam, not the hero's spare ground. */
  .ss-mq--bridge {
    inset: 0 0 auto 0;
    height: calc(var(--mq-row) * 8);
  }
}

@media (prefers-reduced-motion: reduce) {
  /* The stage stops being a pinned box there and becomes two columns of text;
   * an absolutely positioned band over them is not a seam, it is a smear. */
  .ss-mq--bridge {
    display: none;
  }
}

/* Below 1400 the hero is one column: the copy takes its full 991px measure back
 * and there is no right-hand ground to stand on — but there IS empty ground
 * under it, where the laptop stacks. So the field stops being a column beside
 * the reading and becomes a band behind that laptop: pinned to the hero's
 * bottom, full width, and with no horizontal fade because there is no copy
 * beside it to keep clear of.
 *
 * The height is capped rather than left to the row count, or on a tall phone
 * the band climbs back up into the paragraphs it was moved out of. */
@media (max-width: 1399px) {
  .ss-mq {
    inset: auto 0 0 0;
    /* Bounded twice, because one bound is not enough. The band has to start
     * below where the copy ends, and the room between them is content-sized: at
     * 390 the copy runs to y=1073 of a 1434-tall hero, leaving 361px. A share of
     * the viewport alone gets that wrong on a tall phone — 42svh of 1180px is
     * 496px, which put the band 99px INTO the last paragraph. The rem cap is
     * what holds there; the svh cap is what keeps it in proportion on a short
     * one. Checked at 1200x900, 1024x800, 768x1024, 390x844 and 390x1180. */
    height: min(42svh, 22rem);
    mask-image: linear-gradient(to bottom, transparent 0%, rgb(0 0 0) 22%, rgb(0 0 0) 82%, transparent 100%);
  }
}

.ss-mq-row {
  height: var(--mq-row);
}

.ss-mq-track {
  display: block;
  height: 100%;
  /* One tile wider than the row, because that is exactly how far it travels.
   * Any narrower and the row runs out of icons before the loop closes. */
  width: calc(100% + var(--mq-tile));
  background-color: var(--ss-edge);
  mask-image: url("/assets/products/icon-strip.svg");
  mask-repeat: repeat-x;
  mask-size: auto 100%;
  animation: ss-mq-run var(--drift-3) linear infinite;
}

@keyframes ss-mq-run {
  to {
    translate: calc(var(--mq-tile) * -1) 0;
  }
}

/* Speed, sense and phase come from a row's POSITION, so the markup stays twelve
 * identical rows. Durations are the system's drift tokens — new periods are not
 * allowed, and none is needed: at a 3744px tile these run 34, 22 and 14 px/s,
 * all clear of the 8px/s floor an ambient layer has to beat to be worth the
 * repaints.
 *
 * The three cycles are coprime — 3 durations, 2 senses, 7 phases — so the
 * pattern does not close until row 42 and no two of the twelve rows run in
 * lockstep. */
.ss-mq-row:nth-child(3n + 1) .ss-mq-track { animation-duration: var(--drift-2); }
.ss-mq-row:nth-child(3n + 2) .ss-mq-track { animation-duration: var(--drift-3); }
.ss-mq-row:nth-child(3n) .ss-mq-track { animation-duration: var(--drift-4); }

/* Alternate rows run against each other. This is the whole point of the field:
 * a single direction reads as one sheet sliding, two directions read as many
 * independent things in motion. */
.ss-mq-row:nth-child(even) .ss-mq-track { animation-direction: reverse; }

.ss-mq-row:nth-child(7n + 1) .ss-mq-track { animation-delay: -6s; }
.ss-mq-row:nth-child(7n + 2) .ss-mq-track { animation-delay: -13s; }
.ss-mq-row:nth-child(7n + 3) .ss-mq-track { animation-delay: -22s; }
.ss-mq-row:nth-child(7n + 4) .ss-mq-track { animation-delay: -31s; }
.ss-mq-row:nth-child(7n + 5) .ss-mq-track { animation-delay: -44s; }
.ss-mq-row:nth-child(7n + 6) .ss-mq-track { animation-delay: -57s; }
.ss-mq-row:nth-child(7n) .ss-mq-track { animation-delay: -68s; }

/* Stopped for anyone who asked for stillness — and stopped HERE rather than
 * relying on a global reset, because .ss-mq-row:nth-child(...) .ss-mq-track is
 * (0,3,0) and would out-specify it. The field stays: it is a texture, and a
 * still texture is still a texture. */
@media (prefers-reduced-motion: reduce) {
  .ss-mq-row .ss-mq-track {
    animation: none;
  }
}

/* ── ShopSCAPE · the capability rings ─────────────────────────────────────
 * Nineteen capabilities on two very large, counter-turning circles between the
 * hero and the branding band — Thai on the left, English on the right. Ported
 * from the Codrops circle (tympanus.net/Tutorials/3DTextScroll, demo 2).
 *
 * Flat, not 3D: a word sits at (cos, sin) x radius from a centre that is mostly
 * off screen, and is rotated to the tangent. Scrolling turns the circle. The
 * per-word position depends on the viewport, so app.js publishes --x/--y/--r
 * per word; nothing here is a magic transform.
 *
 * The runway-plus-sticky-stage structure is kept from the cylinder this
 * replaced, for two reasons the reference does not have to care about: it is
 * what lets the ground finish its turn to teal before the band arrives, and it
 * is what makes the seam with that band exact. */
.circ {
  /* The scroll the rings actually turn through is this less one screen. Both
   * circles rotate half a turn over it, which is exactly the span the words
   * occupy, so every one of the nineteen passes the reading line once. */
  height: 320svh;
  position: relative;
}

.circ-stage {
  position: sticky;
  top: 0;
  height: 100svh;
  overflow: hidden;
  /* No background of its own. It used to paint a flat --navy-0, and the page
   * behind it is a gradient — measured at the seam, the hero ended on
   * rgb(3 6 18) and this opened on rgb(1 2 9), a 9-step line straight across
   * the page. Letting the page's own ground show through removes it, and costs
   * nothing: the teal below is an overlay, and by the time it is opaque the
   * ground underneath has stopped mattering. */
  background: transparent;
}

/* The ground turning teal, and the seam with the band below.
 *
 * This is the BAND's own gradient with the ellipse pushed one stage-height
 * down, which makes the two meet exactly, at every x, without either box
 * knowing the other's height. That falls out of the geometry: the band centres
 * its ellipse at 46.6% of its height with a vertical radius of 80%, so its top
 * edge always sits at 46.6/80 = 0.5825 of a radius from the centre — a RATIO,
 * so the band's real height cancels out. Repeat the same ellipse here at
 * `50% 146.6%` and this stage's bottom edge lands on that identical 0.5825.
 * Measured before it was done this way: the stage ended flat on rgb(5 97 99)
 * while the band opened on rgb(4 78 80) at its centre — a 19-step wall. */
.circ-stage::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse 64% 80% at 50% 146.6%, var(--ss-core) 0%, var(--ss-mid) 48%, var(--ss-edge) 100%);
  /* Black for most of the section, on the owner's call: the rings read as part
   * of the page they arrived from, and the turn to teal is saved for the last
   * stretch so it lands as the handover to the band rather than as the
   * section's opening move.
   *
   * Nothing before 76%, full by 96% — finishing a little early, not exactly at
   * the end, so the band is never met mid-fade. That is 44svh of scroll to make
   * the turn in, which is about half a screen. */
  opacity: calc((var(--circ-p, 0) - 0.76) / 0.2);
  pointer-events: none;
}

/* NOT --p. The reveal system registers that name with `@property`, and
 * registers it `inherits: false` with an initial value of 1 — so a
 * pseudo-element or a child reading --p gets 1 no matter what this was set to.
 * That failed silently once already: the ground was fully teal before the
 * section had started. */

/* The mark the rings turn around. It does not turn with them: it is the fixed
 * thing they are about, and a spinning logo is a different page's idea. */
.circ-mark {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 2;
  /* A fifth larger than the port's first size, on the owner's call. */
  width: clamp(9.6rem, 18vw, 18rem);
  height: auto;
  translate: -50% -50%;
  /* …and growing as the section runs, so the mark is not simply parked in the
   * middle. Centred on 1 across the stretch it is actually visible: 0.88 as it
   * fades in, about 1.02 halfway, 1.12 as it leaves — so the width above is the
   * size it reads at, not a maximum it never reaches. */
  scale: calc(0.88 + 0.28 * var(--circ-p, 0));
  /* In early, out before the end. The band below carries this same lockup, and
   * the owner does not want the page holding two of them at once — so this one
   * is gone by 84%, which is while the ground is still turning teal and well
   * before the stage unpins and the band comes up.
   *
   * `min` of the two ramps: the rising one wins at the start, the falling one
   * at the end. Both run past 0 and 1 at their far ends and opacity clamps them,
   * so neither needs its own hold. */
  opacity: calc(0.9 * clamp(0, min(var(--circ-p, 0) / 0.1, (0.84 - var(--circ-p, 0)) / 0.14), 1));
  pointer-events: none;
}

.circ-ring {
  position: absolute;
  inset: 0;
  z-index: 1;
  margin: 0;
  padding: 0;
  list-style: none;
}

.circ-word {
  position: absolute;
  top: 0;
  left: 0;
  /* Each word is a fixed-width label laid along the arc, so a long one wraps
   * inside its own box instead of stretching the circle's spacing. The floor is
   * what keeps the two columns apart on a phone: at 390 the columns sit 117px
   * and 273px in, so a box wider than about 120px has them meeting in the
   * middle. */
  width: clamp(8.75rem, 25vw, 25rem);
  font-weight: 700;
  /* A quarter larger than the port's first size, on the owner's call. The box
   * above grew by the same quarter so the words wrap where they did rather than
   * a line earlier — and its floor is what keeps the two columns from meeting
   * on a phone, where they sit 117px and 273px in. */
  font-size: clamp(1.2rem, 2.4vw, 2.4rem);
  line-height: 1.1;
  letter-spacing: -0.01em;
  color: var(--ink);
  /* In with the mark, and out after it. Without the first the section pinned
   * with every word already at full strength against black, which read as the
   * words being switched on rather than arriving.
   *
   * The exit is later and shorter than the mark's — gone by 98%, so the order
   * the reader sees is: mark leaves, words leave, band arrives. Each word has
   * already crossed the reading line by then; the rings turn half a turn, which
   * is exactly one pass each, so nothing is hidden that was not read. */
  opacity: clamp(0, min(var(--circ-p, 0) / 0.1, (0.98 - var(--circ-p, 0)) / 0.12), 1);
  /* The order matters and matches what the reference's tween composes to:
   * travel to the point on the circle, then centre the box on it, then turn to
   * the tangent. */
  transform:
    translate(var(--x, 0px), var(--y, 0px))
    translate(-50%, -50%)
    rotate(var(--r, 0deg));
}

/* Helvetica for the Latin ring — the owner's call, and the one place on this
 * site that leaves --font-sans. Nothing is fetched for it: it is whatever the
 * reader's machine has, real Helvetica on Apple platforms and Arial on Windows,
 * which is metrically compatible. */
.circ-ring--en .circ-word {
  font-family: Helvetica, "Helvetica Neue", Arial, var(--font-sans);
  text-align: right;
}

/* IBM Plex Sans Thai Light for the Thai ring, self-hosted — see the @font-face
 * below. 300 is the weight the owner asked for, so this ring does NOT take the
 * 700 above; Plex Thai has no bold in the file we ship and faux-bolding a Thai
 * face smears the loops shut. */
.circ-ring--th .circ-word {
  font-family: "IBM Plex Sans Thai", var(--font-sans);
  font-weight: 300;
  text-align: left;
}

/* Thai needs the room: its ascenders and descenders stack two levels above and
 * below the baseline, and 1.1 clips them. */
.circ-ring--th .circ-word {
  line-height: 1.45;
}

/* No rings for anyone who asked for stillness — and no 320svh of empty runway
 * either, which is what hiding the motion alone would leave behind. The words
 * are the product's capability list, so they become the two lists they always
 * were, side by side. */
@media (prefers-reduced-motion: reduce) {
  .circ {
    height: auto;
  }

  .circ-stage {
    position: static;
    height: auto;
    display: grid;
    gap: clamp(1rem, 4vw, 3rem);
    padding: clamp(3.5rem, 9vh, 6rem) var(--gutter);
    overflow: visible;
  }

  .circ-stage::before {
    opacity: 1;
  }

  .circ-mark {
    position: static;
    justify-self: center;
    translate: none;
    /* The script never runs here, so --circ-p is never published: the ramp above
     * would resolve to 0 and the growth to 0.88. The mark would be missing, and
     * undersized if it were not. */
    opacity: 0.9;
    scale: 1;
  }

  .circ-ring {
    position: static;
    display: grid;
    gap: 0.5rem;
    align-content: start;
  }

  .circ-word {
    position: static;
    width: auto;
    font-size: clamp(1rem, 2.2vw, 1.35rem);
    transform: none;
    /* The script never runs here, so --circ-p is never published and the ramp
     * above would resolve to 0. These are thirty-eight capabilities: they are
     * the section, and they must not vanish because the effect is off. */
    opacity: 1;
  }

  .circ-ring--en .circ-word {
    text-align: left;
  }

  @media (min-width: 761px) {
    .circ-stage {
      grid-template-columns: 1fr 1fr;
      align-items: start;
    }

    /* …and the mark spans both of them rather than sitting in one. */
    .circ-mark {
      grid-column: 1 / -1;
    }
  }
}

/* ── ShopSCAPE · section artwork ──────────────────────────────────────────
 * The small drawing beside each section heading, emitted by
 * scripts/gen-section-art.mjs. Measured before anything was drawn: every
 * .sd-block on that page leaves the same slot — 668px of clear width starting
 * at x=772 of 1440, about 240px tall before the cards begin — so this is one
 * component thirteen times.
 *
 * The generator emits geometry and class names only. Everything below is what
 * those names mean. */
.sd-art {
  position: absolute;
  top: var(--section-y);
  right: var(--gutter);
  /* The slot, as a share of the section rather than a fixed size: the copy
   * column is held to its own measure, so what is left scales with the page. */
  width: min(42%, 34rem);
  pointer-events: none;
}

.sd-block:has(.sd-art) {
  position: relative;
}

/* By its own name, the way .rp-art and .hero-art are: the generator names the
 * drawing and the stylesheet answers to that name. Styling it as `.sd-art svg`
 * left the emitted class meaning nothing. */
.sa-art {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}

/* One hairline, four weights, and the same reasoning the band's ripple runs
 * under: these are a texture in the ground, not a diagram drawn on top of the
 * page. Anything heavier and the drawing starts competing with the headline it
 * is meant to sit beside. */
.sa-l,
.sa-r {
  fill: none;
  stroke: var(--blue-300);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.sa-d {
  fill: var(--blue-300);
  stroke: none;
}

.sa-b {
  fill: none;
  stroke: var(--blue-300);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

.sa-f1 { opacity: 0.5; }
.sa-f2 { opacity: 0.28; }
.sa-f3 { opacity: 0.16; }
.sa-f4 { opacity: 0.62; }

/* The ecommerce icons the drawings are built from. Same set the hero's marquee
 * runs on, embedded per motif by the generator — so, like everything else here,
 * they arrive with no colour and take it from this rule. */
.sa-i {
  fill: var(--blue-300);
}

/* ── motion ──────────────────────────────────────────────────────────────
 * Three kinds, and every one of them is light rather than travel.
 *
 * The system's ambient rule is >= 8px/s or stay still, and every mark here is
 * 20-200px across: a drawing this size that turned would be buying repaints for
 * movement nobody can see, which is the exact case that rule exists to forbid.
 * The fan reached the same conclusion and moves light across a still field
 * instead. These do the same, three ways — which is what makes it read as more
 * happening without anything actually creeping across the screen.
 *
 * 1 · sa-pulse — one accent breathing, the entry point of the drawing.
 * 2 · sa-flow  — a dash running along the line that carries the meaning: the
 *                bus, the circuit, the stream into the data spine. This IS
 *                travel and it is fast: the dash covers the whole path length
 *                each cycle, which on a 700px circuit is about 90px/s.
 * 3 · sa-lit   — the icons lighting in sequence, so the drawing reads left to
 *                right the way its sentence does. */
.sa-pulse {
  transform-box: fill-box;
  transform-origin: center;
  animation: sa-breathe calc(var(--motion-loop) * 2) ease-in-out infinite;
}

@keyframes sa-breathe {
  0%, 100% { opacity: 0.5; scale: 1; }
  50% { opacity: 1; scale: 1.9; }
}

.sa-flow {
  stroke-dasharray: 5 27;
  animation: sa-flow calc(var(--motion-loop) * 2.5) linear infinite;
}

@keyframes sa-flow {
  to { stroke-dashoffset: -128; }
}

/* Five phases off one keyframe set. Delays, not periods — the same rule the
 * ambient system applies everywhere else: variety comes from phase, never from
 * inventing a second duration. */
.sa-lit {
  animation: sa-glow calc(var(--motion-loop) * 3) ease-in-out infinite;
}

.sa-lit-1 { animation: sa-glow calc(var(--motion-loop) * 3) ease-in-out infinite; }
.sa-lit-2 { animation: sa-glow calc(var(--motion-loop) * 3) ease-in-out infinite -1.9s; }
.sa-lit-3 { animation: sa-glow calc(var(--motion-loop) * 3) ease-in-out infinite -3.8s; }
.sa-lit-4 { animation: sa-glow calc(var(--motion-loop) * 3) ease-in-out infinite -5.7s; }
.sa-lit-5 { animation: sa-glow calc(var(--motion-loop) * 3) ease-in-out infinite -7.6s; }

@keyframes sa-glow {
  0%, 62%, 100% { opacity: 0.3; }
  22% { opacity: 0.95; }
}

/* Below the width where the copy takes the whole column there is no slot left
 * to draw in — the same 981 the rest of this page's two-column thinking uses.
 * Hidden, not shrunk: a 300px-wide version of these would be a smudge. */
@media (max-width: 980px) {
  .sd-art {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  /* All three, and the dash pattern goes with the flow — a line left mid-dash
   * is not a still drawing, it is a broken one. */
  .sa-pulse,
  .sa-flow,
  .sa-lit-1,
  .sa-lit-2,
  .sa-lit-3,
  .sa-lit-4,
  .sa-lit-5 {
    animation: none;
  }

  .sa-flow {
    stroke-dasharray: none;
  }
}

/* ── Where to go next ────────────────────────────────────────────────────────
 *
 * A plain directory into the 30+ pages the homepage teases but never linked.
 * Deliberately not another card grid: the sections above it already sell, and a
 * reader who has scrolled this far wants the list, not a fourth pitch. Columns
 * follow the primary nav's own groups so the two cannot tell different stories.
 *
 * Sized off the same tokens as the footer's columns, which is the closest thing
 * on the site to this shape — but scoped to .dir, because the footer's are laid
 * out for the bottom of the page rather than the middle of it. */
.dir {
  padding: clamp(3rem, 7vh, 5rem) var(--gutter) clamp(2rem, 5vh, 3.5rem);
}

.dir-head {
  max-width: var(--measure-lede);
  margin: 0 0 clamp(2rem, 5vh, 3rem);
}

.dir-head h2 {
  margin: 0;
  font-size: var(--text-2xl);
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--text-hi);
}

.dir-lede {
  margin: 0.75rem 0 0;
  font-size: var(--text-base);
  line-height: var(--lh-body, 1.6);
  color: var(--text-dim);
}

.dir-cols {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: clamp(1.5rem, 3vw, 2.75rem);
}

/* Shape, weight and colour come from the shared eyebrow rule above — joining it
 * rather than restating it is what keeps the hue-on-text budget at 40. Only the
 * two things that genuinely differ live here. */
.dir-title {
  margin-bottom: 0.9rem;
  text-transform: uppercase;
}

/* The Platforms column carries a second list under a second title. */
.dir-col ul + .dir-title {
  margin-top: 1.75rem;
}

.dir-col ul {
  margin: 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: 0.85rem;
  justify-items: start;
}

.dir-col a {
  color: var(--ink);
  opacity: 0.88;
  text-decoration: none;
  font-size: var(--text-base);
  transition: opacity 0.15s ease;
}

.dir-col a:hover,
.dir-col a:focus-visible {
  opacity: 1;
}

@media (max-width: 900px) {
  .dir-cols {
    grid-template-columns: repeat(2, minmax(0, 1fr));
    row-gap: 2.25rem;
  }
}

@media (max-width: 420px) {
  .dir-cols {
    grid-template-columns: 1fr;
  }
}

/* ── Scaffolding, hidden ──────────────────────────────────────────────────────
 *
 * `.sd-unverified` and `.sd-soon` mark copy that is NOT ready: a claim nobody
 * has confirmed, or a thing that exists but is not published yet. They used to
 * render as dashed amber notices reading "PLACEHOLDER — …", which is right while
 * a page is being written and wrong the moment anyone wants to look at the site.
 *
 * So they are hidden rather than deleted. The markup stays because it is the
 * only record of what still has to be collected — `docs/placeholders.md` is
 * generated from these exact blocks (`node scripts/placeholders.mjs`), and 47 of
 * them are open right now. Delete one and the question it was holding is gone.
 *
 * `display: none` on purpose, not `visibility` or opacity: this must leave the
 * accessibility tree too. A screen reader announcing "PLACEHOLDER — retail client
 * names need G4 consent" is the same leak as printing it on the page.
 *
 * TO SEE THEM AGAIN while writing: comment out this one rule. Nothing else
 * changes — every block is still exactly where it was.
 *
 * This does NOT lift the publishing gate. Every page carrying one of these is
 * `noindex, nofollow`, and tests/site/placeholders.test.mjs is what keeps that
 * true: hiding the notice must never be mistaken for answering the question. */
/* !important, and it is not laziness.
 *
 * This is a publishing gate, not a style: everything wearing these classes is
 * a claim nobody has confirmed or a thing that is not live yet. It was written
 * as a plain `display: none` at (0,1,0), which any component rule can match —
 * and /contact did, with `.ct-direct-row { display: grid }` in a stylesheet
 * that loads second. An unconfirmed press address went on the page and looked
 * exactly like the confirmed ones above it.
 *
 * A gate that a later stylesheet can out-rank by accident is not a gate. */
.sd-unverified,
.sd-soon {
  display: none !important;
}

/* ── Newsletter result page ──────────────────────────────────────────────
 * Where a footer sign-up lands when JavaScript is off (src/cms/newsletter.mjs
 * renders it). Everything on it is an existing component — the eyebrow and
 * the primary pill — so this is only the column they stand in. */
.news-result {
  display: grid;
  gap: 1.1rem;
  justify-items: start;
  max-width: 34rem;
  margin: 0 auto;
  padding: clamp(4rem, 16vh, 9rem) 1.25rem 4rem;
}

.news-result-home {
  display: inline-flex;
  margin-bottom: 1.5rem;
}

.news-result h1 {
  margin: 0;
  font-size: var(--text-3xl);
  font-weight: 600;
  line-height: 1.15;
  text-wrap: balance;
}

.news-result p {
  margin: 0;
}

.news-result-text {
  max-width: 46ch;
  font-size: var(--text-lg);
  line-height: 1.55;
  color: var(--text-body);
}
