@charset "UTF-8";

/* Hellos — pattern C
   Four layers, front to back: readout (DOM) / frosted glass / creatures /
   blue field. Only the readout lives here; the other three are drawn by
   assets/js/hero-c.js into <canvas class="stage">.

   Scroll is the clock. hero-c.js publishes the scroll position as --scroll
   (0–1) on <html>, the creatures fall in the canvas, and everything in this
   file that appears or leaves reads that same number. No scroll listeners
   here, no IntersectionObservers — one value, one source. */

:root {
  /* duplicated in hero-c.js (FIELD_TOP / FIELD_BOTTOM) so the page never
     flashes a different blue before WebGL takes over. */
  --field: #2b4fa0;
  --ink: #fff;

  --mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --slab: 'Trench Slab', 'Times New Roman', serif;
  --display: 'Cabinet Grotesk', 'Arial Black', sans-serif;
  /* the notification is the one thing on the page pretending to be iOS, so it
     asks for the system UI face and nothing else */
  --system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;

  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

  --hud: clamp(10px, 1.02vw, 17px);
  --pad-x: clamp(20px, 3.4vw, 62px);
  --pad-y: clamp(18px, 3vw, 52px);
  --rule: max(1.2px, 0.11vw);
  --map-ui-alpha: calc(1 - clamp(0, calc((var(--map) - 0.44) / 0.10), 1));
}

