/* Ubden® — cinematic FX layer.
 *
 * Loaded last, after animations.css, so rules here win by source order.
 * Colours must come from tokens.css (--fx-*) so dark and light both work;
 * never hardcode a hex here.
 *
 * Phase 0 ships this file intentionally empty of visual rules — it exists so
 * the asset pipeline, cache-busting and load order can be verified with no
 * change to how the site looks. Phase 1 onward fills the sections below.
 */

/* ---------- Fail-open contract ----------
 * Anything the FX layer hides before animating in MUST carry [data-fx-hidden]
 * so all three escape hatches below can reveal it. This mirrors the existing
 * contract at animations.css:118-139 — get it wrong and a visitor with the
 * GSAP CDN blocked sees a blank page.
 *
 * Note: main.js:153-156 adds .no-gsap when GSAP is missing *or* when reduced
 * motion is set, so that one selector already covers both cases. The media
 * query is a third net for when main.js itself fails to load.
 *
 * No element carries this attribute yet — Phase 0 is visually inert.
 */
[data-fx-hidden] { opacity: 0; }

.no-gsap [data-fx-hidden],
[data-fx-hidden].fx-shown,
.page-legal [data-fx-hidden] {
  opacity: 1;
  transform: none;
  filter: none;
}

@media (prefers-reduced-motion: reduce) {
  [data-fx-hidden] { opacity: 1; transform: none; filter: none; }
}

/* ---------- Pinned-scope visibility ----------
   main.js skips its reveal loops for anything inside [data-fx-scope] (see the
   fxOwned guard in initGsap). animations.css:120 still sets those elements to
   opacity:0, so without this rule they would never be revealed by anyone and
   the whole pinned section would render blank. */
[data-fx-scope] .reveal,
[data-fx-scope] .stagger-item,
[data-fx-scope] [data-reveal] {
  opacity: 1;
  transform: none;
  filter: none;
}

/* ---------- 01 Aperture loader ----------
   Layered INSIDE the existing #pageLoader rather than replacing it. main.js's
   window.load handler and 2.5s failsafe stay as the floor: if cinematic.js
   never runs, the original spinner shows and clears exactly as it does today.
   Only when .fx-aperture-active is set does cinematic.js own the exit. */
