/* ============================================================================
   Signal — Narrative Header
   ----------------------------------------------------------------------------
   THE MOVING DIVIDERS DO THE MASKING. THERE IS NO WIPE (YET).

   A 5 x 2 GRID OF TEN MODULES.

     0        gut          --bl              --br         100%-gut    100%
     +---------+------------+-----------------+--------------+---------+
     |   e1t   |    i1t     |       bt        |     i2t      |   e2t   |
     +---------+------------+-----------------+--------------+---------+  --hy
     |   e1b   |    i1b     |       bb        |     i2b      |   e2b   |
     +---------+------------+-----------------+--------------+---------+

   FIVE COLUMNS. The outer two, e1 and e2, are pinned to the PAGE GUTTER: they are
   exactly as wide as the page's content inset, so the inner columns start on the
   content grid and the copy needs NO outer padding to line up with the setup line
   above it. They never move. The middle column, b, is the IMAGE BAND, and it is
   the only column that travels. The two columns either side of it, i1 and i2,
   trade places: one is a narrow sliver, the other holds the copy. So a narrow
   column sits at each frame edge in every step, and the composition mirrors as
   the band swings across.

   ONE shared horizontal line at --hy runs the FULL WIDTH and splits every column
   at the same height, which is what makes the modules countable. The image
   occupies ONE band module, bt or bb, so that line is always the picture's own
   edge and never crosses it.

   THE MOVING DIVIDERS SPAWN FROM THE GRID AND MERGE BACK INTO IT. At rest the
   band's outer divider sits exactly ON a gutter divider, so the column between
   them has ZERO width and its two modules vanish: you see FOUR columns and EIGHT
   modules, with three vertical lines, not five and ten. The fifth column only
   exists while the band is travelling. So ax is 0 on the steps where the band is
   left, and bx is 1 on the steps where it is right, which is also why the two
   dividers between them sweep the entire inner span.

     at rest, band left            at rest, band right
     +----+---------+------+       +----+------+---------+
     | e1 |  IMAGE  | copy |       | e1 | copy |  IMAGE  |
     +----+---------+------+       +----+------+---------+
          ^ bl merged                            br merged ^
            into gl                                into gr

   --bl and --br are the band's two edges as lengths, derived once here and reused
   by the modules, the rules, the points and the clips, so there is one definition
   of where the band is.

   HOW AN IMAGE IS REVEALED. The image is NOT laid out inside the band. It is
   PINNED in frame space at the exact position and size its band module will
   settle into, and the module has overflow:hidden. So as the dividers travel, the
   module slides across a stationary image and uncovers it. When they arrive,
   module and image coincide exactly and the picture is whole and correctly
   composed. Nothing scales, nothing re-crops, nothing fades.

     during the move            settled
     +--------+                      +----------+
     | module |  image pinned here   |mod+image |
     +--------+  - - - - -+          +----------+
                |  image  |           coincide
                + - - - - +

   WHY THE BAND IS THE FIXED COLUMN. The reveal needs the image's module to travel
   clear of its own pinned plate. If the band and the copy simply swapped columns
   the outgoing image would sit still while its module only changed width, so the
   old picture would still be on screen when the layer is dropped. The band is
   therefore one column that MOVES, and consecutive positions must not overlap.

   ON THE FULL WIPE. The band can be pushed hard against either gutter, so a
   divider CAN travel the entire inner width and an edge-to-edge wipe is reachable
   by geometry alone. Deliberately not built; the shipped transition is the swing.

   Direction A (rails sweeping a fixed grid) is preserved in narrative-grid.css.
   Depends on signal.css for tokens and .eyebrow / .mono / .wrap / .btn.
   ============================================================================ */

/* Registering these is what makes them interpolate. Without @property they are
   strings and every composition would snap instead of easing. */
@property --ax { syntax: "<number>"; inherits: true; initial-value: 0.02; }
@property --bx { syntax: "<number>"; inherits: true; initial-value: 0.48; }
@property --hy { syntax: "<number>"; inherits: true; initial-value: 0.52; }
/* --veil is registered for the SAME reason: unregistered it is a string, so a module's
   tint SNAPPED from 0.86 to 0 the instant a step changed, which read as the veils
   switching on rather than transitioning. `inherits: true` is required, not incidental:
   the tint is painted by .nb-box::after, and a pseudo-element can only see a custom
   property that inherits from its originating element. initial-value 0 also happens to
   be the right default, so nothing is lost by registration here, unlike --gut where an
   initial-value would have destroyed the page-grid fallback. */
@property --veil { syntax: "<number>"; inherits: true; initial-value: 0; }

.nb-section {
  --nb-line: var(--hairline-soft);
  /* Matches .wrap exactly: max-width 1320 with 40px padding, border-box, centred.
     100% and NOT 100vw, because 100vw includes the scrollbar and .wrap centres
     inside the section's real width. Using vw put the gutter columns 7.5px wide of
     the setup line above them, which is precisely the alignment this is for. */
  --nb-gutter: max(40px, calc((100% - 1320px) / 2 + 40px));
  overflow: hidden;
  padding: 0;
}

/* ---------- the persistent setup ---------- */
/* --nb-top clears a FIXED site header when this sits at the very top of a page, which
   it does: it replaces the hero. weathercompany.com's nav is position:fixed at 112px,
   and the in-situ preview showed 19px of the setup line hidden behind it. The page
   sets the value (the WPCode snippet, or the preview's injected tokens) because only
   the page knows how tall its own chrome is; 0 by default so the sandbox is unchanged. */
/* THE GEOMETRY LIVES ON THE SECTION, not the frame, so the header above the frame can
   read --bl and sit on the band's edge. The JS animates --ax / --bx / --hy here and the
   frame inherits them. Moving these down to the frame again would break the header
   alignment silently: it would resolve --bl from the unanimated initial values. */
.nb-section {
  --nb-top: 0px;
  --ax: 0.02; --bx: 0.48; --hy: 0.52;
  /* The gutter columns, and the inner span the band travels within. Defaults to the
     page grid so the copy aligns with the setup line for free, but --gutx overrides
     it if you want wider or narrower outer columns. Left UNREGISTERED on purpose:
     an @property with an initial-value is never unset, so the fallback here would
     never apply and the page-grid default would be lost. It therefore does not
     interpolate, which is fine, because it is the FIXED grid and is set once for
     every step rather than animated between them. */
  --gut: var(--gutx, var(--nb-gutter));
  --in: calc(100% - 2 * var(--gut));
  /* The band's two edges, as lengths. Everything else refers to these. */
  --bl: calc(var(--gut) + var(--ax) * var(--in));
  --br: calc(var(--gut) + var(--bx) * var(--in));
}
.nb-head { padding-top: calc(54px + var(--nb-top)); padding-bottom: 30px; }
.nb-head .eyebrow { display: block; margin-bottom: 16px; }