@property --letter-pressure {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

* { box-sizing: border-box; }

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

html {
  /* hero-c.js overwrites these every scroll event; the fallbacks keep the page
     coherent for the frame before the module boots. --scroll is the B sub-clock
     (0..1 across the first 55% of the page, capped at 0.92 so it never reaches
     B's landing); --map is simply the GLOBAL scroll position (0..1), which the
     map chapter and the footer both read. */
  --scroll: 0;
  --map: 0;
  background: var(--field);
}

body {
  margin: 0;
  font-family: var(--display);
  font-weight: 800;
  color: var(--ink);
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
}

/* the only element with height — scrolling this is what makes the cast fall.
   Pattern C runs the whole PRESERVED part of B over the first ~55% of the page
   (SPLIT = 0.55) and gives the rest to the overhead map chapter, which overlaps
   the whisper's tail. hero-c.js publishes --scroll (the B sub-clock, capped at
   B_END = 0.92 so B's landing/skyline never fire) and --map (the global scroll
   position), so every B-era rule below keeps working unchanged. */
.scroll-track { height: 640vh; }

.scope {
  position: fixed;
  inset: 0;
  overflow: hidden;
}

.stage {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

.ui {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  animation: ui-in 650ms 80ms var(--ease-out) forwards;
}

/* The map chapter is intentionally free of instrument copy and framing. The
   notification/whisper finish on their own timelines; only the persistent HUD
   clears here so the final map has no surrounding words, grid-like corners or
   tracking brackets. */
.trackers {
  opacity: var(--map-ui-alpha);
}

@keyframes ui-in { to { opacity: 1; } }

/* ───────────────────────  the instrument  ─────────────────────── */

.r {
  position: absolute;
  margin: 0;
  font-family: var(--mono);
  font-size: var(--hud);
  font-weight: 400;
  line-height: 1.5;
  letter-spacing: 0.055em;
  white-space: nowrap;
}

.r-bearing {
  top: var(--pad-y);
  right: var(--pad-x);
  text-align: right;
  letter-spacing: 0.08em;
  opacity: calc(1 - clamp(0, calc((var(--map) - 0.86) / 0.08), 1));
}

.r-bearing span { display: block; }

/* The name, set in Trench Slab across the middle of the frame. The cast falls
   behind it — the readout is the only layer in front of the glass, so the
   creatures pass under the word rather than over it.

   6 glyphs of a slab at roughly 0.56em advance ≈ 3.4em, so 17vw fills about
   three fifths of the measure at any width without measuring anything. */
/* Trench Slab's variable axis runs 300–700, and the name walks it as you
   scroll: it starts light — thin enough that the slabs read as hairlines —
   and thickens as the page moves, so the word gains presence right at the
   moment it starts to leave. The exit is deliberately slower than the weight
   so you see it swell before it goes.

   The swell is the weight axis itself, walked 300→700 — not the stroke an
   earlier pass reached for. That pass thought Fontshare's cut only drew at its
   five named weights, but that came from how we loaded it then: static-instance
   CSS, five separate files with nothing between them to interpolate. The @1
   link serves one true variable font — fvar 300–700, gvar outlines, a
   near-linear avar — and off the live DOM at 100px the width climbs
   continuously (300 → 279.8px, 350 → 282.7, 400 → 285.7), no step anywhere, so
   font-weight carries the swell on its own. */
.r-mark {
  --swell-raw: clamp(0, calc(var(--scroll) / 0.18), 1);
  --swell: calc(
    var(--swell-raw) * var(--swell-raw)
    * (3 - 2 * var(--swell-raw))
  );
  --swell-pulse: calc(
    4 * var(--swell-raw) * (1 - var(--swell-raw))
  );
  --mark-weight: calc(300 + 400 * var(--swell));

  top: 50%;
  left: 50%;
  font-family: var(--slab);
  font-size: clamp(54px, 17vw, 300px);
  font-weight: var(--mark-weight);
  letter-spacing: -0.018em;
  line-height: 0.86;
  /* the centred resting place. The swell and this placement are all the
     variants share — each [data-fx] branch below owns the exit. Kept on the
     base so the name is centred for the frame before hero-c.js tags <html>;
     every branch's transform equals this at scroll 0, so nothing snaps. */
  transform: translate(-50%, -50%);
}

.r-mark,
.r-markmini {
  text-transform: none;
}

.r-mark__swell {
  display: block;
  transform-origin: 50% 56%;
  transform:
    scaleX(calc(1 - 0.006 * var(--swell-pulse)))
    scaleY(calc(1 + 0.024 * var(--swell-pulse)));
}

.r-mark__word {
  display: block;
  transform-origin: 50% 56%;
  animation: mark-appear 560ms 100ms var(--ease-out) both;
}

@keyframes mark-appear {
  from {
    opacity: 0;
    filter: blur(3px);
    transform: translateY(0.04em) scale(0.985);
  }
  to {
    opacity: 1;
    filter: blur(0);
    transform: translateY(0) scale(1);
  }
}

.r-mark__letter {
  --receive-x: 0.018em;
  --receive-scale-x: 0.96;
  --receive-scale-y: 1.018;
  position: relative;
  display: inline-block;
}

.r-mark__letter:first-child {
  --receive-x: 0em;
  --receive-scale-x: 1;
  --receive-scale-y: 1;
}

.r-mark__letter::before {
  content: attr(data-char);
  visibility: hidden;
  font-weight: var(--mark-weight);
}

.r-mark__glyph {
  --letter-pressure: 0;
  position: absolute;
  top: 0;
  left: 50%;
  font-weight: calc(
    var(--mark-weight)
    + (700 - var(--mark-weight)) * var(--letter-pressure)
  );
  transform-origin: 0% 60%;
  animation:
    mark-letter-pressure 460ms
    calc(500ms + var(--i) * 80ms)
    both;
}

.r-mark__letter:nth-child(1) { --i: 0; }
.r-mark__letter:nth-child(2) { --i: 1; }
.r-mark__letter:nth-child(3) { --i: 2; }
.r-mark__letter:nth-child(4) { --i: 3; }
.r-mark__letter:nth-child(5) { --i: 4; }
.r-mark__letter:nth-child(6) { --i: 5; }

@keyframes mark-letter-pressure {
  0% {
    --letter-pressure: 0;
    transform: translateX(-50%) scaleX(1) scaleY(1);
    animation-timing-function: var(--ease-in-out);
  }
  20% {
    --letter-pressure: 0.18;
    transform:
      translateX(calc(-50% + var(--receive-x)))
      scaleX(var(--receive-scale-x))
      scaleY(var(--receive-scale-y));
    animation-timing-function: var(--ease-out);
  }
  40% {
    --letter-pressure: 1;
    transform: translateX(calc(-50% + 0.006em)) scaleX(1.035) scaleY(0.988);
    animation-timing-function: var(--ease-in-out);
  }
  65% {
    --letter-pressure: 0.28;
    transform: translateX(calc(-50% + 0.010em)) scaleX(0.987) scaleY(1.006);
    animation-timing-function: var(--ease-out);
  }
  100% {
    --letter-pressure: 0;
    transform: translateX(-50%) scaleX(1) scaleY(1);
  }
}

/* ── how the name leaves: an A/B fork ──────────────────────────────────────
   Temporary. hero-c.js reads ?fx= and tags <html data-fx="focus|capture|fade">;
   each branch here is one candidate for the exit, and only the exit differs —
   the swell above is shared. When the comparison is settled, fold the winner's
   declarations back into .r-mark and delete the other two branches, these
   [data-fx] wrappers, and the ?fx hook in hero-c.js.

     fade     the original — drifts up a touch and dissolves.
     focus    a rack focus: the lens pulls off the glass to the cast behind, so
              the name defocuses into a soft cloud and is gone.
     capture  the word shrinks and flies to the bottom-left instrument slot,
              filed as the mini mark — no fade, a hard cut on landing. */

/* fade — drift and dissolve. The timing leaves the axis room to finish first:
   the swell runs across 0→0.15 of scroll while the fade holds off until
   0.05→0.17, so the word hits its full 700 just as it dissolves — it leaves at
   its heaviest. */
[data-fx="fade"] .r-mark {
  transform: translate(-50%, calc(-50% - var(--scroll) * 9vh));
  opacity: calc(1 - clamp(0, calc((var(--scroll) - 0.05) / 0.12), 1));
}

/* focus — the depth of field pulls from the name to the cast behind the glass.
   --defocus is squared so the blur builds slowly and then collapses, the way a
   real lens racks; the opacity holds through the blur and only clears once it
   has gone soft, so the word reads as dissolved by defocus rather than faded.
   The drift is unchanged from fade. */
[data-fx="focus"] .r-mark {
  --defocus: clamp(0, calc((var(--scroll) - 0.05) / 0.12), 1);
  transform: translate(-50%, calc(-50% - var(--scroll) * 9vh));
  filter: blur(calc(var(--defocus) * var(--defocus) * 16px));
  opacity: calc(1 - clamp(0, calc((var(--scroll) - 0.13) / 0.09), 1));
  will-change: filter;
}

/* capture — the name is stowed into the instrument. It shrinks along a
   smoothstep (--te) from the centre to the bottom-left mini-mark slot and cuts
   on arrival, in the same pose-cut language the cast falls in — no fade. The
   landing is exact at t=1: the −50% self-centring terms fall to zero there, so
   the box's own changing width and height stop mattering and only pad-x / pad-y
   place it. The 9vh drift is dropped here — the travel is the exit. */
[data-fx="capture"] .r-mark {
  --t: clamp(0, calc((var(--scroll) - 0.05) / 0.19), 1);
  --te: calc(var(--t) * var(--t) * (3 - 2 * var(--t)));
  font-size: calc((1 - var(--te)) * clamp(54px, 17vw, 300px) + var(--te) * var(--hud) * 1.24);
  letter-spacing: calc((1 - var(--te)) * -0.018em + var(--te) * 0.06em);
  transform: translate(
    calc((1 - var(--te)) * -50% + var(--te) * (var(--pad-x) - 50vw)),
    calc((1 - var(--te)) * -50% + var(--te) * (50vh - var(--pad-y) - 100%))
  );
  opacity: calc(1 - clamp(0, calc((var(--scroll) - 0.235) / 0.005), 1));
}

/* under capture the mini mark is filed at the instant the big word cuts out —
   a hard swap at the same 0.235→0.24 step, overriding its usual slow arrival */
[data-fx="capture"] .r-markmini {
  opacity: clamp(0, calc((var(--scroll) - 0.235) / 0.005), 1);
}

/* reduced motion: focus's blur and capture's flight are both motion / visual
   load, so under reduced motion both fall back to the fade — the drift and
   dissolve, nothing else. (fade is already that, so it needs no entry.) */
@keyframes mark-appear-reduced {
  from { opacity: 0; }
  to { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .r-mark__swell { transform: none; }
  .r-mark__word {
    clip-path: none;
    filter: none;
    transform: none;
    animation: mark-appear-reduced 200ms var(--ease-out) both;
  }
  .r-mark__glyph {
    --letter-pressure: 0;
    animation: none;
    transform: translateX(-50%);
  }

  [data-fx="focus"] .r-mark,
  [data-fx="capture"] .r-mark {
    font-size: clamp(54px, 17vw, 300px);
    letter-spacing: -0.018em;
    filter: none;
    transform: translate(-50%, calc(-50% - var(--scroll) * 9vh));
    opacity: calc(1 - clamp(0, calc((var(--scroll) - 0.05) / 0.12), 1));
  }
  [data-fx="capture"] .r-markmini {
    opacity: clamp(0, calc((var(--scroll) - 0.24) / 0.13), 1);
  }
}

/* The persistent product signature. It keeps the logo face, but at instrument
   scale so the central word can still own the opening. */
.r-markmini {
  top: var(--pad-y);
  bottom: auto;
  left: var(--pad-x);
  font-family: var(--slab);
  font-size: calc(var(--hud) * 1.24);
  /* far less than the big one — a slab this small reads solid well before 700,
     so it only walks to 560 */
  font-weight: calc(400 + 160 * clamp(0, calc(var(--scroll) / 0.4), 1));
  letter-spacing: -0.01em;
  opacity: 1;
}

/* Local date, time and timezone-derived city share the lower-left reading. */
.r-user {
  bottom: var(--pad-y);
  left: var(--pad-x);
  font-variant-numeric: tabular-nums;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  opacity: calc(1 - clamp(0, calc((var(--map) - 0.74) / 0.10), 1));
}

.r-user span { display: block; }

.r-social {
  right: var(--pad-x);
  bottom: var(--pad-y);
  padding: 14px;
  margin: -14px;
  pointer-events: auto;
  color: var(--ink);
  text-align: right;
  text-decoration: none;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  opacity: calc(1 - clamp(0, calc((var(--map) - 0.74) / 0.10), 1));
}

.r-social::after {
  content: ' ↗';
}

.r-social:hover,
.r-social:focus-visible {
  text-decoration: underline;
  text-underline-offset: 0.3em;
}

.scroll-cue {
  position: absolute;
  left: 50%;
  bottom: max(28px, 4vh);
  margin: 0;
  transform: translateX(-50%);
  font-family: var(--mono);
  font-size: clamp(10px, 0.85vw, 13px);
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  white-space: nowrap;
  pointer-events: none;
  opacity: calc(1 - clamp(0, calc((var(--scroll) - 0.015) / 0.055), 1));
}

/* ───────────────────────  trackers  ─────────────────────── */

/* hero-c.js writes --tx/--ty/--tw/--th (px), --live and the one-shot
   --lock-scale onto each tracker every frame. --live also handles the wrap: it
   drops to 0 at the very top and bottom of a lane so a bracket never blinks out
   mid-frame. */
.tracker {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--tw, 0px);
  height: var(--th, 0px);
  transform:
    translate3d(var(--tx, 0px), var(--ty, 0px), 0)
    scale(var(--lock-scale, 1));
  transform-origin: 50% 50%;
  opacity: var(--live, 0);
}

.tracker i {
  position: absolute;
  /* both arms cut to one length — off the box's shorter side, so the corner
     stays a square L even when the tracker itself is stretched tall or wide */
  --arm: clamp(8px, calc(min(var(--tw, 0px), var(--th, 0px)) * 0.14), 24px);
  width: var(--arm);
  height: var(--arm);
  border: var(--rule) solid var(--ink);
  /* quiet enough to read as instrument markings, not a second subject */
  opacity: 0.35;
}

.tracker i:nth-child(1) { top: 0; left: 0; border-right: 0; border-bottom: 0; }
.tracker i:nth-child(2) { top: 0; right: 0; border-left: 0; border-bottom: 0; }
.tracker i:nth-child(3) { bottom: 0; left: 0; border-right: 0; border-top: 0; }
.tracker i:nth-child(4) { bottom: 0; right: 0; border-left: 0; border-top: 0; }

.tracker b {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 0;
  height: 0;
}

.tracker b::before,
.tracker b::after {
  content: '';
  position: absolute;
  background: var(--ink);
}

.tracker b::before {
  width: clamp(8px, 0.75vw, 13px);
  height: var(--rule);
  transform: translate(-50%, -50%);
}

.tracker b::after {
  width: var(--rule);
  height: clamp(8px, 0.75vw, 13px);
  transform: translate(-50%, -50%);
}

/* ═══════════════════  the notification  ═══════════════════ */

/* It drops in from above the top edge, the way iOS delivers one.

   hero-c.js writes --drop (the spring: 0 = offstage above, 1 = settled, and it
   passes 1 briefly on the overshoot) and --lit (opacity). The spring is solved
   in JS because neither a transition nor a cubic-bezier can produce a damped
   oscillation, and this one has to stay locked to scroll position rather than
   run on a clock — scrub back up and the banner springs back out the way it
   came.

   It still enters from above the top edge, but it comes to rest in the middle
   of the frame rather than under the status bar — this is a page, not a phone,
   and the centre is where the eye already is. --gap is the distance from that
   resting place out past the top of the viewport, so the banner always starts
   fully offstage however tall it grows. */
.notice {
  --drop: 0;
  --lit: 0;
  --gap: calc(50vh + 70%);

  position: absolute;
  top: 50%;
  left: 50%;
  width: min(460px, calc(100vw - 30px));
  opacity: var(--lit);
  /* scale trails the slide slightly, so it reads as glass settling into the
     frame rather than as a card being resized */
  transform:
    translate(-50%, calc(-50% + (var(--drop) - 1) * var(--gap)))
    scale(calc(0.965 + var(--drop) * 0.035));
}

/* ── Liquid Glass ──
   Four things make it read as glass rather than as a frosted rectangle, and
   all four are doing work here:

   1. the backdrop is not just blurred but brightened and saturated — glass
      concentrates what is behind it, it doesn't only diffuse it
   2. a lit rim traced round the whole edge (::before), brightest where a
      light above-left would catch it and again on the opposite shoulder
   3. light pooling on the inner top surface (::after) — this is what gives
      the panel thickness instead of reading as a flat sheet
   4. a shadow far enough below to say the thing is floating, not printed

   The layout numbers stay iOS's: 38px icon, 15px title over a 13px app line. */
.notice__card {
  position: relative;
  display: flex;
  gap: 14px;
  align-items: flex-start;
  padding: 17px 19px 18px;
  border-radius: 30px;
  /* Darkened rather than whitened. The first pass brightened the backdrop,
     which is right for glass over a light wall but wrong here — the type is
     white, so lifting the panel toward white ate the contrast underneath it.
     Taking the backdrop down instead keeps it reading as glass while giving
     the text something to sit on. */
  background: rgba(9, 24, 62, 0.32);
  backdrop-filter: blur(30px) saturate(145%) brightness(0.82);
  -webkit-backdrop-filter: blur(30px) saturate(145%) brightness(0.82);
  box-shadow:
    0 22px 55px -14px rgba(3, 10, 32, 0.55),
    0 5px 16px -6px rgba(3, 10, 32, 0.35);
  font-family: var(--system);
  color: #fff;
  isolation: isolate;
}

/* the rim. A gradient border done as a masked ring rather than a border, so
   it can vary in brightness around the edge the way a real bevel catches
   light — a plain border colour is the single biggest tell of fake glass. */
.notice__card::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  padding: 1px;
  background: linear-gradient(
    148deg,
    rgba(255, 255, 255, 0.92) 0%,
    rgba(255, 255, 255, 0.28) 22%,
    rgba(255, 255, 255, 0.07) 48%,
    rgba(255, 255, 255, 0.22) 74%,
    rgba(255, 255, 255, 0.72) 100%
  );
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  pointer-events: none;
}

/* the thickness: light gathering just inside the top surface, plus a wide
   soft pool spilling in from above the panel */
.notice__card::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  background: radial-gradient(
    120% 80% at 50% -18%,
    rgba(255, 255, 255, 0.11),
    transparent 68%
  );
  pointer-events: none;
}