.fx-aperture {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.fx-aperture svg { width: min(56vmin, 340px); height: auto; }

/* Painted from CSS so the tokens resolve and both themes work — var() inside
   an SVG fill/stroke attribute is not resolved by browsers. */
.fx-aperture [data-aperture-fill] { fill: var(--bg); }
.fx-aperture [data-aperture-glow] { fill: none; stroke: var(--accent); }
.fx-aperture [data-aperture-arc] { stroke: var(--accent); }

/* The original spinner and wordmark step aside for the aperture. */
.fx-aperture-active .loader-mark,
.fx-aperture-active .loader-bar { display: none; }
.fx-aperture-active .loader-logo {
  position: relative;
  z-index: 2;
}

.fx-aperture-count {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(50% - min(28vmin, 170px) - 2.6rem);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.85rem;
  letter-spacing: 0.35em;
  color: var(--text-muted);
}

@media (prefers-reduced-motion: reduce) {
  /* Never inject the aperture under reduced motion (JS-gated); this is only
     a backstop in case the markup is already in the DOM. */
  .fx-aperture { display: none; }
  .fx-aperture-active .loader-mark { display: flex; }
}
/* ---------- 02 Hero scroll-scrub ----------
   Scrubbed over the hero's own height — NOT pinned. Pinning made the sequence
   one-way: the spacer changed document height and, with a second pinned
   section below, the hero frequently failed to restore on the way back up.
   Only opacity/transform/filter on the content; the WebGL scene follows
   UbdenFX.heroProgress, which three-hero.js reads each frame. */
.fx-hero-on .hero-content,
.fx-hero-on .sub-brands { will-change: transform, opacity, filter; }

/* The navbar descends once the sequence starts. Animated on .navbar, NOT
   .header — main.js:47 already owns .header's transform for hide-on-scroll,
   and two writers on one transform is the bug this project keeps hitting. */
.fx-hero-on .navbar { will-change: transform, opacity; }

@media (prefers-reduced-motion: reduce) {
  .fx-hero-on .hero-content,
  .fx-hero-on .sub-brands,
  .fx-hero-on .navbar {
    opacity: 1 !important;
    transform: none !important;
    filter: none !important;
  }
}

/* ---------- 03 Scroll hint ----------
   JS toggles .is-idle; mix-blend-difference keeps it legible on any ground.
   Only one blend-mode element is budgeted per page and this is it.
   Same inverted safety as spec 19: only .fx-ready opts the hint into being
   hidden, so a JS failure leaves it exactly as it renders today. */
.fx-ready .hero-scroll-hint {
  mix-blend-mode: difference;
  color: #fff;
  opacity: 0;
  transform: translateX(-50%) translateY(10px);
  transition: opacity 0.5s var(--ease-cine), transform 0.5s var(--ease-cine);
  cursor: pointer;
}
.fx-ready .hero-scroll-hint.is-idle {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* ---------- 05 Chromatic aberration heading ----------
   Ghost layers are injected by JS and inherit type metrics from the host. */
.fx-ab { position: relative; isolation: isolate; }
.fx-ab-ghost {
  position: absolute;
  inset: 0;
  z-index: -1;
  opacity: 0;
  pointer-events: none;
  user-select: none;
  will-change: transform, opacity;
}
.fx-ab-ghost--r { color: var(--fx-rgb-red); }
.fx-ab-ghost--c { color: var(--fx-rgb-cyan); }

/* ---------- 06 3D cylinder wheel ----------
   Base markup is a plain, readable list of links. It only becomes a cylinder
   once JS adds .fx-wheel-on, so the no-JS, no-GSAP and reduced-motion states
   are all the same usable list.
   Rows are bare <a> text, never cards: the per-row blur must not land on a
   .glass-card, whose backdrop-filter would then re-rasterise every frame. */
.fx-wheel-list {
  margin: 0;
  padding: 0;
  list-style: none;
  text-align: center;
}
.fx-wheel-list a {
  display: inline-block;
  padding: 0.35rem 0;
  font-family: var(--font-display);
  font-size: clamp(1.35rem, 3.6vw, 2.6rem);
  font-weight: 700;
  color: var(--text-muted);
  transition: color 0.3s var(--ease-cine);
}
.fx-wheel-list a:hover { color: var(--accent); }

.fx-wheel-on .fx-wheel-stage {
  position: relative;
  height: 0;
  perspective: 1500px;
  transform-style: preserve-3d;
}
.fx-wheel-on .fx-wheel-list { position: relative; height: 0; }
.fx-wheel-on .fx-wheel-list > li {
  position: absolute;
  left: 0;
  right: 0;
  backface-visibility: hidden;
  will-change: transform, opacity, filter;
}
/* Reserve the vertical room the absolutely-positioned rows no longer occupy.
   overflow:hidden is a bleed guard — the rows live in a height:0 list, so
   without it they can paint outside the section and over the next one. */
.fx-wheel-on .fx-wheel-shell {
  min-height: 62vh;
  display: grid;
  place-items: center;
  overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
  .fx-wheel-on .fx-wheel-stage,
  .fx-wheel-on .fx-wheel-list { height: auto; perspective: none; }
  .fx-wheel-on .fx-wheel-list > li {
    position: relative;
    transform: none !important;
    opacity: 1 !important;
    filter: none !important;
  }
  .fx-wheel-on .fx-wheel-shell { min-height: 0; display: block; }
}

/* ---------- 07 Hover preview cards ----------
   Pure CSS. .fx-border already supplies the rotating conic edge on hover;
   this adds the lift, the media push-in and the title colour shift. */
.fx-hover-card {
  transition:
    transform 0.5s var(--ease-cine),
    border-color 0.5s var(--ease-cine),
    box-shadow 0.5s var(--ease-cine);
  will-change: transform;
}
.fx-hover-card:hover,
.fx-hover-card:focus-within {
  transform: translateY(-6px);
  border-color: color-mix(in srgb, var(--accent) 60%, transparent);
  box-shadow: var(--shadow-card), var(--shadow-glow-cyan);
}
.fx-hover-card img {
  transition: transform 0.7s var(--ease-cine);
  will-change: transform;
}
.fx-hover-card:hover img,
.fx-hover-card:focus-within img { transform: scale(1.05); }

.fx-hover-card .certification-title,
.fx-hover-card .office-name {
  transition: color 0.3s var(--ease-cine);
}
.fx-hover-card:hover .certification-title,
.fx-hover-card:hover .office-name,
.fx-hover-card:focus-within .certification-title,
.fx-hover-card:focus-within .office-name { color: var(--accent); }

/* ---------- 04 Lenis smooth scroll + scene depth ----------
   Scoped to html.ub-lenis, which JS adds only after Lenis actually
   constructs — so a failed CDN leaves native smooth scrolling untouched. */
html.ub-lenis { scroll-behavior: auto; }
html.ub-lenis,
html.ub-lenis body { overscroll-behavior-y: none; }

.ub-lenis .service-block-figure,
.ub-lenis [data-fx-figure] { will-change: transform; }

@media (prefers-reduced-motion: reduce) {
  .ub-lenis .service-block-figure,
  .ub-lenis [data-fx-figure] { transform: none !important; }
}

/* ---------- 08 Service explorer ----------
   Two columns: a service list on the left, a sticky preview on the right.
   The preview panes ARE the original .service-block elements, restacked —
   so without .fx-explorer-on the page is exactly the old stacked layout.
   Each block carries --svc (its accent triple) from includes/service-colors.php. */
.fx-explorer-on .fx-explorer-blocks { display: block; }


/* Stage holds all blocks stacked in one grid cell; scroll progress decides
   which is visible. This is the pinned element, so it must fit the viewport
   exactly — anything taller gets its bottom cut off for the whole sequence. */
.fx-explorer-on .fx-explorer-stage {
  display: grid;
  align-items: center;
  height: 100vh;
  height: 100svh;
}
/* The section's own padding would otherwise sit above and below the pinned
   stage as dead background. */
.fx-explorer-on #service-explorer {
  padding-top: 0;
  padding-bottom: 0;
}
/* --- The card itself ---------------------------------------------------
   Editorial split: a photograph on one side, the copy on the other, inside a
   single glass panel tinted with the service's own accent (--svc). */
#service-explorer .service-block {
  max-height: 100%;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
  gap: 0;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--glass-bg);
  box-shadow: var(--shadow-card), 0 0 0 1px rgba(var(--svc), 0.14),
              0 24px 70px -30px rgba(var(--svc), 0.55);
}

/* --- Photo panel --- */
#service-explorer .service-block-figure {
  position: relative;
  /* Height comes from grid stretch against the copy column. A percentage
     min-height would resolve to auto here (the row is content-sized) and the
     panel collapsed to zero once the photo went absolute. */
  align-self: stretch;
  min-height: 0;
  /* pages.css caps this at max-width:420px and auto-centres it, which left the
     photo band inset inside the card instead of full-bleed. */
  max-width: none;
  width: 100%;
  margin: 0;
  overflow: hidden;
}
#service-explorer .svc-photo {
  /* Absolute, not height:100% — pages.css sizes .service-block-figure img by
     its intrinsic ratio, so the photo rendered letterboxed inside the panel
     instead of filling it. */
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  max-height: none;
  object-fit: cover;
  /* Photos come in at mixed exposures; this settles them into the dark
     palette so eight different stock shots read as one set. */
  filter: saturate(0.75) contrast(1.05) brightness(0.72);
}
/* Accent wash + edge fade, so the photo meets the copy without a hard seam. */
#service-explorer .service-block-figure::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    linear-gradient(90deg, transparent 45%, var(--glass-bg) 97%),
    linear-gradient(180deg, rgba(var(--svc), 0.32), transparent 55%);
  pointer-events: none;
}
#service-explorer .svc-badge {
  position: absolute;
  left: clamp(1rem, 2vw, 1.5rem);
  bottom: clamp(1rem, 2vw, 1.5rem);
  z-index: 1;
  display: grid;
  place-items: center;
  width: 62px;
  height: 62px;
  border-radius: 16px;
  border: 1px solid rgba(var(--svc), 0.5);
  background: rgba(5, 6, 15, 0.55);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
}
#service-explorer .svc-badge img { width: 34px; height: 34px; }