/* HEADER ALIGNMENT, stated EXPLICITLY in both modes.

   It used to lean on .wrap's own centring for the fixed case, which silently broke as
   soon as the gutter columns were overridden: .wrap follows the PAGE-GRID formula
   ((100% - 1320)/2 + 40) while the columns follow --gut, so with the gutter set to 80px
   the header sat ~265px in and the boxes started at 80px. Aligning to --gut directly
   means fixed mode is always on the gutter columns' edge, which IS boxes 3 and 4's edge,
   whatever the gutter is set to.

   .wrap's max-width and auto margins have to be neutralised or the block is centred at
   1320 first and the padding lands in the wrong place. */
.nb-section .nb-head {
  max-width: none;
  margin: 0;
  padding-left: var(--gut);
  /* Only the frame edge constrains it, NOT a column. The header sits above the frame in
     open space, so it needs its left edge on a line, not to fit inside a cell.
     Constraining it squeezed the line to four wraps on the band-right steps. */
  padding-right: 40px;
}
/* Follow the band instead: --bl is animated, so it eases across with everything else
   rather than jumping. */
.nb-section.nb-head-band .nb-head { padding-left: var(--bl); }
.nb-setup {
  margin: 0;
  /* Wide enough to break into TWO lines, not three, and text-wrap:balance then
     evens them so neither line is left short. --hs scales the line. */
  max-width: 38ch;
  font-size: calc(clamp(30px, 3.4vw, 54px) * var(--hs, 1));
  font-weight: 300;
  line-height: 1.08;
  letter-spacing: -0.02em;
  color: var(--white);
  text-wrap: balance;
  /* KEEP THE LAST LINE OUT FROM UNDER THE BUTTON. The CTA is absolutely positioned on
     the setup line's own baseline, so it does not participate in wrapping and long copy
     runs straight underneath it: the Drop story's first draft put "keeps a crew safe"
     123px behind the button and the last word was unreadable. `placeCta()` measures the
     button and writes this reserve, so the fix scales with the label instead of being a
     magic number. 0 when the CTA is off or the layout has gone compact, where the button
     drops below the text and needs no room. */
  padding-right: var(--nb-cta-reserve, 0px);
}
.nb-setup em { color: var(--cyan); font-style: normal; }

/* ---------- the CTA ----------
   "Talk to an expert", carried over from the hero this header replaces. On the live site
   it is `<a class="btn btn-primary">` inside `.hero-ctas`, so the styling here is COPIED
   FROM PRODUCTION rather than eyeballed: `#0F62FE` (--sig-blue, Carbon Blue 60) with
   white text, IBM Plex Sans 600 at 13.5px, 10px/18px padding, 2px radius, hovering to
   `#002D9C`. Read out of `signal-skin.css`, where the skin's `.btn` rule beats
   `.btn-primary`'s cyan-on-navy with `!important`. Keep these in step if the skin moves.

   Positioned absolutely so the existing header markup does not have to be restructured
   in three places (tuner, view page, and the in-situ injection). Its RIGHT edge sits on
   `--gut`, mirroring the setup line's left edge, so the two ends of the header land on
   the same lines as the gutter columns whatever the gutter is set to. `top` clears
   `--nb-top`, the fixed site nav, or the button would sit behind it. */
.nb-head { position: relative; }
.nb-cta {
  position: absolute;
  /* FALLBACK ONLY. `placeCta()` overwrites `top` with a measured value so the button's
     text baseline lands on the LAST LINE of the setup line's baseline. That cannot be
     done in CSS: it needs the line box of the final line plus the font's real ascent and
     descent, and the setup line is 1 or 2 lines depending on width and --hs. */
  top: calc(54px + var(--nb-top, 0px));
  right: var(--gut);
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 18px;
  border-radius: 2px;
  background: #0F62FE;
  color: #FFFFFF;
  font-family: 'IBM Plex Sans', sans-serif;
  font-size: 13.5px;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: 0;
  text-decoration: none;
  white-space: nowrap;
  transition: background 0.15s ease;
}
.nb-cta:hover, .nb-cta:focus-visible { background: #002D9C; }
.nb-cta svg { display: block; flex: none; }
/* Stacked, it stops being a corner element and sits under the line where there is room
   for it, because at 390px an absolute top-right button collides with the headline. */
@media (max-width: 900px) {
  .nb-cta {
    position: static;
    margin-top: 20px;
    align-self: flex-start;
  }
}

/* ---------- frame ---------- */
.nb-frame {
  position: relative;
  width: 100%;
  height: var(--nb-h);
  --nb-h: clamp(480px, 58vh, 660px);
  /* TRANSPARENT, deliberately. The backdrop used to live INSIDE the frame, so an opaque
     frame background sat harmlessly behind it. Now that the video and sweep span the
     SECTION, an opaque frame paints straight over them: the ten modules all tinted flat
     navy and the 0.86/0.72 checkerboard that differentiates them vanished, because a veil
     over nothing looks the same whatever its alpha. The section supplies the navy. */
  background: transparent;
  /* THE HAIRLINES ARE DRAWN INSIDE, NOT AS BORDERS (fixed 2026-08-17). As real borders they
     were the one strip in the header with nothing veiling the backdrop: once the frame went
     transparent the video showed through those 1px rows at full strength, measured at
     luminance 129 against 25 either side. That read as a bright thin bar above the logo
     strip and as "1 or 2 pixels of zero opacity" along the top and bottom. Drawn as
     pseudo-elements at the rules' own layer they sit ABOVE the modules and composite over
     veiled content like every other line in the frame. */
  border: 0;
  overflow: hidden;
  isolation: isolate;

  /* --ax / --bx / --hy / --gut / --in / --bl / --br are declared on .nb-section and
     inherited here, so the header above the frame can align to the band. */

  /* Measured frame size in px, written by the JS. The pinned plates need the
     frame's real dimensions: percentages inside a module resolve against the
     module, which is exactly the thing that is moving. */
  --fw: 1425px;
  --fh: 660px;
}

/* ============================================================================
   THE BACKDROP

   A full-frame plate behind everything, video or still, screened back by --bgo. It
   runs the whole width, under the gutter columns and out to both frame edges, which
   is why it is a child of the frame rather than of any module.

   Stacking, back to front: backdrop (0) -> image layers (1, 2) -> modules (3) ->
   rules (4) -> points (5).
   ============================================================================ */
.nb-bg {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
}
/* --bgo screens back the PLATE only. It used to sit on the wrapper, which meant the
   radar sweep was dimmed by the plate's level as well as by the module veils on top of
   it: 22% cyan at 55% under an 80% veil is about 2%, so the sweep was on but invisible.
   The sweep now carries its own level instead. */
.nb-bg-media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  opacity: var(--bgo, 0.55);
}
/* THE RADAR SWEEP. The rotating conic wedge from the original hero (`.sweep` in
   signal.css), same gradient, same 9s linear spin, same screen blend. Sized at 200% of
   the frame with a 1:1 ratio so the circle always covers it, and centred with negative
   margins: margin percentages resolve against the containing block's WIDTH, and the
   element is square, so -100% centres it on both axes. The frame clips it. */