/* content rides above both glass layers */
.notice__icon,
.notice__body { position: relative; z-index: 2; }

.notice__icon {
  flex: 0 0 auto;
  width: 52px;
  height: 52px;
  padding: 4px;
  border: 1px solid rgba(255, 255, 255, 0.28);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.12);
  box-shadow: 0 6px 18px rgba(6, 16, 42, 0.2);
  object-fit: cover;
}

.notice__body {
  flex: 1 1 auto;
  min-width: 0;
}

.notice__body p { margin: 0; }

.notice__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 4px;
  font-size: 14px;
  line-height: 1.25;
  letter-spacing: 0.01em;
  color: rgba(255, 255, 255, 0.74);
}

.notice__app {
  font-weight: 500;
  letter-spacing: 0.01em;
}

.notice__time { flex: 0 0 auto; }

.notice__title {
  font-size: 18px;
  font-weight: 600;
  line-height: 1.28;
  letter-spacing: -0.012em;
}

.notice__body .notice__text {
  margin-top: 2px;
  font-size: 16px;
  font-weight: 400;
  line-height: 1.28;
  letter-spacing: -0.012em;
  color: rgba(255, 255, 255, 0.93);
}

/* ═══════════════════  the whisper  ═══════════════════ */

/* What sent the notification, talking. It comes in after the banner has gone,
   and it lights up a beat at a time as you scroll — hero-c.js gives every <i>
   its own --from, so each one crosses from dim to lit a fraction of the page
   after the one before it. That staggering is the whole effect: read all at
   once it's a paragraph, read in sequence it's a voice.

   Unlit text sits at 0.16 rather than 0 so the shape of the whole sentence is
   visible before it is spoken — you can see how much more it intends to say. */
