/* ===========================================================================
   WaftTech v2 · design tokens
   Same palette + type as v1, promoted into one editable source of truth.
   Markup stays inline-styled (v1 authoring pattern) but every colour,
   radius, easing and measure resolves through a var() defined here.
   ======================================================================== */

:root {
  /* --- surfaces ------------------------------------------------------- */
  --paper:      #FFFFFF;
  --mist:       #F4F6F8;
  --ink:        #111111;
  --ink-2:      #1C1C1C;
  --ink-3:      #2A2A2A;

  /* --- dark panel surface ----------------------------------------------
     v2 originally used flat --ink for every dark panel. On a white page a
     pure-black block reads as a hole rather than a surface, and it fights the
     light editorial feel of the rest of the site.

     These stops are chosen, not picked by eye: brand cyan is only 4.5:1 on
     text once the panel gets lighter than about #0C3560, so the ramp stops
     there. Anything lighter and every cyan eyebrow label fails AA.
     The cyan sheen sits top-right, away from the left-aligned labels. */
  --navy-900:   #06182B;
  --navy-800:   #0A2540;
  --navy-700:   #0C3560;
  --surface-dark:
    radial-gradient(120% 85% at 100% 0%, rgba(19, 173, 225, .20), transparent 62%),
    linear-gradient(152deg, var(--navy-900) 0%, var(--navy-800) 48%, var(--navy-700) 100%);
  /* small blocks that sit ON a dark panel — reads as a recessed tile */
  --surface-dark-tile: rgba(4, 20, 36, .46);

  /* Text tints for use ON --surface-dark only. The panel's lightest point
     (sheen over --navy-700) is around #0D4A72, and the brand cyan only reaches
     3.8:1 there — under AA. A lifted cyan holds 5.2:1 and, on a blue-navy
     field, reads brighter and cleaner than the mid cyan ever did. */
  --cyan-on-dark: #5AD3F5;

  /* --- brand ---------------------------------------------------------- */
  --cyan:       #13ADE1;   /* brand cyan — graphics, and text on dark (7.3:1) */
  --blue:       #0E5DAE;
  /* The brand cyan only reaches 2.6:1 on white, so small text on light
     surfaces uses a deepened ink variant of the same hue (5.1:1 on white,
     4.8:1 on --mist). Overridden back to full cyan inside [data-dark]. */
  --cyan-ink:   #0A769C;
  --cyan-text:  var(--cyan-ink);
  --cyan-10:    rgba(19, 173, 225, .10);
  --cyan-16:    rgba(19, 173, 225, .16);
  --cyan-24:    rgba(19, 173, 225, .24);
  --blue-10:    rgba(14, 93, 174, .10);
  --blue-16:    rgba(14, 93, 174, .16);
  --blue-32:    rgba(14, 93, 174, .32);

  /* --- hairlines ------------------------------------------------------ */
  --line:       rgba(17, 17, 17, .14);
  --line-soft:  rgba(17, 17, 17, .08);
  --line-faint: rgba(17, 17, 17, .05);
  --line-dark:  rgba(255, 255, 255, .14);
  --line-dark-soft: rgba(255, 255, 255, .07);

  /* --- text ----------------------------------------------------------- */
  --fg:         #111111;
  --muted:      rgba(17, 17, 17, .68);
  --muted-2:    rgba(17, 17, 17, .62);
  --fg-dark:    #FFFFFF;
  --muted-dark: rgba(255, 255, 255, .72);
  --muted-dark-2: rgba(255, 255, 255, .60);

  /* --- type ----------------------------------------------------------- */
  --f-display: 'Red Hat Display', system-ui, -apple-system, sans-serif;
  --f-text:    'Red Hat Text', system-ui, -apple-system, sans-serif;
  --f-mono:    'Red Hat Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  /* --- type scale (editorial ramp) ------------------------------------ */
  --t-mega:  clamp(56px, 13vw, 216px);   /* statement / "Let's talk"   */
  --t-hero:  clamp(46px, 7.2vw, 118px);  /* page h1                   */
  --t-d1:    clamp(38px, 5vw, 78px);     /* section h2                */
  --t-d2:    clamp(30px, 3.6vw, 56px);   /* feature h2 / card h2      */
  --t-lead:  clamp(21px, 2vw, 30px);     /* lead paragraph            */
  --t-body:  clamp(16px, 1.15vw, 18px);  /* body                      */
  --t-small: 15px;
  --t-mono:  12px;

  /* --- layout --------------------------------------------------------- */
  --maxw:  1320px;
  --maxw-wide: 1400px;
  --pad:   clamp(20px, 5vw, 64px);
  --pad-x: clamp(12px, 3vw, 32px);
  --gut:   clamp(40px, 6vw, 96px);       /* section-internal gutter   */
  --band:  clamp(88px, 11vw, 176px);     /* section vertical padding  */
  --band-s: clamp(56px, 7vw, 104px);     /* compact band              */

  /* --- radii ---------------------------------------------------------- */
  --r-xs:  10px;
  --r-sm:  18px;
  --r:     28px;
  --r-lg:  clamp(24px, 3vw, 40px);
  --r-pill: 999px;

  /* --- motion --------------------------------------------------------- */
  --ease: cubic-bezier(.2, .7, .2, 1);
  --dur:   .45s;

  /* --- elevation ------------------------------------------------------ */
  --lift:  0 30px 60px -40px rgba(14, 93, 174, .6);
  --lift-d: 0 24px 60px -34px rgba(0, 0, 0, .7);

  color-scheme: light;
}