.nb-sweep {
  position: absolute;
  top: 50%;
  left: 50%;
  /* 200% of the WIDTH was enough to cover the frame. The backdrop now spans the whole
     section, which is much taller, so the circle is sized off the larger of the two axes
     instead: 200vmax guarantees it covers however tall the section gets, and the negative
     margins are half of that so it stays centred. Percentage margins resolve against the
     containing block's WIDTH, which is why they cannot be used here any more. */
  width: 200vmax;
  aspect-ratio: 1 / 1;
  margin-left: -100vmax;
  margin-top: -100vmax;
  border-radius: 50%;
  /* The original's wedge peaked at 0.22, which reads on a bare hero. Here it has module
     veils of 72 to 86% on top of it, so it is raised to survive them and given its own
     --swo level to tune. It stays BEHIND the modules deliberately: putting it above them
     would screen over the copy and cost text contrast. */
  background: conic-gradient(from 0deg,
    rgba(51, 177, 255, 0.55),
    rgba(51, 177, 255, 0) 24%,
    rgba(51, 177, 255, 0) 100%);
  opacity: var(--swo, 1);
  mix-blend-mode: screen;
  animation: nb-spin 9s linear infinite;
  pointer-events: none;
}
@keyframes nb-spin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
/* Held still rather than hidden: the wedge still reads as a radar frame. */
.nb-section.nb-reduce .nb-sweep { animation: none; }

/* The GRAF mesh, over the plate and under everything else. */
.nb-graf { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

/* ---------- the bands outside the frame ----------
   The backdrop now spans the SECTION, so the header band, the HUD and the logo strip sit
   over live video instead of flat navy. At the plate's own 55% that is far too strong out
   there: inside the frame the module veils (72 to 86%) knock it back to a hint, and these
   bands need the same treatment. A scrim on each is what does it.

   0.88 leaves the footage and the sweep just perceptible, which is what was asked for,
   without costing the headline any contrast. They are `background-color` longhand so the
   mesh, which paints beneath, is unaffected, and they are positioned so they stack above
   the backdrop rather than under it. */
.nb-section .nb-head,
.nb-section .nb-hud,
.nb-section .nb-logo-train {
  position: relative;
  z-index: 1;
  background-color: rgba(0, 17, 65, 0.88);
}

/* ============================================================================
   THE TEN MODULES
   ============================================================================ */
/* The frame's top and bottom hairlines. z-index 4 matches .nb-rule so they belong to the
   same family of lines rather than reading as the edge of a box. */
.nb-frame::before,
.nb-frame::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  height: 1px;
  background: var(--nb-line);
  z-index: 4;
  pointer-events: none;
}
.nb-frame::before { top: 0; }
.nb-frame::after { bottom: 0; }

.nb-box { position: absolute; overflow: hidden; z-index: 3; }

/* THE VEIL holds the backdrop back so the copy stays readable. It has to be a
   PSEUDO-ELEMENT behind the module's own content rather than a background on the
   module: the modules paint above the image layers, so a background would tint the
   photo as well as the backdrop. z-index -1 puts it behind the module's children while
   still inside the module's own box. The JS sets --veil per module and zeroes it on
   whichever module holds the picture. */
.nb-box::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: rgba(0, 17, 65, var(--veil, 0));
}
/* The tint EASES between steps instead of switching. Driven by --vlms, which the JS
   writes from TIMING.dissolve, so the one control moves the pictures and the tints
   together and they read as a single gesture. On the module receiving the picture the
   veil is going to 0, so this also lifts the tint off the arriving photo rather than
   cutting it away. Set on .nb-box and NOT on the box's inline style, because
   clearRoles() strips inline styles on every step. */
.nb-box { transition: --veil var(--vlms, 260ms) ease; }

/* Columns. */
.nb-box--e1t, .nb-box--e1b { left: 0; width: var(--gut); }
.nb-box--i1t, .nb-box--i1b { left: var(--gut); width: calc(var(--bl) - var(--gut)); }
.nb-box--bt,  .nb-box--bb  { left: var(--bl); width: calc(var(--br) - var(--bl)); }
.nb-box--i2t, .nb-box--i2b { left: var(--br); width: calc(100% - var(--gut) - var(--br)); }
.nb-box--e2t, .nb-box--e2b { left: calc(100% - var(--gut)); width: var(--gut); }

/* Rows, shared by every column. */
.nb-box--e1t, .nb-box--i1t, .nb-box--bt, .nb-box--i2t, .nb-box--e2t {
  top: 0; height: calc(var(--hy) * 100%);
}
.nb-box--e1b, .nb-box--i1b, .nb-box--bb, .nb-box--i2b, .nb-box--e2b {
  top: calc(var(--hy) * 100%); height: calc((1 - var(--hy)) * 100%);
}

/* A faint checkerboard so the modules read as separate planes rather than one
   navy field. The JS only adds .nb-tint to modules holding nothing, because the
   modules sit above the image layers and a background would paint over a
   picture. */
.nb-box.nb-tint { background: rgba(255, 255, 255, 0.03); }

/* ============================================================================
   THE MASK AND THE PLATE — this is the reveal

   The image lives in its OWN full-frame layer, not inside a module. That layer is
   clipped to its band module's rect, and the clip is derived from the animated
   dividers, so the clip edges move as they move. The plate inside is positioned in
   absolute frame coordinates at the rect the module settles into, so it never
   moves. Result: the moving clip edge slides across a stationary image and
   reveals it.

   Keeping the image out of the module system is what lets the band swing sides.
   The module a band vacates is where the copy can go, and when they shared a DOM
   node the copy's hidden pre-state took the outgoing image down with it.

   Two rules, one per band module, because the image alternates rows.
   ============================================================================ */