.whisper {
  --start: 0.72;
  --step: 0.011;
  --span: 0.02;

  position: absolute;
  top: 50%;
  left: 50%;
  width: min(960px, calc(100vw - var(--pad-x) * 2));
  transform: translate(-50%, -50%);
  font-family: var(--display);
  font-size: clamp(23px, 3.3vw, 52px);
  font-weight: 800;
  line-height: 1.08;
  letter-spacing: -0.03em;
  text-align: center;
  text-wrap: balance;
}

.whisper p { margin: 0 0 0.46em; }
.whisper p:last-child { margin-bottom: 0; }

/* direct children only — the typing dots are <i> too, and they run on their
   own clock rather than on the scroll */
.whisper p > i {
  font-style: normal;
  /* --from is written per element by hero-c.js */
  opacity: calc(0.16 + 0.84 * clamp(0, calc((var(--scroll) - var(--from, 9)) / var(--span)), 1));
}

/* ── who is speaking ──
   The voice needs an owner or the lines read as page copy. This is the same
   face the notification came from, and it arrives a couple of beats before the
   first line — you see who is about to speak just before they do. */
.whisper p.whisper__from {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  margin-bottom: clamp(20px, 1.8vw, 28px);
  font-family: var(--display);
  font-size: clamp(13px, 1.05vw, 16px);
  font-weight: 800;
  line-height: 1;
  letter-spacing: 0.025em;
  opacity: calc(clamp(0, calc((var(--scroll) - var(--from, 9)) / var(--span)), 1));
}