/* --- Copy panel --- */
#service-explorer .service-block-body {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.75rem;
  padding: clamp(1.5rem, 3vw, 2.75rem);
  min-width: 0;
}
#service-explorer .svc-index {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.75rem;
  letter-spacing: 0.22em;
  color: rgb(var(--svc));
}
#service-explorer .service-block-body h3 {
  margin: 0;
  font-size: clamp(1.4rem, 2.4vw, 2rem);
  line-height: 1.15;
}
#service-explorer .service-block-body .lead {
  margin: 0;
  color: var(--text-muted);
  font-size: 0.975rem;
  line-height: 1.6;
}

/* Feature rows become chips — the stacked boxes read as empty form fields. */
#service-explorer .feature-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin: 0.25rem 0 0;
  padding: 0;
  list-style: none;
}
#service-explorer .feature-list li {
  padding: 0.4rem 0.85rem;
  border: 1px solid rgba(var(--svc), 0.32);
  border-radius: 999px;
  background: rgba(var(--svc), 0.09);
  color: var(--text);
  font-size: 0.8rem;
  font-weight: 600;
  line-height: 1.3;
}
#service-explorer .feature-list li::before { content: none; }
#service-explorer .svc-cta { margin-top: auto; }

@media (max-width: 1100px) {
  /* Below this the two panels squeeze the copy; drop to photo-on-top. */
  #service-explorer .service-block {
    grid-template-columns: 1fr;
    /* auto rows, not a percentage: outside the pinned stage the card has no
       definite height, so `minmax(0, 34%)` resolves against nothing and the
       photo panel collapses. A fixed photo band is what this needs. */
    grid-template-rows: auto auto;
  }
  #service-explorer .service-block-figure {
    height: clamp(140px, 30vw, 220px);
  }
  #service-explorer .service-block-figure::after {
    background:
      linear-gradient(180deg, rgba(var(--svc), 0.3), transparent 40%, var(--glass-bg) 98%);
  }
  /* Stacked cards need their own rhythm — in the pinned stage they overlap in
     one grid cell and never need margins. */
  .fx-explorer-blocks > .service-block + .service-block { margin-top: var(--space-3); }
}
.fx-explorer-on .fx-explorer-stage > .service-block {
  /* No `display` here: the card rule below turns this into a two-column grid
     and both selectors have identical specificity, so declaring display:block
     at this point silently won by source order and collapsed the photo panel
     to zero height. */
  grid-area: 1 / 1;
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
}
.fx-explorer-on .fx-explorer-stage > .service-block.is-active {
  visibility: visible;
  opacity: 1;
  pointer-events: auto;
}
/* Accent glow behind the active pane, tinted per service. */
#service-explorer .service-block .service-block-figure {
  position: relative;
  isolation: isolate;
}
#service-explorer .service-block .service-block-figure::before {
  content: '';
  position: absolute;
  inset: -12%;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(60% 60% at 50% 35%,
    rgba(var(--svc, 0 212 255), 0.26), transparent 70%);
  pointer-events: none;
}
#service-explorer .service-block img { will-change: transform; }

@media (max-width: 900px) {
  /* No pinning on narrow screens (the effect is gated to `wide` in JS), so
     the blocks must lay out normally again. */
  .fx-explorer-on .fx-explorer-stage { display: block; min-height: 0; }
  .fx-explorer-on .fx-explorer-stage > .service-block {
    visibility: visible;
    opacity: 1;
    pointer-events: auto;
  }
}

@media (prefers-reduced-motion: reduce) {
  .fx-explorer-on .fx-explorer-stage > .service-block { transition: none; }
}
/* ---------- 09 Horizontal pinned carousel ----------
   The grid only becomes a horizontal track once JS confirms it can drive it
   (.fx-hpan-on). Without JS, under reduced motion, or on narrow screens it
   stays the original responsive grid — which is also the fallback the spec
   asks for. Only `x` is animated; no per-frame filters. */
.fx-hpan-on .services-grid {
  display: flex;
  flex-wrap: nowrap;
  width: max-content;
  align-items: stretch;
  gap: clamp(1rem, 2vw, 1.75rem);
  /* Vertical scrolling must still work with a finger on the track. */
  touch-action: pan-y;
  will-change: transform;
}
.fx-hpan-on .services-grid > .service-card {
  flex: 0 0 clamp(280px, 26vw, 360px);
}
/* This is the pinned element, so it must fit the viewport on its own. */
.fx-hpan-on .fx-hpan-viewport {
  overflow: hidden;
  /* Full-bleed so cards can travel edge to edge. */
  width: 100vw;
  margin-left: calc(50% - 50vw);
  padding-inline: clamp(1.25rem, 5vw, 4rem);
  padding-block: clamp(1rem, 3vh, 2.5rem);
  display: flex;
  align-items: center;
  max-height: 100vh;
  max-height: 100svh;
}
/* No max-height/overflow clamp here on purpose: clipping would silently eat a
   card's CTA. The JS measures instead and refuses to pin at all when the cards
   do not fit the viewport, leaving the normal responsive grid. */

@media (prefers-reduced-motion: reduce) {
  .fx-hpan-on .services-grid {
    display: grid;
    width: auto;
    touch-action: auto;
    transform: none !important;
  }
  .fx-hpan-on .fx-hpan-viewport {
    overflow: visible;
    width: auto;
    margin-left: 0;
    padding-inline: 0;
  }
}

/* ---------- 10 Marquee ----------
   Single row, and deliberately plain. pages.css:100 dresses each logo as a
   glass card — surface fill, border, backdrop-filter, drop shadow — and runs
   a `cardSwing` 3D animation on a `perspective: 900px` track with staggered
   delays. That is a wall of tumbling boxes, not a brand band, and twelve-plus
   backdrop-filter layers is the most expensive thing on the hero.
   Stripped back to sharp logos on a steadily moving strip. */
.sub-brands .fx-marquee-track { perspective: none; }

.sub-brands .sub-brand-item {
  padding: 0.35rem clamp(1.25rem, 3vw, 2.5rem);
  background: none;
  border: 0;
  border-radius: 0;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  box-shadow: none;
  transform-style: flat;
  animation: none;
  transition: opacity 0.3s var(--ease-cine);
}
.sub-brands .sub-brand-item:hover {
  animation: none;
  background: none;
  border-color: transparent;
  box-shadow: none;
}