.nb-mask { position: absolute; inset: 0; z-index: 1; }
.nb-mask--in { z-index: 2; }   /* the arriving image paints over the leaving one */
.nb-mask--bt {
  clip-path: inset(0 calc(100% - var(--br)) calc((1 - var(--hy)) * 100%) var(--bl));
}
.nb-mask--bb {
  clip-path: inset(calc(var(--hy) * 100%) calc(100% - var(--br)) 0 var(--bl));
}

/* Pinned in absolute frame coordinates: it does not move, ever. */
.nb-plate {
  position: absolute;
  left:   calc(var(--ptl) * var(--fw));
  top:    calc(var(--ptt) * var(--fh));
  width:  calc(var(--ptw) * var(--fw));
  height: calc(var(--pth) * var(--fh));
  /* CLIPPED TO THE FINAL RECT (Michael, 2026-08-17: "some of the image being transitioned
     to is not masked beforehand to the shape of the containing box it will resolve to").

     The plate is already pinned to the module rect the picture will SETTLE in, but its
     media is scaled up by the step's zoom (1.25 to 1.35) plus the 8% push, and a transform
     is not clipped by its own box. The only clip was the travelling mask, which during a
     move is a different shape from the final module, so wherever the mask was momentarily
     LARGER than the plate the scaled overflow showed outside the box the picture was about
     to resolve into. Clipping here answers it at the right level: the picture can never
     paint outside its destination, and the mask still does the revealing. */
  overflow: hidden;
}
/* A plate's media is a still OR a video, and both are framed identically: the
   focus point, the zoom and the cover-fit all work the same on a <video>, so a
   generated clip drops into the same slot a jpg sits in with nothing else to change. */
.nb-plate img,
.nb-plate video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  object-position: var(--fx, 50%) var(--fy, 50%);
  transform: scale(var(--fz, 1));
  transform-origin: var(--fx, 50%) var(--fy, 50%);
}
/* The clip edge is what CONCEALS a plate: no opacity is used to hide or reveal one
   mid-flight. The only exception is a short dissolve (TIMING.dissolve, 260ms of a
   1300ms move) on the first and last moment of a picture's life, applied by JS via
   WAAPI, because the arriving mask is inserted once its band already has width and so
   used to appear at full strength mid-reveal. Do not add an opacity transition here:
   it would apply to every geometry change and fight the wipe. */
.nb-plate img.is-model,
.nb-plate video.is-model { filter: saturate(1.14) brightness(0.96) contrast(1.06); }
.nb-plate img.is-photo,
.nb-plate video.is-photo { filter: saturate(1.02) brightness(0.78) contrast(1.05); }
.nb-plate::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(180deg, rgba(0, 17, 65, 0.12) 0%, rgba(0, 17, 65, 0.42) 100%);
}

/* ---------- step marker, pinned with its plate ---------- */
.nb-step {
  position: absolute;
  left: 0; top: 0;
  z-index: 3;
  padding: 14px 18px;
  font-family: "IBM Plex Mono", monospace;
  font-size: 10.5px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--white);
  background: linear-gradient(135deg, rgba(0, 17, 65, 0.82) 0%, rgba(0, 17, 65, 0) 100%);
}
.nb-step b { color: var(--cyan); font-weight: 500; }

/* ---------- copy module ---------- */
/* One block per step: kicker, headline, and the line with the figure inside it.
   NO outer padding: the copy column's outer edge already sits on the content grid
   because the gutter column occupies the inset, so the text lines up with the
   setup line above with nothing added. Only the inner edge needs clearance from
   the band divider. */
/* --ts scales the whole block, --fg how loud the figure is, --pl / --pr the padding
   on each edge. The JS writes all four per step; the values here are the fallback,
   and they reproduce the original setting. */
.nb-box--copy {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: calc(15px * var(--ts, 1));
  padding: 0 var(--pr, 44px) 0 var(--pl, 0px);
}
/* Alignment. The flex column needs align-items as well as text-align, or the child
   boxes stay left while only their wrapped lines move. */
.nb-box--copy.is-align-left  { text-align: left;  align-items: flex-start; }
.nb-box--copy.is-align-right { text-align: right; align-items: flex-end; }
/* The measure is capped, so right-aligned copy has to hug the right edge. */
.nb-box--copy.is-align-right .nb-line { margin-left: auto; margin-right: 0; }
.nb-kicker {
  font-family: "IBM Plex Mono", monospace;
  font-size: calc(11px * var(--ts, 1));
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--cyan);
  white-space: nowrap;
}
.nb-headline {
  margin: 0;
  font-size: calc(clamp(21px, 1.95vw, 31px) * var(--ts, 1));
  font-weight: 300;
  line-height: 1.16;
  letter-spacing: -0.012em;
  color: var(--white);
  text-wrap: balance;
}
.nb-line {
  margin: 0;
  font-size: calc(clamp(13.5px, 0.98vw, 15px) * var(--ts, 1));
  line-height: 1.6;
  color: var(--gray);
  /* A wider measure than the old stat-box layout: the copy module is shorter now
     that the grid has two rows, so the sentence has to run wider to stay inside
     it. Still within a comfortable reading measure. */
  max-width: 48ch;
  text-wrap: pretty;
}
/* The folded-in figure. It carries the weight the stat module used to, without
   taking a module: mono and cyan against the sentence's sans and gray. Push --fg up
   and it becomes a proper callout inside the sentence. inline-block with a tight
   line-height keeps a large figure from shoving its own line apart more than it has
   to; the paragraph's leading absorbs the rest. */
.nb-figure {
  font-family: "IBM Plex Mono", monospace;
  font-weight: 500;
  font-size: calc(var(--fg, 1.06) * 1em);
  line-height: 1.05;
  letter-spacing: -0.01em;
  color: var(--cyan);
  /* INLINE, not inline-block. Each word of the figure is its own span, and a break
     opportunity exists either side of an ATOMIC inline box, so as inline-block the
     phrase still broke between words ("6T API calls a / month") even with
     non-breaking spaces between them. Plain inline lets those NBSPs actually bind the
     run, and the JS inserts them for exactly this reason.
     The constraint it buys: keep a figure shorter than the measure, or an unbreakable
     run wider than the line will be clipped by the module's overflow. Short is right
     anyway, it is a callout. */
  display: inline;
  white-space: nowrap;
}

/* ---------- the support stat ----------
   The figure lifted out of the sentence and set big as a support stat. It lives INSIDE
   a module, chosen like the text and the image are, and fills it absolutely rather than
   joining its flow, so putting it in the module that holds the copy overlaps rather
   than shoving the copy around. Because it is a child of the module it tracks that
   module as the grid animates, with no second set of geometry to keep in step.

   --ss scales it, --spl / --spr pad the sides (outer edge flush to the content grid by
   default, same rule as the copy), --spy insets it from the edge it aligns to. */