.whisper__avatar {
  width: clamp(40px, 3.2vw, 48px);
  height: clamp(40px, 3.2vw, 48px);
  padding: 3px;
  border: 1px solid rgba(255, 255, 255, 0.28);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.12);
  box-shadow: 0 6px 18px rgba(6, 16, 42, 0.2);
  object-fit: cover;
}

.whisper__who { opacity: 0.9; }

/* the typing indicator: the one thing on the page that moves on its own clock.
   Everything else waits for the scroll — this doesn't, which is the point. It
   goes quiet once the last line has been said. */
.whisper__typing {
  display: inline-flex;
  gap: 3px;
  align-items: center;
  opacity: calc(1 - clamp(0, calc((var(--scroll) - 0.85) / 0.03), 1));
}

.whisper__typing i {
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: currentColor;
  animation: whisper-typing 1.25s infinite ease-in-out;
}

.whisper__typing i:nth-child(2) { animation-delay: 0.16s; }
.whisper__typing i:nth-child(3) { animation-delay: 0.32s; }

@keyframes whisper-typing {
  0%, 62%, 100% { opacity: 0.28; }
  31% { opacity: 1; }
}

/* the two lines that are not reporting anything, but answering */
.whisper__beat { font-weight: 800; }

/* the live fields hold their own width so the line doesn't reflow every second
   as the dwell counter ticks */