/* A thin separator between logos, as in the reference band. */
.sub-brands .sub-brand-item + .sub-brand-item::before {
  content: '';
  position: absolute;
  left: 0;
  top: 50%;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: var(--accent);
  opacity: 0.5;
  transform: translate(-50%, -50%);
}
.sub-brands .sub-brand-item { position: relative; }

/* Logos stay crisp and evenly weighted — no hover-only reveal. */
.sub-brands .sub-brand-item img { opacity: 1; transition: none; }

/* Constant motion: the band should not stall when the pointer crosses it. */
.sub-brands .fx-marquee:hover .fx-marquee-track { animation-play-state: running; }

/* Kept so a second row can be reinstated with one class if the logo set grows.
   Must out-specify `.fx-marquee .fx-marquee-track.is-cloned` (0,3,0) in
   animations.css:266, whose `animation:` shorthand resets the direction. */
.fx-marquee .fx-marquee-track--reverse.is-cloned { animation-direction: reverse; }

/* ==========================================================================
   Photography layer
   Three reusable patterns, all theme-aware through --fx-photo-*. Every photo
   sits BEHIND its text with a scrim on top, so contrast never depends on how
   bright a given stock shot happens to be.
   ========================================================================== */

/* --- Page-hero backdrop (service detail, careers, about) --- */
.page-hero { position: relative; isolation: isolate; }
.page-hero-media {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
}
.page-hero-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Fixed, not --fx-photo-filter: this band is dark in both themes, so the
     photo must not brighten under the light palette. */
  filter: saturate(0.72) contrast(1.05) brightness(0.62);
}
.page-hero-media::after {
  content: '';
  position: absolute;
  inset: 0;
  /* Deliberately dark in BOTH themes. A light-theme scrim over a brightened
     photo left light heading text on a light ground. A photographic hero is
     treated as a cinematic band and always carries light type — that is a
     deterministic contrast guarantee rather than one that depends on how
     bright a given stock shot is. */
  background: linear-gradient(180deg, rgba(4, 6, 16, 0.55) 0%, rgba(4, 6, 16, 0.88) 100%);
}
/* Type over the photo is light in both themes, to match that band. */
.page-hero--photo .page-hero-title,
.page-hero--photo .hero-badge { color: #f4f7ff; }
.page-hero--photo .page-hero-subtitle { color: rgba(244, 247, 255, 0.82); }
/* The hero's existing particle canvas and ornament stay above the photo. */
.page-hero .page-hero-particles,
.page-hero .hero-ornament { z-index: 0; }
.page-hero > .container { position: relative; z-index: 1; }

/* --- Card backdrop (office cards) --- */
.office-card { position: relative; isolation: isolate; overflow: hidden; }
.office-media {
  position: absolute;
  inset: 0;
  z-index: -1;
}
.office-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: var(--fx-photo-card-dim);
  filter: var(--fx-photo-filter);
}
.office-media::after {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--fx-photo-card-scrim);
}

/* --- Editorial figure (about, careers body sections) --- */
.fx-figure {
  position: relative;
  margin: 0;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-card);
}
.fx-figure img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  filter: var(--fx-photo-filter);
  transition: transform 0.7s var(--ease-cine);
}
.fx-figure:hover img { transform: scale(1.04); }
.fx-figure::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(var(--fx-figure-tint, 0 212 255), 0.16), transparent 55%);
  pointer-events: none;
}

/* Two-column band: figure beside the section copy. */
.fx-media-band {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(1.5rem, 4vw, 3rem);
  align-items: center;
}
.fx-media-band--reverse .fx-figure { order: 2; }
@media (max-width: 860px) {
  .fx-media-band { grid-template-columns: 1fr; }
  .fx-media-band--reverse .fx-figure { order: 0; }
}

/* --- Mission / vision feature pair --------------------------------------
   Two statements carrying the page's thesis, so they get feature weight:
   an accent glow that bleeds from the top corner, a large index numeral, a
   badged icon and generous measure. Each card supplies its own --mv triple. */
.mv-card--feature {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  padding: clamp(1.75rem, 3vw, 2.75rem);
  border-color: rgba(var(--mv), 0.22);
  transition:
    transform 0.5s var(--ease-cine),
    border-color 0.5s var(--ease-cine),
    box-shadow 0.5s var(--ease-cine);
}
.mv-card--feature::before {
  /* Corner bloom in the card's own accent. */
  content: '';
  position: absolute;
  top: -40%;
  right: -25%;
  z-index: -1;
  width: 70%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(var(--mv), 0.22), transparent 68%);
  pointer-events: none;
}
.mv-card--feature:hover {
  transform: translateY(-6px);
  border-color: rgba(var(--mv), 0.5);
  box-shadow: var(--shadow-card), 0 24px 60px -30px rgba(var(--mv), 0.7);
}

.mv-index {
  position: absolute;
  top: clamp(1rem, 2vw, 1.6rem);
  right: clamp(1.25rem, 2.5vw, 2rem);
  font-family: var(--font-display);
  font-size: clamp(2.5rem, 6vw, 4.25rem);
  font-weight: 800;
  line-height: 1;
  /* Ghosted numeral — present as texture, never competing with the heading. */
  color: transparent;
  -webkit-text-stroke: 1px rgba(var(--mv), 0.28);
  pointer-events: none;
  user-select: none;
}

.mv-badge {
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  margin-bottom: 1.1rem;
  border-radius: 16px;
  border: 1px solid rgba(var(--mv), 0.35);
  background: rgba(var(--mv), 0.1);
  color: rgb(var(--mv));
}

.mv-card--feature h3 {
  margin: 0 0 0.6rem;
  font-size: clamp(1.3rem, 2.2vw, 1.75rem);
  line-height: 1.2;
}
.mv-card--feature p {
  margin: 0;
  max-width: 46ch;
  color: var(--text-muted);
  font-size: 1rem;
  line-height: 1.7;
}

@media (prefers-reduced-motion: reduce) {
  .mv-card--feature:hover { transform: none; }
}

/* --- Home service cards -------------------------------------------------
   Photo as a backdrop rather than a header band, so the card's height — and
   therefore the horizontal carousel's fit check — is unchanged. */