.nb-stat {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  gap: calc(7px * var(--ss, 1));
  padding: var(--spy, 26px) var(--spr, 0px) var(--spy, 26px) var(--spl, 0px);
}
/* Which corner OF ITS MODULE it sits in. */
.nb-stat--tl { justify-content: flex-start; align-items: flex-start; }
.nb-stat--bl { justify-content: flex-end;   align-items: flex-start; }
.nb-stat--tr { justify-content: flex-start; align-items: flex-end; text-align: right; }
.nb-stat--br { justify-content: flex-end;   align-items: flex-end; text-align: right; }
.nb-stat-value {
  font-family: "IBM Plex Mono", monospace;
  font-weight: 400;
  font-size: calc(clamp(38px, 4.6vw, 76px) * var(--ss, 1));
  line-height: 0.94;
  letter-spacing: -0.03em;
  color: var(--cyan);
  white-space: nowrap;
  /* Auto placement puts the stat in an empty module, but a corner chosen by hand can
     land it over the picture, so it carries just enough shadow to stay readable there
     without reading as a drop shadow on navy. */
  text-shadow: 0 1px 18px rgba(0, 17, 65, 0.85);
}
.nb-stat-label {
  font-family: "IBM Plex Mono", monospace;
  font-size: calc(10.5px * var(--ss, 1));
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--gray);
  line-height: 1.35;
  text-wrap: balance;
  text-shadow: 0 1px 12px rgba(0, 17, 65, 0.9);
}

/* ---------- the copy types itself in, word by word ---------- */
/* The only thing in here that uses opacity, and it is text, not imagery. Each
   word carries its own delay, so lifting the pre-state cascades them in. Per
   word rather than per character: at sentence length characters read as a
   gimmick and take far too long. */
.nb-rise { opacity: 1; }
.nb-type > span {
  opacity: 1;
  transition: opacity 70ms linear var(--wd, 0ms);
}
.nb-rise.is-pre .nb-type > span { opacity: 0; transition: none; }

/* The block leaves as a block. A reverse stagger on the way out just drags.

   IT FADES THE CONTENTS, NOT THE BOX, and that distinction is the whole point. `nb-rise`
   sits on the copy MODULE, and the module's veil is its own ::after, so fading the module
   took the tint with the words: the backdrop flooded through mid-transition and then
   snapped back to full when the class came off. Listing the children keeps the tint
   completely out of it, and it also stops the tint sliding 6px, which it never should. */
.nb-rise.is-out > .nb-kicker,
.nb-rise.is-out > .nb-headline,
.nb-rise.is-out > .nb-line,
.nb-rise.is-out > .nb-stat {
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 180ms linear, transform 180ms ease-out;
}

/* ---------- the dividers, and the points ---------- */
.nb-rule { position: absolute; background: var(--nb-line); pointer-events: none; z-index: 4; }
/* The gutter dividers are FIXED: they mark the content grid and never move. */
.nb-rule--gl { top: 0; bottom: 0; width: 1px; left: var(--gut); }
.nb-rule--gr { top: 0; bottom: 0; width: 1px; left: calc(100% - var(--gut)); }
/* The band dividers travel. */
.nb-rule--bl { top: 0; bottom: 0; width: 1px; left: var(--bl); }
.nb-rule--br { top: 0; bottom: 0; width: 1px; left: var(--br); }
/* One horizontal line, full width, at one height. */
.nb-rule--h  { left: 0; right: 0; height: 1px; top: calc(var(--hy) * 100%); }

.nb-rule { transition: background 400ms ease, box-shadow 400ms ease; }
/* Only what is actually moving lights up. The fixed grid stays calm. */
.nb-frame.is-moving .nb-rule--bl,
.nb-frame.is-moving .nb-rule--br,
.nb-frame.is-moving .nb-rule--h {
  background: var(--cyan);
  box-shadow: 0 0 12px rgba(51, 177, 255, 0.4);
}

/* The grid's own two intersections. A travelling point spawns off one and merges
   into the other, and at rest it covers one exactly, so the pair reads as a single
   mark sitting on the grid. Dimmer and smaller: these are anchors, not actors. */
/* EVERY DOT PULSES, and none of them together. Same idea as the original grid dots in
   signal.css (opacity 0.25 to 1 over 3.2s, ease-in-out) but on scale as well, since
   these are elements rather than SVG circles with an animatable r.

   --dot is the base size, and width, height and the centring margin all derive from it,
   so one value resizes a dot correctly. --pd is its own period and --pdl its offset:
   the offsets are NEGATIVE so each dot starts already part-way through rather than
   every one flashing in unison on load. Sizes and periods are deliberately unequal. */
@keyframes nb-pulse {
  0%, 100% { opacity: 0.3; transform: scale(0.72); }
  50%      { opacity: 1;   transform: scale(1.16); }
}
.nb-anchor, .nb-point {
  --dot: 9px;
  --pd: 3.2s;
  --pdl: 0s;
  position: absolute;
  width: var(--dot); height: var(--dot);
  margin: calc(var(--dot) / -2) 0 0 calc(var(--dot) / -2);
  top: calc(var(--hy) * 100%);
  border-radius: 50%;
  pointer-events: none;
  animation: nb-pulse var(--pd) ease-in-out var(--pdl) infinite;
}

.nb-anchor {
  z-index: 4;
  background: rgba(51, 177, 255, 0.4);
}
.nb-anchor--l { left: var(--gut);                 --dot: 6px; --pd: 5.3s; --pdl: -0.7s; }

/* ---------- position counter ----------
   Micro telemetry next to the left anchor dot, reading the live divider positions so
   the grid's movement is legible as coordinates rather than just felt. It rides that
   dot, which sits on the fixed gutter line at the shared split, so with an extreme
   split it lands in a frame corner.

   NO PER-FRAME JS. The numbers come from CSS counters fed by the registered --ax/--bx
   /--hy, so they interpolate with the very same animation that moves the dividers and
   stay in step by construction. That is the whole reason those three are @property.
   `counter-reset` wants an <integer> and a calc() on a <number> is rounded to one. */