.whisper i[data-live] { font-variant-numeric: tabular-nums; }

/* ───────────────────────  portrait  ─────────────────────── */

@media (max-aspect-ratio: 4/5) {
  .r-markmini, .r-bearing { top: calc(var(--pad-y) + 2.2em); }
}

@media (max-width: 640px) {
  .r-bearing {
    right: calc(var(--pad-x) + 8px);
  }

  .notice {
    width: min(420px, calc(100vw - 36px));
  }

  .notice__card {
    gap: 11px;
    padding: 14px 15px 15px;
    border-radius: 25px;
  }

  .notice__icon {
    width: 44px;
    height: 44px;
    padding: 3px;
  }

  .notice__head {
    gap: 8px;
    margin-bottom: 3px;
    font-size: 12px;
  }

  .notice__title { font-size: 16px; }

  .notice__body .notice__text {
    font-size: 14px;
    line-height: 1.34;
  }

  .scroll-cue {
    bottom: max(30px, 4vh);
  }
}

/* ───────────────────────  the footer  ─────────────────────── */

/* The bottom of the page, under the ground line the cast lands on (GROUND in
   hero-c.js). Everything here arrives after the whisper has cleared and while
   the cast is still coming down, so the words settle a beat after the bodies
   do — you see them land, then you read why.

   Two things, each on its own delay: the plain fact of where this starts,
   and the way in. */