.service-card { position: relative; isolation: isolate; overflow: hidden; }
.service-media { position: absolute; inset: 0; z-index: -1; }
.service-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.26;
  filter: saturate(0.55) brightness(0.5);
  transition: transform 0.8s var(--ease-cine), opacity 0.5s var(--ease-cine);
}
.service-media::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    linear-gradient(155deg, rgba(var(--svc, 0 212 255), 0.26), transparent 58%),
    linear-gradient(180deg, rgba(5, 6, 15, 0.66) 0%, rgba(5, 6, 15, 0.94) 100%);
}
.service-card:hover .service-media img { transform: scale(1.05); opacity: 0.36; }
.service-card .service-icon,
.service-card .tilt-inner { position: relative; }

[data-theme="light"] .service-media img { opacity: 0.13; filter: saturate(0.6) brightness(1.06); }
[data-theme="light"] .service-media::after {
  background:
    linear-gradient(155deg, rgba(var(--svc, 0 212 255), 0.12), transparent 58%),
    linear-gradient(180deg, rgba(244, 246, 253, 0.76) 0%, rgba(244, 246, 253, 0.94) 100%);
}
[data-theme="light"] .service-card:hover .service-media img { opacity: 0.2; }

@media (prefers-reduced-motion: reduce) {
  .service-card:hover .service-media img { transform: none; }
}

/* --- Partner cards ------------------------------------------------------
   Abstract texture behind each card, tinted with the partner's own accent so
   six cards read as a set rather than six different stock pictures.
   Deliberately abstract: a photograph of a real office or product would imply
   something specific about the partnership that the page does not claim. */
.partner-card {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  border-color: rgba(var(--pt, 0 212 255), 0.2);
  transition:
    transform 0.5s var(--ease-cine),
    border-color 0.5s var(--ease-cine),
    box-shadow 0.5s var(--ease-cine);
}
.partner-media {
  position: absolute;
  inset: 0;
  z-index: -1;
}
.partner-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.3;
  filter: saturate(0.5) brightness(0.5);
  transition: transform 0.8s var(--ease-cine), opacity 0.5s var(--ease-cine);
}
.partner-media::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    linear-gradient(160deg, rgba(var(--pt, 0 212 255), 0.3), transparent 55%),
    linear-gradient(180deg, rgba(5, 6, 15, 0.6) 0%, rgba(5, 6, 15, 0.93) 100%);
}
[data-theme="light"] .partner-media img { opacity: 0.16; filter: saturate(0.6) brightness(1.05); }
[data-theme="light"] .partner-media::after {
  background:
    linear-gradient(160deg, rgba(var(--pt, 0 212 255), 0.14), transparent 55%),
    linear-gradient(180deg, rgba(244, 246, 253, 0.72) 0%, rgba(244, 246, 253, 0.93) 100%);
}
.partner-card:hover {
  transform: translateY(-6px);
  border-color: rgba(var(--pt, 0 212 255), 0.5);
  box-shadow: var(--shadow-card), 0 22px 60px -32px rgba(var(--pt, 0 212 255), 0.65);
}
.partner-card:hover .partner-media img { transform: scale(1.06); opacity: 0.42; }
[data-theme="light"] .partner-card:hover .partner-media img { opacity: 0.24; }

@media (prefers-reduced-motion: reduce) {
  .partner-card:hover { transform: none; }
  .partner-card:hover .partner-media img { transform: none; }
}

/* --- CTA backdrop --- */
.contact-cta { position: relative; isolation: isolate; overflow: hidden; }
.cta-media { position: absolute; inset: 0; z-index: -1; }
.cta-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: var(--fx-photo-card-dim);
  filter: var(--fx-photo-filter);
}
.cta-media::after {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--fx-photo-card-scrim);
}

@media (prefers-reduced-motion: reduce) {
  .fx-figure:hover img { transform: none; }
}

@media print {
  .page-hero-media, .office-media, .cta-media { display: none !important; }
}

/* ---------- 11 Bokeh depth of field ----------
   Scoped to the .page-hero it replaces, not fixed to the viewport, so it can
   never cover page content. Rendered at dpr 0.5 by design — it is all soft
   gradients, so half resolution is free quality. */
.fx-bokeh {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
}
/* When bokeh takes over, the 2D particle canvas must not also render. */
.page-hero[data-ub-owner="bokeh"] .page-hero-particles { display: none; }

/* ---------- 13 Wave field ----------
   Full-viewport overlay above content but below the nav and the cursor. */
.fx-wave {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: var(--z-fx-overlay);
  pointer-events: none;
}

@media print {
  .fx-bokeh, .fx-wave, .fx-cursor-dot, .contact-dock { display: none !important; }
}

/* ---------- 14 Custom cursor ----------
   html.fx-cursor is added by JS only after the dot exists and has been
   positioned, and is removed by a window error handler — otherwise a throw
   mid-init would leave the visitor with no pointer at all. */
.fx-cursor,
.fx-cursor a,
.fx-cursor button,
.fx-cursor [role="button"] { cursor: none; }

/* Text entry must keep a real caret — losing it in a form is disorienting. */
.fx-cursor input,
.fx-cursor textarea,
.fx-cursor select,
.fx-cursor [contenteditable] { cursor: auto; }

.fx-cursor-dot {
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--z-cursor);
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: var(--fx-cursor-dot);
  box-shadow: 0 0 10px 2px var(--fx-cursor-glow);
  pointer-events: none;
  will-change: transform;
}
/* ---------- 15 Floating contact dock ----------
   Sits directly above the existing WhatsApp FAB, which stays the primary
   channel and keeps its own styling from components.css:640.
   Inverted safety again: with no JS the channels render as a plain visible
   column of links and the toggle is hidden, so every contact route stays
   reachable. .dock-ready (added by cinematic.js) collapses them. */
.contact-dock {
  position: fixed;
  right: calc(1.2rem + env(safe-area-inset-right, 0px));
  bottom: calc(1.2rem + 58px + 0.75rem + env(safe-area-inset-bottom, 0px));
  z-index: calc(var(--z-nav) - 1);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6rem;
}