.nb-counter {
  position: absolute;
  /* RIDES THE TRAVELLING DIVIDER, not the fixed gutter line (Michael's call 2026-08-17).
     `--bl` is animated, so the readout moves with the band and its numbers change at the
     same time: the position and the reading of it are the same gesture. It therefore sits
     over the picture, so the label carries a soft navy shadow to stay legible on
     photography, the same trick the corner stat uses. */
  left: var(--bl);
  top: calc(var(--hy) * 100%);
  z-index: 6;
  margin-left: 13px;
  transform: translateY(9px);
  font-family: "IBM Plex Mono", monospace;
  font-size: 9px;
  line-height: 1;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: rgba(51, 177, 255, 0.75);
  text-shadow: 0 1px 3px rgba(0, 17, 65, 0.85), 0 0 10px rgba(0, 17, 65, 0.6);
  white-space: nowrap;
  pointer-events: none;
  /* Tabular figures and a floor on the width, or the label twitches sideways every
     time a number gains or loses a digit while the dividers travel. */
  font-variant-numeric: tabular-nums;
  /* Fits the widest case so the label never reflows as digits come and go. Measured at
     9px Plex Mono: the three-digit longitude (180) makes it 126px, and 23ch left it 2px
     short, so the label twitched between 124 and 126 as the band reached the gutter. */
  min-width: 25ch;
  /* READS AS A COORDINATE FIX. The vertical divider position becomes a latitude and the
     horizontal one a longitude, which is the natural mapping and makes the readout feel
     like a position rather than two loose percentages.

     Decimals without any JS: `round(down, ...)` gives the whole degrees and
     `mod(x * 9000, 100)` gives the two digits after the point, both from the same
     registered vars that drive the dividers. Verified in Chrome: at hy 0.8525 these
     yield 76 and 72, so 76.72. If `mod()` or `round()` are ever unavailable the numbers
     degrade to 0 rather than breaking the layout.

     N and W are fixed rather than derived. They put it over North America, which is
     where this audience is, and a hemisphere that flipped with the geometry would imply
     a real position that these numbers do not describe. */
  counter-reset:
    nb-lat  calc(round(down, var(--hy) * 90, 1))
    nb-latd calc(round(down, mod(var(--hy) * 9000, 100), 1))
    nb-lon  calc(round(down, var(--ax) * 180, 1))
    nb-lond calc(round(down, mod(var(--ax) * 18000, 100), 1));
}

/* ---------- second mark, on the OTHER travelling dot ----------
   Michael asked for a little more technicality in the corners without overdoing it. The
   restraint is the point, so this is ONE extra mark, not a scatter of them, and it reads
   the FAR divider's own position (`--bx`) exactly as the first reads `--ax`. The two then
   bracket the band like a pair of instrument fixes rather than repeating each other.

   EVERY MARK IS DERIVED FROM REAL STATE. Nothing here is invented telemetry: no fake
   irradiance, no made-up load figures. A plausible-looking number under the TWC logo that
   means nothing is worse than no number at all.

   Longitude only, so it is visibly the shorter half of the pair. It sits to the LEFT of
   its dot because at `bx: 1` the dot is on the right gutter and a label to the right
   would hang off the frame. */
.nb-counter--b {
  left: var(--br);
  margin-left: 0;
  transform: translateX(-100%) translateX(-13px) translateY(9px);
  min-width: 0;
  text-align: right;
  counter-reset:
    nb-lon2  calc(round(down, var(--bx) * 180, 1))
    nb-lond2 calc(round(down, mod(var(--bx) * 18000, 100), 1));
}
/* `.nb-section` prefix is load-bearing: without it this ties on specificity with the
   generic `.nb-counter.is-above` below, which is declared LATER and therefore won,
   stripping the translateX and throwing this label off the right of the frame on the
   two low-split steps. */
.nb-section .nb-counter--b.is-above {
  transform: translateX(-100%) translateX(-13px) translateY(-100%) translateY(-9px);
}
/* `.nb-section` prefix again, and for the same reason as the is-above rule: this ties on
   specificity with the generic `.nb-counter::before` declared just below, which therefore
   won, so this mark rendered A's full coordinate instead of its own longitude. It did not
   even fall back to zeroes, because a CSS counter with no reset on the element picks up the
   value from a PRECEDING SIBLING's scope, and mark A is exactly that. */
.nb-section .nb-counter--b::before {
  content: counter(nb-lon2) "." counter(nb-lond2, decimal-leading-zero) "\00B0\2009W";
}
.nb-counter::before {
  /* `decimal-leading-zero` on the fractional parts only, so .5 reads as .05 rather than
     .5, which is what a coordinate does. */
  content: counter(nb-lat) "." counter(nb-latd, decimal-leading-zero) "\00B0\2009N"
           "\2003"
           counter(nb-lon) "." counter(nb-lond, decimal-leading-zero) "\00B0\2009W";
}
/* Flipped above the line when the split is low, so it never hangs off the frame. */
.nb-counter.is-above { transform: translateY(-100%) translateY(-9px); }
/* Brighter while the grid is actually moving, which is when it means anything. */
.nb-frame.is-moving .nb-counter { color: rgba(51, 177, 255, 0.9); }
.nb-counter { transition: color 300ms ease; }
/* Stacked, nothing travels, so a positional readout would be describing a grid that
   is not there. */
@media (max-width: 900px) { .nb-counter { display: none; } }
.nb-section.nb-no-counter .nb-counter { display: none; }
.nb-anchor--r { left: calc(100% - var(--gut));    --dot: 5px; --pd: 4.7s; --pdl: -2.1s; }

/* The points sit on the two moving dividers, at the shared line, so they travel
   as a pair. The fixed gutter dividers get no travelling point: they get an
   anchor, which is what the moving point departs from and returns to. */
.nb-point {
  z-index: 5;
  background: var(--cyan);
  box-shadow: 0 0 0 1px rgba(0, 17, 65, 0.7), 0 0 14px rgba(51, 177, 255, 0.8);
  /* --dot animates too, so growing while travelling stays centred. */
  transition: width 400ms ease, height 400ms ease, margin 400ms ease;
}
.nb-point--a { left: var(--bl); --dot: 10px; --pd: 3.2s; --pdl: 0s; }
.nb-point--b { left: var(--br); --dot: 8px;  --pd: 4.1s; --pdl: -1.3s; }
/* Bigger while the dividers travel: set the ONE variable and the size and centring
   both follow, rather than restating width, height and margin. */
.nb-frame.is-moving .nb-point--a { --dot: 16px; }
.nb-frame.is-moving .nb-point--b { --dot: 14px; }
.nb-point::after {
  content: "";
  position: absolute;
  inset: -6px;
  border-radius: 50%;
  border: 1px solid rgba(51, 177, 255, 0.5);
  opacity: 0;
  transform: scale(0.6);
  transition: opacity 480ms ease, transform 480ms ease;
}
.nb-frame.is-moving .nb-point::after { opacity: 1; transform: scale(1.7); }