/* Dark panels get their own text ramp — see --cyan-on-dark for why. */
[data-dark] {
  --cyan-text: var(--cyan-on-dark);
  --muted-dark: rgba(255, 255, 255, .82);
  --muted-dark-2: rgba(255, 255, 255, .72);
}

/* ===========================================================================
   Base
   ======================================================================== */

* { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

/* The dc runtime ships `html, body, #dc-root { height: 100% }`, which pins the
   component host to exactly one viewport while its content overflows. Chrome's
   full-page screenshot expands the viewport to the document height, so the bug
   hides there — but in a normal window the site footer, appended to <body> as a
   sibling of #dc-root, lands at y = 100vh and paints over the first screen.
   v1 has the same defect (its footer sits at exactly 700px).
   These pages are documents, not a fixed-viewport canvas, so let them grow. */
html, body { height: auto; }
#dc-root, #dc-root > .sc-host { height: auto; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--fg);
  font-family: var(--f-text);
  font-size: var(--t-body);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; }
img, svg { display: block; max-width: 100%; }

::selection { background: var(--cyan); color: #fff; }

/* Anything hash-targeted clears the fixed nav pill. */
[id] { scroll-margin-top: 112px; }

/* ===========================================================================
   Focus — v1 had none. Visible for keyboards only.
   ======================================================================== */

:focus { outline: none; }

:focus-visible {
  outline: 2px solid var(--cyan);
  outline-offset: 3px;
  border-radius: 4px;
}

.on-dark :focus-visible,
[data-dark] :focus-visible { outline-color: #fff; }

/* ===========================================================================
   Skip link — v1 had none.
   ======================================================================== */

.skip {
  position: fixed;
  top: 8px;
  left: 8px;
  z-index: 10050;
  transform: translateY(-160%);
  padding: 12px 20px;
  border-radius: var(--r-pill);
  background: var(--ink);
  color: #fff;
  font: 500 12px/1 var(--f-text);
  letter-spacing: .14em;
  text-transform: uppercase;
  transition: transform .25s var(--ease);
}

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

/* ===========================================================================
   Shared textures
   Two hairline layers that tie the light and dark bands together.
   ======================================================================== */

/* blueprint grid — 44px cells, 4% ink / 5% white */
.grid-ink,
.grid-paper {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image:
    linear-gradient(to right, var(--grid-c) 1px, transparent 1px),
    linear-gradient(to bottom, var(--grid-c) 1px, transparent 1px);
  background-size: 44px 44px;
  -webkit-mask-image: radial-gradient(120% 90% at 78% 12%, #000 0%, transparent 72%);
          mask-image: radial-gradient(120% 90% at 78% 12%, #000 0%, transparent 72%);
}

.grid-ink  { --grid-c: rgba(17, 17, 17, .045); }
.grid-paper { --grid-c: rgba(255, 255, 255, .05); }

/* dot matrix */
.dots {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: radial-gradient(currentColor 1px, transparent 1px);
  background-size: 22px 22px;
  opacity: .5;
}

/* ===========================================================================
   Hairline primitives used across every section
   ======================================================================== */

.rule { height: 1px; background: var(--line); border: 0; margin: 0; }
.rule-dark { height: 1px; background: var(--line-dark); border: 0; margin: 0; }

/* the 28px cyan tick that opens every section */
.tick {
  flex: none;
  width: 28px;
  height: 1px;
  background: var(--cyan);
}

/* ===========================================================================
   Reveal — the [data-reveal] opt-in the site JS watches.
   Elements are hidden by JS only, so a JS failure still shows content.
   ======================================================================== */

[data-reveal] { will-change: opacity, transform; }

/* Line-mask variant: each .lm span slides up out of its own clip box.
   The negative margin gives ascenders and descenders room inside the mask
   without adding height to the layout — without it, a tight line-height
   shears the tops off capitals and the tails off descenders. Room is needed on
   BOTH edges: overflow:hidden clips the ascender side just as hard as the
   descender side, which is what the mega "Let's talk" line hit. */
.lm {
  display: block;
  overflow: hidden;
  padding: .14em 0 .22em;
  margin: -.14em 0 -.22em;
}

.lm > span { display: block; }

/* A link that slides in on hover must also come in on keyboard focus —
   otherwise it stays off-screen while holding tab focus. */
.slide-up { transition: transform .5s var(--ease); }
.slide-up:focus-visible { transform: none; }

/* ===========================================================================
   Marquee — client strip + ticker
   ======================================================================== */

.marquee {
  position: relative;
  display: flex;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
          mask-image: linear-gradient(to right, transparent, #000 8%, #000 92%, transparent);
}

.marquee__track {
  display: flex;
  flex: none;
  align-items: center;
  gap: var(--mq-gap, 72px);
  padding-right: var(--mq-gap, 72px);
  min-width: 100%;
  animation: mq var(--mq-dur, 46s) linear infinite;
}

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

@keyframes mq { to { transform: translateX(-100%); } }

/* ===========================================================================
   Buttons
   ======================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  height: 56px;
  padding: 0 28px;
  border: 0;
  border-radius: var(--r-pill);
  background: var(--blue);
  color: #fff;
  font: 500 13px/1 var(--f-text);
  letter-spacing: .14em;
  text-transform: uppercase;
  cursor: pointer;
  transition: background .25s var(--ease), transform .25s var(--ease);
}

/* Buttons light up cyan on hover, and the label flips to deep navy. White on
   cyan is only 2.6:1 - it fails AA - so the text has to change with the plate,
   not stay white. Navy on cyan is 6.9:1. */
.btn:hover { background: var(--cyan); color: var(--navy-900); }

.btn--ghost {
  background: transparent;
  color: var(--fg);
  box-shadow: inset 0 0 0 1px var(--line);
}

.btn--ghost:hover { background: transparent; box-shadow: inset 0 0 0 1px var(--cyan); }

/* Outline button on a dark plate. Same reason as the disc above: the inline
   colour and ring would beat a :hover rule, and a `style-hover` attribute is
   stripped by the runtime before it reaches the DOM, so it never fired. */
.btn--ghost-dark {
  background: transparent;
  color: #fff;
  box-shadow: inset 0 0 0 1px var(--line-dark);
}
.btn--ghost-dark:hover {
  background: transparent;
  color: var(--cyan-text);
  box-shadow: inset 0 0 0 1px var(--cyan);
}

/* underlined text link */
.ulink {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 0 4px;
  font: 500 13px/1.4 var(--f-text);
  letter-spacing: .14em;
  text-transform: uppercase;
  border-bottom: 1px solid var(--line);
  transition: color .25s var(--ease), border-color .25s var(--ease);
}

.ulink:hover { color: var(--cyan-text); border-bottom-color: var(--cyan-text); }

.ulink--dark { border-bottom-color: var(--line-dark); }
.ulink--dark:hover { color: var(--cyan-text); }

/* ===========================================================================
   Hover vocabulary
   ---------------------------------------------------------------------------
   v1 declared hover states with a `style-hover="..."` attribute. The dc
   runtime strips unknown attributes off the template before render, so those
   rules never reached the DOM — all of v1's hover states are dead. v2 authors
   hovers as classes here instead, and keeps the visual intent 1:1.

   Combine freely: class="btn hv-fill"
   ======================================================================== */

/* text -> cyan */
/* Every hover that recolours TEXT uses --cyan-text, never --cyan.
   --cyan is the brand fill: it reaches just 3.8:1 on the navy panels and
   2.6:1 against white text on itself. --cyan-text is the surface-aware tint -
   deep enough to read on a light panel, lifted to #5AD3F5 inside [data-dark] -
   so the same rule is legible wherever the link sits. Use raw --cyan for
   backgrounds, rings and borders, which are not held to text contrast. */
.hv-a:hover { color: var(--cyan-text); }

/* Reveal-colour links sit inside a card or list, not on a coloured plate, so
   they follow the surface they are on. */
.hv-line:hover { color: var(--cyan-text); border-bottom-color: var(--cyan-text); }

/* Text sitting on a --blue plate. The tint has to be chosen against the plate,
   not the page: these anchors carry `color: white` inline (specificity 1,0,0,0)
   and an over-broad `.hv-fill:hover { color: #fff }` was inheriting to the
   accent word inside them, which turned it white on white. That rule is now
   scoped to `<button class="hv-fill">` where the text really is a direct child.
   The tint is a colour-mix so it tracks --cyan instead of drifting. */
/* Text sitting on a plate that lights up cyan. The accent word cannot stay
   cyan-on-cyan once the plate changes, so it flips to navy with everything
   else. Inline colours would beat a plain rule, hence the attributed selector. */
.hv-fill:hover { background: var(--cyan); }
button.hv-fill:hover { color: var(--navy-900); }

/* The accent word carries an inline colour, so only !important wins. Scoped to
   the hover state on purpose - it is the one place inline must be beaten. */
.hv-disc:hover .on-plate,
.btn:hover .on-plate { color: var(--navy-900) !important; }

/* solid → ink (for light-on-dark buttons) */
.hv-fill-ink:hover { background: var(--ink); }

/* lift */
.hv-lift { transition: transform var(--dur) var(--ease); }
.hv-lift:hover { transform: translateY(-6px); }

/* transparent → blue, with cyan text */
.hv-invert:hover { background: var(--blue); color: #fff; }

/* inset ring → cyan. Uses the surface tint because a plain .hv-ring target is
   an outline button on a light panel. */
/* inset ring → cyan. Uses --blue for the label, not the surface tint: this
   targets an outline button, and the closing-CTA card sits it on a cyan-tinted
   panel where --cyan-text only reaches 4.07:1. --blue holds ~5.5:1 there and
   6.5:1 on plain white. */
.hv-ring:hover { box-shadow: inset 0 0 0 1px var(--cyan-text); color: var(--blue); }
.hv-ring-dark:hover { box-shadow: inset 0 0 0 1px var(--cyan); color: var(--cyan-text); }

/* media zoom */
.hv-img { overflow: hidden; }
.hv-img > * { transition: transform .7s var(--ease); }
.hv-img:hover > * { transform: scale(1.05); }

/* circular icon button: ink fill + rotate on the glyph */
.hv-orb:hover { background: var(--cyan); color: #fff; }
.hv-orb:hover .hv-orb-glyph { transform: rotate(45deg); }
.hv-orb-glyph { display: block; transition: transform .4s var(--ease); }

/* row arrow slides right, title tints */
.hv-row:hover .hv-row-arrow { transform: translate(6px, -6px); color: var(--cyan-text); }
.hv-row:hover .hv-row-title { color: var(--cyan-text); }
.hv-row-arrow { display: block; transition: transform .4s var(--ease), color .4s var(--ease); }
.hv-row-title { transition: color .3s var(--ease); }

/* horizontal rule that draws in from the left */
.hv-draw { position: relative; }
.hv-draw::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  height: 1px;
  width: 100%;
  background: var(--cyan);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform .55s var(--ease);
}
.hv-draw:hover::after { transform: scaleX(1); }

/* hairline cell: background wash + ink→blue arrow chip */
.hv-cell:hover { background: var(--paper); }
.hv-cell-chip { transition: background .3s var(--ease), color .3s var(--ease); }
.hv-cell:hover .hv-cell-chip { background: var(--cyan); color: #fff; }

/* big circle CTA: cyan fill, arrow nudges */
.hv-disc:hover { background: var(--cyan); color: #fff; }
.hv-disc-arrow { transition: transform .4s var(--ease); }
.hv-disc:hover .hv-disc-arrow { transform: translateX(6px); }

/* ===========================================================================
   Our Works — a hairline logo wall with a hover reveal.
   ---------------------------------------------------------------------------
   The marks never change and the wall never moves on its own — a client list
   you have to wait out stops reading as a list. The interest has to come from
   what a single hover does, so the cell carries three layers that arrive
   together:

     ::before     a cursor-tracked cyan wash, clipped by the cell
     .works-rail  a cyan hairline that draws across the cell's top edge
     .works-mark  the mark itself lifting out of grey

   An earlier pass also revealed the domain and an arrow chip beneath the mark.
   Both are gone: the artwork is the content, and repeating it as text under
   the logo just restates it in a smaller, less legible font. The cell is a
   real link, so the domain survives where it is actually needed — the
   accessible name.

   `max-width: 46%` keeps a wide wordmark from filling its cell and a tall
   lockup from touching the border above it; `object-fit: contain` then
   preserves the artwork rather than cropping it into a fixed box.
   ======================================================================== */

.works-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  border-top: 1px solid var(--line);
  border-left: 1px solid var(--line);
}

.works-cell {
  position: relative;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(14px, 1.6vw, 20px);
  min-width: 0;
  padding: clamp(34px, 4vw, 56px) clamp(16px, 2vw, 28px);
  border-right: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  overflow: hidden;
  transition: background .35s var(--ease);
}

/* Painted children need to sit above the wash, or the logo greys out under it. */
.works-cell > * { position: relative; z-index: 1; }

.works-cell::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(
    240px circle at var(--mx, 50%) var(--my, 50%),
    var(--cyan-16),
    rgba(19, 173, 225, .05) 46%,
    transparent 70%
  );
  transition: opacity .4s var(--ease);
}

.works-cell:hover::before,
.works-cell:focus-visible::before { opacity: 1; }

/* The rail sits on the cell's own top border and draws from the left, the same
   vocabulary as `.hv-draw` on the services rows. */
.works-rail {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 2;
  height: 2px;
  width: 100%;
  background: var(--cyan);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform .5s var(--ease);
}

.works-cell:hover .works-rail,
.works-cell:focus-visible .works-rail { transform: scaleX(1); }

.works-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 46%;
  min-width: 0;
}

.works-mark img {
  display: block;
  width: 100%;
  height: auto;
  max-height: clamp(40px, 4vw, 52px);
  object-fit: contain;
  opacity: .82;
  filter: grayscale(1);
  transition: opacity .35s var(--ease), filter .35s var(--ease), transform .5s var(--ease);
}

.works-cell:hover .works-mark img,
.works-cell:focus-visible .works-mark img {
  opacity: 1;
  filter: grayscale(0);
  transform: scale(1.05);
}

/* ---------------------------------------------------------------------------
   Hover motion
   ---------------------------------------------------------------------------
   A one-shot lift on the mark itself, so re-entering a cell replays it. A CSS
   animation rather than a class-toggled transition on purpose: a transition
   cannot re-run while the pointer stays inside the cell. Only `transform`,
   `opacity` and `filter` animate, all compositor-friendly, and only one cell
   animates at a time because it takes the pointer to start one.

   works-swap.js adds and removes `.is-swapping`; without it the section is
   still a complete, fully legible wall. */

@keyframes works-mark-in {
  from { opacity: .82; transform: scale(.94); }
  to   { opacity: 1;   transform: scale(1.05); }
}

/* No fill-mode on purpose. The resting state must come from :hover, not from a
   keyframe held open by a class that may still be set — works-swap.js clears
   `.is-swapping` on a timer because `animationend` does not fire when the
   document is not being rendered. */
.works-cell.is-swapping .works-mark img {
  animation: works-mark-in .5s var(--ease);
}

@media (max-width: 1000px) {
  .works-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .works-mark { width: 56%; }
}

@media (max-width: 700px) {
  .works-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .works-cell { padding: 28px 12px; gap: 12px; }
  .works-mark { width: 66%; }
}

/* No hover on touch, and the spotlight would otherwise sit pinned at the cell
   centre reading as a smudge. */
@media (hover: none) {
  .works-cell::before { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .works-rail { display: none; }
  .works-cell:hover .works-mark img { transform: none; }
}

/* ===========================================================================
   Dark panel — the blueprint grid drifts, top-right to bottom-left, on a loop
   ---------------------------------------------------------------------------
   Two decisions make it read as the pattern travelling rather than the panel
   sliding:

   `background-position` is animated instead of `transform`. Translating the
   element would drag its radial mask along with it, so the fade would move too
   and the whole panel would look like it was shifting rather than the lines.

   The layer is bled 26px past the panel (passed in by the page, because
   `WX_ART.grid()` writes `inset` inline and an inline value outranks this
   rule). Without the bleed the offset would leave an unpainted strip along the
   top and left edges.

   The travel is one full 44px cell, so the loop closes seamlessly — there is no
   visible cut where it restarts.

   `background-position` is not compositor-only the way `transform` is, so this
   repaints one decorative hairline layer per frame. The alternative (a
   transformed wrapper around a static child) costs an extra full-panel layer
   and paints the same pixels, so it is not worth it here.

   Dropped under reduced motion, where the panel keeps its static grid.
   ======================================================================== */

/* The travel must be exactly one cell — 44px — or the pattern lands somewhere
   other than it started and the loop shows a cut as it restarts. 22px either
   side of zero is one full cell; the layer's 26px bleed is what covers the
   offset, and it only has to be greater than or equal to it.

   6s, not 22s. The first pass moved one cell in 22s, which is 2px per second —
   it was animating correctly and was still impossible to see, because a 6%
   hairline grid has almost no signal to follow at that speed. One cell in 6s
   is ~7px/s, which reads as a slow drift rather than a jump. */
@keyframes wx-grid-drift {
  from { background-position: 22px -22px; }
  to   { background-position: -22px 22px; }
}

.wx-panel-dark .wx-panel-grid {
  animation: wx-grid-drift 6s linear infinite;
}

/* A cyan sheen travelling the same diagonal. This is what actually makes the
   movement legible — the grid alone is too low-contrast to follow, which is
   exactly the problem the 6s fix only partly solves.

   `inset: -40%` makes the layer 180% of the panel, so a 22% translate puts its
   centre on the panel's far corner: +22%/-22% starts top-right, -22%/+22%
   ends bottom-left. Same two numbers as the grid travel, which is why they
   match.

   It fades to zero at both ends, so the reset from bottom-left back to
   top-right is never seen. Opacity and transform only, so it stays on the
   compositor. */
@keyframes wx-panel-sheen {
  0%   { transform: translate3d(22%, -22%, 0); opacity: 0; }
  15%  { opacity: 1; }
  85%  { opacity: 1; }
  100% { transform: translate3d(-22%, 22%, 0); opacity: 0; }
}

.wx-panel-dark::after {
  content: "";
  position: absolute;
  inset: -40%;
  z-index: 0;
  pointer-events: none;
  /* .09, not .17. This panel's contrast budget is already spent: --surface-dark
     lays a .20 cyan sheen over the navy ramp, and the panel's lightest point is
     where the 12px `--cyan-text` labels have the least room. Compositing a
     second cyan wash there at .17 dropped them to 4.06:1 — under AA. At .09
     they hold 4.53:1, and white holds 7.88:1. The extra visibility over the
     grid comes from travelling across a large dark area, not from being
     brighter. */
  background: radial-gradient(circle at 50% 50%,
    rgba(19, 173, 225, .09) 0%,
    rgba(19, 173, 225, .035) 38%,
    transparent 68%);
  animation: wx-panel-sheen 6s cubic-bezier(.45, 0, .55, 1) infinite;
}

/* The sheen paints over the grid, so the text needs to sit above it. */
.wx-panel-dark > * { position: relative; z-index: 1; }

@media (prefers-reduced-motion: reduce) {
  .wx-panel-dark .wx-panel-grid { animation: none; }
  .wx-panel-dark::after { animation: none; opacity: 0; }
}

/* ===========================================================================
   Services accordion panel
   ---------------------------------------------------------------------------
   This used to sit inside `<sc-if>`, so opening a row inserted a brand new
   element — and a freshly inserted element has no previous height or opacity to
   transition from. The result was a snap. It is now always mounted and
   animates between two real states.

   Height animates via the `grid-template-rows: 0fr -> 1fr` trick rather than
   `max-height`. A max-height has to be guessed high enough to clear the content,
   so the tail of the movement is spent easing through empty space at a visibly
   wrong rate, and it needs re-guessing whenever the copy changes length. The 0fr
   trick measures the real content height and interpolates to it.

   `overflow: hidden` has to sit on the *inner* wrapper, not on the grid item:
   the grid item is the thing being sized, and clipping it would clip against
   the 0fr track rather than the content.
   ======================================================================== */

.svc-panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows .5s cubic-bezier(.16, .84, .28, 1);
}

.svc-panel[data-open="true"] {
  grid-template-rows: 1fr;
}

.svc-panel__inner {
  overflow: hidden;
  min-height: 0;
  opacity: 0;
  transform: translateY(-6px);
  transition:
    opacity .34s var(--ease),
    transform .5s cubic-bezier(.16, .84, .28, 1);
}

/* The padding lives here rather than on `.svc-panel__inner` because a 0fr grid
   track only collapses the content it clips — padding on the clipped box itself
   is outside the clip and still occupies space. Measured: with the padding on
   the inner element, a collapsed panel sat at 44px instead of 0, which is a
   visible gap under every closed row. On a nested child it is clipped along
   with everything else. */
.svc-panel__body {
  padding: 0 0 clamp(28px, 3.4vw, 48px) calc(clamp(40px, 5vw, 72px) + clamp(12px, 2vw, 28px));
  display: flex;
  flex-direction: column;
  gap: 22px;
}

.svc-panel[data-open="true"] .svc-panel__inner {
  opacity: 1;
  transform: none;
}

/* The opacity and transform run slightly ahead of the height so the content is
   already legible as the panel opens rather than fading in behind a closing
   box. On close they are quicker than the height, so the box is not left
   visibly empty while the text finishes fading. */
.svc-panel[data-open="false"] .svc-panel__inner {
  transition:
    opacity .22s var(--ease),
    transform .28s var(--ease);
}

@media (prefers-reduced-motion: reduce) {
  .svc-panel,
  .svc-panel__inner { transition: none; }
}

/* ===========================================================================
   Where we are — office map
   ---------------------------------------------------------------------------
   Drawn as inline SVG rather than an embedded map iframe, for three reasons:
   it inherits the site's type and palette, it adds no third-party script or
   tracking beacon to a page that otherwise loads none, and it renders instantly
   with no network round-trip for tiles.

   That trade has a cost worth stating plainly: the land is a simplified outline,
   not survey data, and there is no zoom or pan. It answers "where are you" and
   nothing more, so each card links out to a real map. Do not add detail here
   expecting it to be trustworthy — take it from OpenStreetMap instead.

   Projection is plate carrée (equirectangular): longitude and latitude mapped
   linearly across a 2:1 viewBox, which is why the graticule is evenly spaced and
   the box is `0 0 1000 500`. Pins are placed from each city's real coordinates —
   x = (lon + 180) / 360 × 1000, y = (90 − lat) / 180 × 500. Kathmandu
   (27.6644N, 85.3188E) → 737,173 and Rockdale (33.8853S, 151.1934E) → 920,344.
   **Recompute both if an office moves; do not nudge them by eye.** The first
   draft hand-placed the pins at 700,226 and 846,392, which were not on the
   projection at all and in Rockdale's case fell outside the drawn coastline.

   `meet` rather than `slice`, so the map never crops: the panel is much wider
   than 2:1, and `slice` would zoom in and cut Kathmandu off the left edge
   entirely. Letterboxing is invisible because the SVG carries the same gradient
   as the panel behind it. */
.office-map {
  display: grid;
  grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
  gap: 1px;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--line);
  overflow: hidden;
}

.office-map__canvas {
  display: block;
  width: 100%;
  height: 100%;
  min-height: clamp(280px, 34vw, 420px);
  background: linear-gradient(158deg, #06182B 0%, #0A2540 52%, #0C3560 100%);
}

/* White at low alpha over the navy, so the map stays a backdrop and the office
   names are the only thing that reads as content on it. */
.office-map__grid path {
  fill: none;
  stroke: rgba(255, 255, 255, .07);
  stroke-width: 1;
}

.office-map__land path {
  fill: rgba(255, 255, 255, .055);
  stroke: rgba(255, 255, 255, .14);
  stroke-width: 1;
}

.office-map__link {
  fill: none;
  stroke: var(--cyan);
  stroke-width: 1.5;
  stroke-dasharray: 5 6;
  opacity: .75;
}

.office-map__dot {
  fill: var(--cyan);
  stroke: var(--navy-900);
  stroke-width: 1.5;
}

.office-map__label {
  fill: #fff;
  font: 500 13px/1 var(--f-text);
  letter-spacing: .01em;
}

/* One pulse per pin, offset so they never beat in sync. Transform and opacity
   only, so it stays on the compositor; `transform-box` puts the origin on the
   circle rather than the SVG viewport, otherwise the scale is taken from 0,0 and
   the ring flies across the map. */
.office-map__pulse {
  fill: none;
  stroke: var(--cyan);
  stroke-width: 1.5;
  transform-box: fill-box;
  transform-origin: center;
  animation: office-pulse 2.8s var(--ease) infinite;
}

.office-map__pin[data-office="sydney"] .office-map__pulse { animation-delay: -1.4s; }

@keyframes office-pulse {
  0%   { transform: scale(1);   opacity: .85; }
  70%  { transform: scale(2.6); opacity: 0; }
  100% { transform: scale(2.6); opacity: 0; }
}

.office-map__cards {
  display: flex;
  flex-direction: column;
  background: var(--paper);
}

.office-card {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: clamp(24px, 2.6vw, 36px);
}

.office-card + .office-card { border-top: 1px solid var(--line); }

.office-card__top {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
}

.office-card__city {
  margin: 0;
  font: 300 clamp(26px, 2.4vw, 34px)/1 var(--f-display);
  letter-spacing: -.025em;
}

.office-card__tz {
  flex: none;
  font: 400 12px/1 var(--f-mono);
  letter-spacing: .14em;
  color: var(--cyan-text);
}

.office-card__address {
  margin: 0;
  font: 400 15px/1.65 var(--f-text);
  color: var(--muted);
}

.office-card__links {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 20px;
  font: 400 15px/1.5 var(--f-text);
  color: var(--muted);
}

.office-card__time {
  margin-top: auto;
  padding-top: 8px;
  font: 400 12px/1 var(--f-mono);
  letter-spacing: .14em;
  color: var(--muted-2);
}

@media (max-width: 860px) {
  .office-map { grid-template-columns: minmax(0, 1fr); }
  .office-map__canvas { min-height: 220px; }
  .office-card + .office-card { border-top: 1px solid var(--line); border-left: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .office-map__pulse { animation: none; opacity: .5; }
}

/* ===========================================================================
   Insight card — cursor-tracked spotlight
   ---------------------------------------------------------------------------
   Two paint layers sit over the base card, and the card clips them with its own
   overflow:hidden, so the wash follows the cursor but can never bleed into the
   gap between cards or past the corner radius:

     ::before   radial gradient pinned to the pointer via --mx / --my, written
                by the motion layer in v2-site.js
     ::after    a soft corner wash that fades up on its own, so the surface
                still reads as changing with the cursor parked

   Only opacity, colour and border are transitioned. Transitioning the gradient
   position would make the light trail the pointer, which reads as lag rather
   than as polish, so --mx / --my are written with no transition at all.

   The label and title go to --blue rather than --cyan-text on hover, because
   the spotlight lifts the surface underneath them: --cyan-text only reaches
   ~4.4:1 on the washed background while --blue holds ~5.6:1. Same rule as the
   CTA card eyebrow. ========================================================= */

.post-card {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  min-height: 270px;
  border-radius: var(--r);
  border: 1px solid var(--line);
  background: var(--paper);
  padding: 28px;
  display: flex;
  flex-direction: column;
  gap: 18px;
  /* `transform` has to be named here, not just in `.hv-lift`. This rule comes
     after `.hv-lift` in the sheet and both declare `transition`, so this one
     replaces it wholesale — the card was snapping to its lifted position with no
     transition at all, which is what made the hover read as a jump.
     The lift itself is longer and softer than `.hv-lift`'s default 6px/.45s,
     because a 270px card moving 6px looks like a twitch at that scale. */
  transition:
    transform .62s cubic-bezier(.16, .84, .28, 1),
    border-color .4s var(--ease),
    box-shadow .4s var(--ease);
}

.post-card:hover,
.post-card:focus-visible {
  transform: translateY(-9px);
}

/* The glow is a positioned pseudo-element, so in-flow children would paint
   underneath it without this. */
.post-card > * { position: relative; z-index: 1; }

.post-card::before,
.post-card::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: 0;
  transition: opacity .45s var(--ease);
}

.post-card::before {
  background: radial-gradient(
    260px circle at var(--mx, 50%) var(--my, 50%),
    rgba(19, 173, 225, .17),
    rgba(19, 173, 225, .06) 45%,
    transparent 72%
  );
}

.post-card::after {
  background: linear-gradient(158deg, rgba(14, 93, 174, .08), rgba(19, 173, 225, .045) 46%, transparent 72%);
  transition: opacity .6s var(--ease);
}

.post-card:hover,
.post-card:focus-visible {
  border-color: rgba(19, 173, 225, .45);
  /* Grown to sit under a 9px lift — a shadow sized for no lift leaves the card
     looking like it is hovering with nothing beneath it. */
  box-shadow: 0 30px 60px -32px rgba(14, 93, 174, .6);
}

.post-card:hover::before,
.post-card:focus-visible::before,
.post-card:hover::after,
.post-card:focus-visible::after { opacity: 1; }

/* These live in CSS rather than inline so a :hover rule can reach them - an
   inline colour wins every time and would pin them at the resting tint. */
.post-card__date {
  font: 400 12px/1 var(--f-mono);
  letter-spacing: .14em;
  color: var(--cyan-text);
  transition: color .35s var(--ease);
}

.post-card__title {
  margin: 0;
  font: 400 clamp(21px, 1.8vw, 26px)/1.3 var(--f-display);
  letter-spacing: -.01em;
  text-wrap: balance;
  transition: color .35s var(--ease);
}

.post-card__more {
  margin-top: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  font: 400 13px/1 var(--f-text);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--muted-2);
  transition: color .35s var(--ease);
}

.post-card__more > span { transition: transform .35s var(--ease); }

.post-card:hover .post-card__date,
.post-card:hover .post-card__title,
.post-card:focus-visible .post-card__date,
.post-card:focus-visible .post-card__title { color: var(--blue); }

.post-card:hover .post-card__more,
.post-card:focus-visible .post-card__more { color: var(--ink); }

.post-card:hover .post-card__more > span,
.post-card:focus-visible .post-card__more > span { transform: translate(3px, -3px); }

/* Touch has no cursor to follow and :hover sticks after a tap. Reduced motion
   means no travelling light, so the spotlight is dropped rather than left
   pinned at the card centre where it would read as a stray smudge. */
@media (hover: none), (prefers-reduced-motion: reduce) {
  .post-card::before { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .post-card,
  .post-card::after,
  .post-card__date,
  .post-card__title,
  .post-card__more { transition: none; }
}

/* ===========================================================================
   Hero split
   ---------------------------------------------------------------------------
   The hero was `repeat(auto-fit, minmax(min(100%,460px),1fr))`. That needs
   460 + 460 + gap before it holds two columns, so it collapsed to one at
   around 980px - a very ordinary laptop width - and dropped the frame beneath
   the copy. An explicit fraction pair holds the side-by-side far longer, and
   only a real media query decides when to stack.
   ======================================================================== */

.wx-hero-split {
  display: grid;
  grid-template-columns: minmax(0, 1.04fr) minmax(0, .96fr);
  gap: clamp(32px, 4vw, 72px);
  align-items: center;
}

@media (max-width: 820px) {
  .wx-hero-split { grid-template-columns: minmax(0, 1fr); }
}

/* ===========================================================================
   Hero headline word
   ---------------------------------------------------------------------------
   The old word animates out and the new one is revealed underneath it. That is
   why this needs no timers and no class toggling: a CSS animation fires on
   mount, so it cannot be cancelled by the rotation timer landing on the same
   tick, which is exactly what broke the earlier setTimeout version. The new
   word sits in normal flow, so it defines the line box and the headline never
   reflows, however short the next word is.
   ======================================================================== */

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

.wx-word-out {
  position: absolute;
  left: 0;
  top: 0;
  pointer-events: none;
}

@keyframes wx-word-out-up {
  from { clip-path: inset(0 0 0 0); opacity: 1; transform: none; }
  to   { clip-path: inset(0 0 100% 0); opacity: 0; transform: translateY(-.10em); }
}

@keyframes wx-word-out-down {
  from { clip-path: inset(0 0 0 0); opacity: 1; transform: none; }
  to   { clip-path: inset(100% 0 0 0); opacity: 0; transform: translateY(.10em); }
}

[data-hero-dir="fwd"] .wx-word-out { animation: wx-word-out-up .58s var(--ease) forwards; }
[data-hero-dir="back"] .wx-word-out { animation: wx-word-out-down .58s var(--ease) forwards; }

/* ===========================================================================
   Hero media — the right-hand frame transitions with the cycling headline.
   ---------------------------------------------------------------------------
   Not a cross-fade. The outgoing layer closes like a shutter while scaling up
   and the incoming one opens, and which edge it opens from follows the
   direction you travelled through the words: forward and the new frame rises
   from the bottom edge, stepping back and it drops from the top. One
   transition doing the work a slide + zoom + wipe would need three for.

   Only transform and clip-path animate. Both are compositor-only, so a 940x540
   frame with five stacked layers stays cheap; a blur here would have looked
   similar and cost far more.
   ======================================================================== */

.wx-hero-media {
  position: absolute;
  inset: 0;
  opacity: 0;
  transform: scale(1.06);
  /* inset(top right bottom left) — bottom inset of 100% collapses the clip to
     a zero-height strip on the bottom edge, so opening reveals upward. */
  clip-path: inset(100% 0 0 0);
  transition:
    opacity .5s var(--ease),
    transform .85s var(--ease),
    clip-path .85s var(--ease);
  pointer-events: none;
}

/* stepping backwards, the frame arrives from the top edge instead */
[data-hero-dir="back"] .wx-hero-media {
  clip-path: inset(0 0 100% 0);
}

.wx-hero-media.on {
  opacity: 1;
  transform: none;
  clip-path: inset(0 0 0 0);
  will-change: transform, clip-path;
  /* property order is opacity, transform, clip-path — delaying the first one
     makes the fade trail the clip, so the frame reads as arriving rather than
     simply appearing */
  transition-delay: .12s, 0s, 0s;
}

/* Dropped clip sits over the CSS art, which stays as the fallback layer. */
.wx-hero-media video,
.wx-hero-media img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  border-radius: inherit;
}

/* ===========================================================================
   Closing CTA card
   ---------------------------------------------------------------------------
   Tinted panel: headline on the left, actions on the right, wrapping to a
   column on narrow screens. The wash is cyan rather than the reference's pink
   so it stays on palette; the layout is what was being asked for.
   ======================================================================== */

.cta-card {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: clamp(24px, 3.5vw, 48px);
  overflow: hidden;
  border-radius: var(--r-lg);
  border: 1px solid var(--line-soft);
  background:
    radial-gradient(85% 150% at 84% 50%, var(--cyan-16), transparent 64%),
    linear-gradient(112deg, var(--paper) 0%, var(--mist) 58%, var(--cyan-10) 100%);
  padding: clamp(28px, 4.2vw, 60px) clamp(24px, 4.2vw, 60px);
}

/* faint blueprint grid, same motif as the art frames */
.cta-card::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image:
    linear-gradient(to right, rgba(14, 93, 174, .05) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(14, 93, 174, .05) 1px, transparent 1px);
  background-size: 44px 44px;
  -webkit-mask-image: radial-gradient(70% 130% at 88% 50%, #000, transparent 70%);
          mask-image: radial-gradient(70% 130% at 88% 50%, #000, transparent 70%);
}

.cta-card > * { position: relative; }

.cta-card__actions {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}

@media (max-width: 760px) {
  .cta-card { flex-direction: column; align-items: stretch; }
  .cta-card__actions { width: 100%; }
}

/* ===========================================================================
   Custom cursor inherits the accent; hidden from assistive tech
   ======================================================================== */

.cursor {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 10001;
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border-radius: 50%;
  background: var(--blue);
  pointer-events: none;
  opacity: 0;
  will-change: transform;
  transition: width .3s var(--ease), height .3s var(--ease),
              margin .3s var(--ease), background .3s var(--ease), opacity .3s;
}

.cursor--on { opacity: 1; }
.cursor--link { background: var(--cyan-24); }

/* ======================================================================== */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
  .marquee__track { animation: none; }
}