.dock-channels {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.dock-channel a {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  color: #fff;
  box-shadow: var(--shadow-card);
  transition: transform 0.3s var(--ease-cine), box-shadow 0.3s var(--ease-cine);
}
.dock-channel a:hover { transform: scale(1.1); }
.dock-channel a svg { width: 22px; height: 22px; }
.dock-channel--call a  { background: #0a7285; }
.dock-channel--email a { background: var(--brand-purple); }

/* Toggle is inert without JS, so it is hidden until JS claims the dock. */
.dock-toggle { display: none; }

.dock-ready .dock-toggle {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-contrast);
  cursor: pointer;
  box-shadow: var(--shadow-card), var(--shadow-glow-cyan);
  transition: transform 0.3s var(--ease-cine);
}
.dock-ready .dock-toggle:hover { transform: scale(1.05); }
.dock-toggle-icon {
  display: flex;
  transition: transform 0.3s var(--ease-cine);
}
.dock-toggle-icon svg { width: 22px; height: 22px; }
.dock-ready .dock-toggle[aria-expanded="true"] .dock-toggle-icon { transform: rotate(135deg); }

/* Attention pulse, only while collapsed. */
.dock-ready .dock-toggle[aria-expanded="false"]::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid var(--accent);
  animation: waPulse 2.4s ease-out infinite;
  pointer-events: none;
}

/* Collapsed channels. Per-item delays are set inline by JS: opening runs in
   reverse index order so the topmost channel lands last. */
.dock-ready .dock-channel {
  transition:
    opacity 0.3s var(--ease-cine),
    transform 0.3s var(--ease-cine);
}
.dock-ready .dock-channels[data-open="false"] .dock-channel {
  opacity: 0;
  transform: translateY(20px) scale(0.6);
  pointer-events: none;
}
.dock-ready .dock-channels[data-open="true"] .dock-channel {
  opacity: 1;
  transform: translateY(0) scale(1);
}

@media (max-width: 640px) {
  .contact-dock {
    right: calc(1rem + env(safe-area-inset-right, 0px));
    bottom: calc(1rem + 52px + 0.65rem + env(safe-area-inset-bottom, 0px));
  }
  .dock-channel a,
  .dock-ready .dock-toggle { width: 44px; height: 44px; }
}

/* ---------- 16 Aurora button ----------
   .btn-primary::after is already the shimmer sweep (animations.css:241), so
   the aurora uses ::before. isolation:isolate makes the button a stacking
   context, which lets the z-index:-1 pseudo paint above the button's own
   background but below its label — no markup change needed on any of the
   many .btn-primary instances across the site.
   --border-angle is already registered with @property (animations.css:214),
   which is what makes the conic-gradient angle animatable at all. */
.btn-primary { isolation: isolate; }
.btn-primary::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: -1;
  aspect-ratio: 1;
  width: 220%;
  transform: translate(-50%, -50%);
  background: conic-gradient(
    from var(--border-angle),
    #0a5266, #123f6b, #15803d, #0b3d4e, #0a5266
  );
  opacity: 0;
  transition: opacity 0.3s var(--ease-cine);
  pointer-events: none;
}
.btn-primary:hover::before,
.btn-primary:focus-visible::before {
  opacity: 0.75;
  animation: borderRotate var(--fx-aurora-spin) linear infinite;
}
/* The dark aurora would muddy the light theme's brighter pill. */
[data-theme="light"] .btn-primary:hover::before,
[data-theme="light"] .btn-primary:focus-visible::before { opacity: 0.45; }

/* ---------- 17 Navbar pill + mobile menu ----------
   Framer Motion's layoutId becomes one absolutely-positioned span that JS
   measures and moves. The pill only exists once JS adds .fx-nav-ready, and
   the underline it replaces is only removed in that same state — so without
   JS the original active-link underline still marks the current page. */
.fx-nav-ready .nav-list { position: relative; }
.fx-nav-ready .nav-link.active::after { display: none; }
.fx-nav-ready .nav-item { position: relative; z-index: 1; }

.nav-pill {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  height: 0;
  width: 0;
  border-radius: 999px;
  /* Solid --accent, not --gradient-brand: the label is painted with
     --accent-contrast, which is the designed contrast partner for --accent.
     Over a cyan-to-purple gradient that same near-black label fails contrast
     at the purple end. This pairing holds in both themes. */
  background: var(--accent);
  box-shadow: 0 0 22px -6px var(--accent);
  opacity: 0;
  pointer-events: none;
  will-change: transform, width, height;
}
/* The dark label colour follows the PILL, never the active state. The pill
   moves to whatever is hovered, so colouring .active as well left the current
   page's link as near-black text on a dark bar with nothing behind it — it
   read as a menu item that had gone black. .is-pill is on exactly one link at
   a time and always has the pill under it. */
.fx-nav-ready .nav-link.is-pill { color: var(--accent-contrast); }
/* Anything the pill is not under stays legible. */
.fx-nav-ready .nav-link.active:not(.is-pill) { color: var(--text); }
.fx-nav-ready .nav-link:hover { background: transparent; }