/* ---------- HUD ---------- */
.nb-hud {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding-top: 20px;
  padding-bottom: 30px;
  flex-wrap: wrap;
}
.nb-meta { display: flex; align-items: baseline; gap: 13px; flex-wrap: wrap; }

/* ---------- bars-only chrome ----------
   Michael's call 2026-08-17: the logo train that lived inside the replaced hero has to
   come back under the header, and the room for it comes from the header's own chrome
   rather than from the frame. With `.nb-bars-only` the HUD keeps just the step BARS,
   which say the same thing as "SEQUENCE · INTO THE GRID" and "STEP 04 · THE SOLAR FARM"
   without spending three lines of type on it. The text is hidden, not deleted, so it is
   one toggle to put back. */
.nb-section.nb-bars-only .nb-meta,
.nb-section.nb-bars-only .nb-progress { display: none; }
/* WITH THE TEXT GONE, THE BARS HAVE TO CARRY THE JOB ON THEIR OWN. Right-aligned at 3px
   they were technically present but nobody saw them, which is not the same as working:
   Michael asked where they had gone while they were on screen the whole time. So in
   bars-only mode they move to the LEFT, onto the same gutter line as the header and the
   copy where the eye already is, and get a little more presence: taller, longer, and a
   brighter inactive state so four marks read as four rather than one lit dash. */
.nb-section.nb-bars-only .nb-hud {
  justify-content: flex-start;
  padding-top: 16px;
  padding-bottom: 18px;
}
/* `.nb-hud` is ALSO a `.wrap`, so its own centring padding stacks with anything added
   here: the bars landed at 450px instead of the 117px gutter. Same trap the header hit.
   Neutralise the wrap first, then align to --gut, so the bars sit on the same line as the
   headline and the copy whatever the gutter is set to. */
.nb-section.nb-bars-only .nb-hud {
  max-width: none;
  margin: 0;
  padding-left: var(--gut);
  padding-right: 40px;
  /* NO BOTTOM PADDING. The logo strip used to be pulled up 25px with a negative margin,
     which made it overlap this band by 25 of its 38px. Both carry the same 0.88 scrim, and
     two stacked scrims read as ~0.986, so the 13px of HUD that was NOT overlapped looked
     like a lighter blue bar across the page. The lift now comes out of this band's own
     padding instead, so the strip sits directly under the bars with nothing doubled. */
  padding-top: 10px;
  padding-bottom: 0;
}
.nb-section.nb-bars-only .nb-track { gap: 8px; }
.nb-section.nb-bars-only .nb-tick { width: 58px; height: 4px; background: rgba(51, 177, 255, 0.30); }
.nb-section.nb-bars-only .nb-tick.is-done { background: rgba(51, 177, 255, 0.62); }
.nb-section.nb-bars-only .nb-tick[aria-current="true"] { background: var(--cyan); }
/* The eyebrow is editorial rather than chrome, so it is a separate switch and stays on
   unless the room is genuinely needed. */
/* CLASS NAME IS DELIBERATE: `nb-brow-off`, NOT `nb-no-eyebrow`.
   signal-skin.css on the live site carries `[class*="eyebrow"]{text-transform:uppercase}`,
   an attribute-SUBSTRING selector. `nb-no-eyebrow` contains "eyebrow", so that rule matched
   the SECTION itself, and because text-transform inherits, every word in the header went
   uppercase in the in-situ page: headline, copy, even the CTA. Nothing in our own CSS was
   wrong, which is why it took a while to find. **Do not use a class containing "eyebrow",
   and avoid ones ending in "-btn" for the same reason.** */
.nb-section.nb-brow-off .nb-head .eyebrow { display: none; }

/* ---------- the faint grid behind the header ----------
   Michael's ask 2026-08-17: bring the slight grid back behind the header text (and, in
   the in-situ page, behind the lifted logo train). It reads as graph paper under the
   type, which is the same "measured" language the frame's modules and the coordinate
   marks are speaking, so the open space above the frame stops feeling like plain navy.

   Deliberately very low contrast: at 0.05 alpha it is texture, not a visible grid, and it
   must never compete with the copy. 72px squares to be near the type's own rhythm rather
   than a dense technical mesh.

   Painted on the SECTION, so the frame (which carries its own backdrop layers) covers it
   naturally and nothing has to be masked out. */
.nb-section {
  --nb-grid-line: rgba(51, 177, 255, 0.05);
  --nb-grid-size: 72px;
}
/* ON A PSEUDO-ELEMENT, not the section's own background. In the in-situ page the live
   theme carries an `!important` background rule that matches this element, which wiped
   both the navy AND the grid: computed background came back `rgba(0,0,0,0)` / `none` even
   though the class was applied and every variable resolved. A pseudo-element is out of
   reach of rules targeting the section, so this works in the sandbox and inside the real
   page without an !important war. The frame is positioned and comes later in the DOM, so
   it paints over this without needing a z-index of its own. */
/* Back to the ORIGINAL navy (Michael, 2026-08-17). A darker hero was tried, #000C2E
   against the band's #001141, to give the logo band an edge; he preferred the single
   continuous field. The variable stays so the step is one number away if it comes back. */
.nb-section {
  position: relative;
  --nb-hero-bg: #001141;
  background-color: var(--nb-hero-bg);
}
/* THE REAL MESH, not a flat CSS grid. `.nb-mesh` is a canvas running the same GRAF
   renderer as the frame's backdrop, sized to the whole section, so it densifies and
   relaxes on the original's 7s loop behind the header type and the logo train. The flat
   72px grid that was here first was a static stand-in and read as almost nothing.
   Behind everything, and inert to the pointer. */
.nb-mesh {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
  display: none;
}
.nb-section.nb-grid .nb-mesh { display: block; }
.nb-partner { font-size: 16px; font-weight: 600; color: var(--white); }
.nb-industry {
  font-family: "IBM Plex Mono", monospace;
  font-size: 10.5px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--cyan);
}
.nb-controls { display: flex; align-items: center; gap: 16px; }
.nb-track { display: flex; gap: 6px; }
.nb-tick {
  appearance: none;
  border: 0;
  padding: 0;
  width: 46px; height: 3px;
  background: rgba(51, 177, 255, 0.22);
  cursor: pointer;
  transition: background 0.3s ease;
}
.nb-tick:hover { background: rgba(51, 177, 255, 0.5); }
.nb-tick[aria-current="true"] { background: var(--cyan); }
.nb-tick.is-done { background: rgba(51, 177, 255, 0.55); }
.nb-tick:focus-visible { outline: 2px solid var(--cyan); outline-offset: 3px; }
.nb-progress {
  font-family: "IBM Plex Mono", monospace;
  font-size: 11px;
  letter-spacing: 0.1em;
  color: var(--gray);
}