.foot {
  --foot-span: 0.04;

  position: absolute;
  left: 50%;
  /* Tie the closing copy to the lower edge of the map instead of the viewport
     bottom. A proportional top position holds the composition together across
     tall, square and wide viewports without crowding the browser edge. */
  top: 74vh;
  bottom: auto;
  transform: translateX(-50%);
  width: min(820px, calc(100vw - var(--pad-x) * 2));
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

/* Driven by --map, which in pattern C is just the GLOBAL scroll position: the
   words arrive over the very last of the page (0.90–1.0), as the last pins are
   planting on the map, instead of at the end of the fall. The rise and the
   stagger are the same shape B used, only on the new clock. */
.foot > * {
  margin: 0;
  opacity: clamp(0, calc((var(--map) - var(--in)) / var(--foot-span)), 1);
  transform: translateY(calc((1 - clamp(0, calc((var(--map) - var(--in)) / var(--foot-span)), 1)) * 0.7em));
  /* the map behind is busy hairline-on-blue; a soft shadow keeps the readout
     legible over it without a solid plate that would break the instrument feel */
  text-shadow: 0 1px 18px rgba(6, 16, 42, 0.55), 0 1px 3px rgba(6, 16, 42, 0.5);
}

.foot__say  { --in: 0.92; }
.foot__cta  { --in: 0.95; }

.foot__say {
  font-family: var(--display);
  font-size: clamp(24px, 3.6vw, 54px);
  font-weight: 800;
  letter-spacing: -0.035em;
  text-wrap: balance;
}

/* The one control on the page. A compact black launch CTA breaks cleanly from
   the blue map and gives the closing screen one obvious action. The inner shell
   owns hover and press transforms so the link's scroll entrance stays intact. */
.foot__cta {
  margin-top: clamp(18px, 1.7vw, 28px);
  pointer-events: auto;
  color: var(--ink);
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}

.foot__cta-shell {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5em;
  width: clamp(210px, 22vw, 280px);
  min-height: clamp(60px, 5.8vw, 76px);
  padding: 0.92em 1.4em;
  border: 0;
  border-radius: 999px;
  background: linear-gradient(180deg, #2a2a2a 0%, #111 54%, #050505 100%);
  box-shadow:
    inset 0 2px 2px rgba(255, 255, 255, 0.15),
    inset 0 0 0 1px rgba(255, 255, 255, 0.07),
    inset 0 -6px 7px rgba(0, 0, 0, 0.38),
    0 14px 26px rgba(4, 8, 18, 0.34),
    0 25px 50px rgba(4, 8, 18, 0.2);
  font-family: var(--display);
  font-size: clamp(17px, 1.45vw, 21px);
  font-weight: 800;
  line-height: 1;
  letter-spacing: -0.035em;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.55);
  transition:
    transform 220ms var(--ease-out),
    filter 180ms var(--ease-out),
    box-shadow 220ms var(--ease-out);
}

.foot__cta-logo {
  width: 0.82em;
  height: auto;
  flex: none;
}

.foot__cta:focus-visible {
  outline: none;
}

.foot__cta:focus-visible .foot__cta-shell {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

.foot__cta:active .foot__cta-shell {
  transform: translateY(3px) scale(0.98);
}

@media (hover: hover) and (pointer: fine) {
  .foot__cta:hover .foot__cta-shell {
    transform: translateY(3px) scale(0.99);
    filter: brightness(0.94);
    box-shadow:
      inset 0 3px 4px rgba(0, 0, 0, 0.42),
      inset 0 0 0 1px rgba(255, 255, 255, 0.05),
      inset 0 -4px 6px rgba(0, 0, 0, 0.34),
      0 8px 16px rgba(4, 8, 18, 0.3),
      0 14px 28px rgba(4, 8, 18, 0.16);
  }

  .foot__cta:active .foot__cta-shell {
    transform: translateY(5px) scale(0.975);
    box-shadow:
      inset 0 2px 3px rgba(0, 0, 0, 0.35),
      inset 0 0 0 1px rgba(255, 255, 255, 0.04),
      inset 0 -4px 6px rgba(0, 0, 0, 0.38),
      0 8px 16px rgba(4, 8, 18, 0.26);
  }
}

@media (prefers-reduced-motion: reduce) {
  .foot__cta-shell {
    transition-property: filter, box-shadow;
  }

  .foot__cta:hover .foot__cta-shell,
  .foot__cta:active .foot__cta-shell {
    transform: none;
  }
}

/* The skyline that used to live here as an <img> is drawn by hero-c.js inside
   the canvas now — behind the cast, in front of the field — so the landed row
   stands in the street instead of floating over the picture. Its dusk dimming
   and late arrival moved with it (CITY_* in hero-c.js). */

/* The voice is not standing there through the whole descent.
 *
 * Its beats rest at 0.16 opacity so they read as unsaid rather than absent,
 * but that also meant the whole paragraph was faintly legible from the top of
 * the page — you could read the ending before the notification had even
 * arrived. Gating the block fixes that: it turns up only once the banner has
 * been and gone, which is the moment the thing that sent it starts talking.
 *
 * And it clears again before the landing, so what you are left looking at is
 * the cast rather than the words about them. That is the whole descent: you
 * stop being told they are there, and you see them. */
.whisper {
  opacity: calc(
    clamp(0, calc((var(--scroll) - 0.625) / 0.06), 1)
    * (1 - clamp(0, calc((var(--map) - 0.61) / 0.08), 1))
  );
}

/* ───────────────────────  no-js  ─────────────────────── */

.fallback {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: var(--hud);
  letter-spacing: 0.12em;
}

@media (prefers-reduced-motion: reduce) {
  .ui { animation-duration: 0.01ms; animation-delay: 0ms; }
}