/* --- Mobile full-screen menu --- */
@media (max-width: 900px) {
  /* The pill is desktop-only, so the original active underline has to come
     back — otherwise the current page has no indicator at all in the mobile
     menu. .fx-nav-ready hides it unconditionally further up. */
  .fx-nav-ready .nav-link.active::after { display: block; }
  .fx-nav-ready .nav-menu.open .nav-link {
    transition:
      opacity 0.4s var(--ease-cine),
      filter 0.4s var(--ease-cine);
  }
  /* Focus-dim: everything the reader is not pointing at recedes. */
  .fx-nav-ready .nav-list.is-hovering .nav-link:not(:hover) {
    opacity: 0.35;
    filter: blur(3px);
  }
  .fx-nav-ready .nav-menu .nav-link.active { color: var(--accent); }
  /* The pill is a desktop affordance only. */
  .nav-pill { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .nav-pill { transition: none; }
  .fx-nav-ready .nav-list.is-hovering .nav-link:not(:hover) {
    opacity: 1;
    filter: none;
  }
}

/* ---------- 19 Blur-reveal ----------
   Deliberately a NEW attribute, not [data-reveal]: main.js:169 already owns
   every [data-reveal] element, and two animators on one element is the bug
   class this project keeps hitting. cinematic.js owns [data-fx-reveal].
   Inverted safety: elements are visible by default and are hidden ONLY under
   .fx-ready, which cinematic.js adds to <html> when it has confirmed it can
   actually animate them. If the script, GSAP, or the CDN fails, the class is
   never added and nothing can be left permanently invisible. */
[data-fx-reveal] { will-change: opacity, transform, filter; }

.fx-ready [data-fx-reveal="item"]  { opacity: 0; transform: translateY(26px); filter: blur(var(--fx-blur-reveal)); }
.fx-ready [data-fx-reveal="card"]  { opacity: 0; transform: translateY(24px); filter: blur(var(--fx-blur-card)); }
.fx-ready [data-fx-reveal="title"] { opacity: 0; filter: blur(var(--fx-blur-title)); }

/* "header" is a container variant: the block itself stays laid out and its
   children are revealed in reading order, so one attribute replaces the old
   .reveal on every section header without touching each child in PHP.
   The h2 gets the heavy title blur, the paragraphs the lighter item blur. */
.fx-ready [data-fx-reveal="header"] > * { opacity: 0; }
.fx-ready [data-fx-reveal="header"] > h1,
.fx-ready [data-fx-reveal="header"] > h2,
.fx-ready [data-fx-reveal="header"] > h3 { filter: blur(var(--fx-blur-title)); }
.fx-ready [data-fx-reveal="header"] > p,
.fx-ready [data-fx-reveal="header"] > div,
.fx-ready [data-fx-reveal="header"] > a {
  transform: translateY(26px);
  filter: blur(var(--fx-blur-reveal));
}
.fx-ready [data-fx-reveal="header"].fx-shown > * {
  opacity: 1;
  transform: none;
  filter: none;
}

.fx-ready [data-fx-reveal].fx-shown,
.fx-ready.page-legal [data-fx-reveal],
.page-legal [data-fx-reveal] {
  opacity: 1;
  transform: none;
  filter: none;
}

/* ---------- 18 Route transition rack-focus ----------
   Cheap despite the heavy blur: the browser animates its own snapshots, not
   live DOM. This is a plain multi-page PHP site, so cross-document view
   transitions come from the at-rule alone — no JS, no router. Browsers
   without support ignore the whole block, which is a perfect fail-open. */
@view-transition { navigation: auto; }

/* The outgoing page pulls back and out of focus, like a camera releasing the
   subject; the incoming one is revealed by a wipe travelling up the frame
   while it settles into focus. The wipe is what makes it read as a cut rather
   than a cross-fade. */
::view-transition-old(root) {
  animation: 300ms var(--ease-cine) both fx-defocus;
  transform-origin: 50% 45%;
}
::view-transition-new(root) {
  /* Starts before the old one has fully cleared, so the two overlap briefly
     instead of leaving a blank frame between pages. */
  animation: 620ms var(--ease-cine) 140ms both fx-focus;
  transform-origin: 50% 55%;
}

@keyframes fx-defocus {
  to {
    opacity: 0;
    filter: blur(12px) brightness(0.72);
    transform: scale(1.03);
  }
}
@keyframes fx-focus {
  from {
    opacity: 0;
    filter: blur(16px) brightness(1.25);
    transform: scale(1.05) translateY(2.5%);
    clip-path: inset(88% 0 0 0);
  }
  55% { opacity: 1; }
  to {
    opacity: 1;
    filter: blur(0) brightness(1);
    transform: scale(1) translateY(0);
    clip-path: inset(0 0 0 0);
  }
}

/* Reduced motion still gets a clean cut, just without the travel. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) { animation-duration: 1ms; animation-delay: 0s; }
}

/* The header deliberately does NOT get its own view-transition-name.
   Naming it lifts the header into the transition layer for the whole
   navigation, and pinning that group with `animation: none` was leaving the
   nav visually stale between pages — the active item could render as if the
   FX layer had never initialised. The page-level rack-focus above already
   carries the effect; freezing the header bought a refinement that was not
   worth a broken menu on every navigation. */

/* ---------- 19 Blur-reveal ---------- */

/* ---------- Reduced motion ----------
   animations.css already carries the global kill-switch. This block is for
   FX-layer specifics only, and is a CSS-side backstop: it cannot stop GSAP,
   which writes inline styles. Every JS effect gates itself in cinematic.js. */
@media (prefers-reduced-motion: reduce) {
  /* 19 — never leave a reveal target hidden */
  [data-fx-reveal],
  .fx-ready [data-fx-reveal],
  .fx-ready [data-fx-reveal="header"] > * { opacity: 1; transform: none; filter: none; }

  /* 03 — hint stays put, no blend trickery */
  .fx-ready .hero-scroll-hint {
    opacity: 1;
    transform: translateX(-50%);
    transition: none;
    mix-blend-mode: normal;
    color: var(--text-faint);
  }

  /* 05 — no ghosts at all */
  .fx-ab-ghost { display: none; }

  /* 07 / 16 — keep the colour feedback, drop the movement */
  .fx-hover-card:hover,
  .fx-hover-card:focus-within { transform: none; }
  .fx-hover-card:hover img,
  .fx-hover-card:focus-within img { transform: none; }
  .btn-primary::before { display: none; }

  /* 10 — second row must not scroll either (animations.css:305 handles row 1) */
  .fx-marquee .fx-marquee-track--reverse.is-cloned { animation: none; }
}

/* ==========================================================================
   Mobile pass
   Everything the FX layer added, tuned for narrow screens. The heavy effects
   (pinned carousel, wheel, explorer, cursor, bokeh, wave) are already gated
   off in JS by the `wide` / `finePointer` capability checks — what remains is
   making the new photographic surfaces sit correctly at small sizes.
   ========================================================================== */
@media (max-width: 900px) {
  /* Photo heroes: shorter band, tighter type, and the photo pulled toward the
     subject so a 16:9 crop does not become a strip of sky. */
  .page-hero--photo { padding-block: calc(72px + var(--space-4)) var(--space-4); }
  .page-hero-media img { object-position: 50% 35%; }

  /* Editorial figures read better squarer than 16:9 on a narrow column. */
  .fx-figure img { aspect-ratio: 4 / 3; }

  /* Card backdrops: lift the photo a little, since the card is now the whole
     screen width and the image is doing more of the visual work. */
  .service-media img,
  .partner-media img { opacity: 0.34; }
  .office-media img { opacity: calc(var(--fx-photo-card-dim) + 0.06); }

  /* Mission / vision: the ghost numeral crowds a 335px card. */
  .mv-index { font-size: 2.75rem; top: 0.9rem; right: 1rem; }
  .mv-badge { width: 48px; height: 48px; margin-bottom: 0.9rem; }
  .mv-card--feature { padding: 1.5rem 1.35rem; }
  .mv-card--feature p { max-width: none; }
}

@media (max-width: 640px) {
  /* Contact dock: smaller targets and, more importantly, no reserved column
     of dead space — collapsed channels are removed from layout rather than
     just made transparent. */
  .dock-ready .dock-channels[data-open="false"] { display: none; }
  .contact-dock { gap: 0.5rem; }

  /* Service explorer never pins here; make sure the card is a plain stack. */
  #service-explorer .service-block {
    grid-template-columns: 1fr;
    grid-template-rows: auto auto;
  }

  /* Feature chips wrap tighter. */
  #service-explorer .feature-list li {
    font-size: 0.75rem;
    padding: 0.35rem 0.7rem;
  }
}