.nb-section .nowrap { white-space: nowrap; }

.nb-sr {
  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;
}

/* ---------- scroll-driven progression ----------
   The section pins inside a tall track, so the reader's scroll position picks the step
   and nobody scrolls past mid-sequence. Only applied in scroll mode: in auto mode the
   track is not even built, so the timed behaviour is untouched.

   The track must not clip or the sticky child stops sticking. */
.nb-scrolltrack { position: relative; overflow: visible; }
.nb-section.nb-scrolldriven {
  position: sticky;
  top: 0;
  /* Sticky needs a stacking position above what follows, or the next section scrolls
     over the pinned header. */
  z-index: 2;
}

/* ---------- progressive-enhancement gate ---------- */
.nb-section:not(.nb-live) .nb-rise { opacity: 1 !important; transform: none !important; }
.nb-section:not(.nb-live) .nb-rise > * { opacity: 1 !important; transform: none !important; }

/* ---------- reduced motion ---------- */
/* `?reduce=1` sets .nb-reduce so the dev flag behaves like the real setting. It covers
   the motion-bearing parts only; the media query below carries the full treatment. */
.nb-section.nb-reduce .nb-anchor,
.nb-section.nb-reduce .nb-point { animation: none; transform: none; }
.nb-section.nb-reduce .nb-point { opacity: 0.85; }
.nb-section.nb-reduce .nb-box { transition: none; }
.nb-section.nb-reduce .nb-anchor { opacity: 0.8; }
.nb-section.nb-reduce .nb-type > span { opacity: 1; transition: none; }

/* No typing: the words are simply there. */
@media (prefers-reduced-motion: reduce) {
  .nb-rise, .nb-rule, .nb-point { transition: none !important; }
  .nb-box { transition: none !important; }
  .nb-type > span { opacity: 1 !important; transition: none !important; }
  .nb-rise.is-pre, .nb-rise.is-out { opacity: 1; transform: none; }
  .nb-rise.is-out > .nb-kicker,
  .nb-rise.is-out > .nb-headline,
  .nb-rise.is-out > .nb-line,
  .nb-rise.is-out > .nb-stat { opacity: 1; transform: none; }
  .nb-sweep { animation: none; }
  .nb-frame.is-moving .nb-rule--bl,
  .nb-frame.is-moving .nb-rule--br,
  .nb-frame.is-moving .nb-rule--h { background: var(--nb-line); box-shadow: none; }
  /* No pulsing, and no growing on the move. Held at a readable opacity, the way the
     original grid dots do under reduced motion. */
  .nb-anchor, .nb-point { animation: none !important; transform: none !important; }
  .nb-point { opacity: 0.85; }
  .nb-anchor { opacity: 0.8; }
  .nb-frame.is-moving .nb-point--a,
  .nb-frame.is-moving .nb-point--b { --dot: 9px; }
  .nb-point::after { display: none; }
}

/* ---------- stacked: everything below 900px ----------
   Ten modules need width. Side by side, the copy column falls to about 290px at
   768px wide, a 38-character measure wrapping to five lines with 71px of headroom
   left: it survives but it reads badly. So the composition collapses to two
   stacked bands for the whole of tablet and phone, not just phone. The plate then
   fills the band outright, because a moving divider has no meaning once stacked.
   Keyed off ROLE, not module id.

   THE JS `COMPACT` QUERY MUST MATCH THIS WIDTH. It rebuilds the step when the
   breakpoint is crossed, so if the two disagree you get the stacked layout with
   geometry that was never re-rendered for it. */
@media (max-width: 900px) {
  /* Still ADDS the site-chrome offset. Overwriting padding-top outright here lost it,
     and the setup line went back under the live nav on mobile by 4px. */
  .nb-head { padding-top: calc(34px + var(--nb-top)); padding-bottom: 22px; }
  .nb-setup { font-size: clamp(25px, 4.6vw, 40px); max-width: 26ch; }
  /* The frame stops being a fixed-height stage and becomes a COLUMN as tall as its
     two parts. A fixed 560px with the copy top-aligned inside it left a band of
     leftover navy under the text, up to 113px of it. Now the height is the band plus
     whatever the copy actually needs, so there is nothing left over. */
  .nb-frame {
    height: auto;
    --nb-h: auto;
    display: flex;
    flex-direction: column;
  }
  /* THE MASK IS THE IMAGE BAND once stacked. No clipping box is needed, because
     nothing travels, so the band is simply the mask laid out in the flow at a height
     that scales with the width. One expression across the whole range, so there is no
     jump at the tablet boundary: 240px at 390 wide, 399px at 768, 430px at 899, which
     keeps the picture between 1.6 and 2.1 to a side. */
  .nb-mask {
    position: relative;
    inset: auto;
    width: 100%;
    height: clamp(240px, 52vw, 430px);
    clip-path: none !important;
  }
  .nb-mask--out { display: none; }
  .nb-plate {
    position: absolute;
    left: 0 !important; top: 0 !important;
    width: 100% !important; height: 100% !important;
  }
  /* Every module is out of it except the copy, which flows under the band and is
     only as tall as its own text. Alignment is deliberately NOT forced back to the
     left: if a step is set right-aligned, it stays right-aligned here too. */
  .nb-box { display: none !important; }
  .nb-box--copy,
  .nb-box--copy.is-right {
    display: flex !important;
    position: relative !important;
    left: auto !important; top: auto !important;
    width: 100% !important; height: auto !important;
    padding: 26px 22px 30px !important;
  }
  .nb-box.nb-tint { background: none; }
  .nb-rule, .nb-point, .nb-anchor { display: none; }
  .nb-hud { padding-top: 16px; padding-bottom: 22px; gap: 12px; }
  .nb-controls { width: 100%; justify-content: space-between; }
  .nb-track { flex: 1 1 auto; }
  .nb-tick { width: 100%; flex: 1 1 auto; }
}

/* ---------- stacked, with tablet room to spend ----------
   Same column, just more generous inset once there is width for it. The band height
   needs no adjustment here: the clamp above already scales it across the range. */
@media (min-width: 601px) and (max-width: 900px) {
  .nb-box--copy,
  .nb-box--copy.is-right { padding: 30px 40px 34px !important; }
}