/* Very small phones: keep the hero legible rather than letting clamp() bottom
   out at a size that still wraps awkwardly. */
@media (max-width: 380px) {
  .mv-index { display: none; }
  .svc-index { font-size: 0.7rem; }
}

/* ==========================================================================
   Cookie consent — refined
   components.css:676 builds a boxy panel with two radial glows. Restyled here
   as a quiet glass card that matches the rest of the surface language, and on
   phones as a proper bottom sheet with safe-area padding.
   ========================================================================== */
.cookie-consent {
  /* Narrower and lighter than the original 420px slab. */
  width: min(380px, calc(100vw - 2.4rem));
  padding: 1.15rem 1.25rem 1.1rem;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  /* One soft accent wash instead of two competing radial glows. */
  background:
    linear-gradient(150deg, rgba(0, 212, 255, 0.07), transparent 60%),
    var(--glass-bg);
  backdrop-filter: blur(var(--glass-blur));
  -webkit-backdrop-filter: blur(var(--glass-blur));
  box-shadow: 0 18px 50px -22px rgba(0, 0, 0, 0.7), var(--shadow-card);
  animation: cookieRise 0.5s var(--ease-cine) both;
}
[data-theme="light"] .cookie-consent {
  box-shadow: 0 18px 46px -24px rgba(23, 33, 74, 0.35), var(--shadow-card);
}

@keyframes cookieRise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* A small cookie glyph gives the panel an anchor instead of raw text. */
.cookie-consent::before {
  content: '';
  display: block;
  width: 30px;
  height: 30px;
  margin-bottom: 0.7rem;
  border-radius: 9px;
  border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
  background:
    radial-gradient(circle at 32% 34%, currentColor 1.6px, transparent 1.7px),
    radial-gradient(circle at 63% 52%, currentColor 1.9px, transparent 2px),
    radial-gradient(circle at 42% 70%, currentColor 1.4px, transparent 1.5px),
    color-mix(in srgb, var(--accent) 12%, transparent);
  color: var(--accent);
}

.cookie-consent-text {
  margin-bottom: 1rem;
  color: var(--text-muted);
  font-size: 0.85rem;
  line-height: 1.55;
}
.cookie-consent-actions {
  gap: 0.5rem;
  /* Accept leads, reject sits beside it, the policy link steps back to the
     end — a clear hierarchy rather than three equal-weight controls. */
  flex-direction: row-reverse;
  justify-content: flex-end;
}
.cookie-consent .cookie-consent-link {
  margin-right: auto;
  color: var(--text-faint);
  font-size: 0.78rem;
  text-decoration: none;
  border-bottom: 1px solid color-mix(in srgb, var(--text-faint) 45%, transparent);
  transition: color 0.3s var(--ease-cine), border-color 0.3s var(--ease-cine);
}
.cookie-consent .cookie-consent-link:hover {
  color: var(--accent);
  border-bottom-color: var(--accent);
}
.cookie-consent .cookie-btn {
  min-height: 36px;
  padding: 0.42rem 1.1rem;
  border-radius: 999px;
  font-size: 0.82rem;
  font-weight: 600;
}

@media (max-width: 640px) {
  /* Bottom sheet: full width, flush to the bottom edge, rounded only on top.
     Sits above the FAB and dock, which are bottom-right.

     The `:not([hidden])` guard is load-bearing, not cosmetic. Visibility is
     driven purely by the `hidden` attribute (main.js:757), and a bare
     `.cookie-consent { display: grid }` here would out-order the UA's
     `[hidden] { display: none }` — re-showing the consent sheet to every
     visitor who had already accepted or rejected. */
  .cookie-consent:not([hidden]) {
    left: 0;
    right: 0;
    bottom: 0;
    width: auto;
    border-radius: var(--radius) var(--radius) 0 0;
    border-left: 0;
    border-right: 0;
    border-bottom: 0;
    padding: 0.95rem 1.15rem calc(0.95rem + env(safe-area-inset-bottom, 0px));
    animation-name: cookieSheetRise;
    /* The glyph leads the copy instead of stacking above it. On a phone that
       gives back a whole row of vertical space without the sheet losing the
       detail that makes it feel designed rather than generic. */
    display: grid;
    grid-template-columns: auto 1fr;
    column-gap: 0.7rem;
    row-gap: 0.8rem;
    align-items: start;
  }
  .cookie-consent::before {
    width: 26px;
    height: 26px;
    margin-bottom: 0;
    border-radius: 8px;
  }
  .cookie-consent-text {
    grid-column: 2;
    margin-bottom: 0;
    font-size: 0.8rem;
    line-height: 1.5;
  }
  /* Full-width tap targets; the policy link drops to its own line above. */
  .cookie-consent-actions {
    grid-column: 1 / -1;
    flex-wrap: wrap;
  }
  .cookie-consent .cookie-btn { flex: 1; min-height: 44px; }
  .cookie-consent .cookie-consent-link {
    order: -1;
    flex-basis: 100%;
    margin: 0 0 0.15rem;
  }
}

/* A small offset, never translateY(100%). With `both` fill the element holds
   the from-state until the animation runs, so a full-height offset leaves the
   consent banner completely off-screen if animations are deferred or blocked —
   and a consent control must always be reachable. */
@keyframes cookieSheetRise {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: translateY(0); }
}

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

/* The 3D wheel is a desktop device: JS gates it to `wide`, so on phones the
   section degrades to a bare list of the same eight service names that the
   cards below already carry in full. Two lists of the same thing, the first
   with no heading, is what made the mobile services page read as noise.
   Hidden rather than restyled — nothing is lost, the cards are the content. */
@media (max-width: 900px) {
  #service-wheel { display: none; }
}
