/* ═══════════════════════════════════════════════════════════════════════════
   DRIIF — VIPER page-specific layout only.
   Anything reusable belongs in components, anything numeric in tokens.

   The hero shell, the two-column hero split, the flow strip and the
   picture-tabs pattern are all shared with nexus.html and/or index.html
   and live in components.css. Fold 3 (Mission Layer) reuses
   .problem-resolve — the image-left/copy-right pattern components.css
   already shares with nexus.html's Counter-UAS Challenge and the
   Applications page's own detail folds — rather than the pinned
   points-against-media pattern (.decision-grid) NEXUS's Define and
   Operational Loop use: Mission Layer's own points have no per-point video
   to justify stepping through one at a time, so they sit static instead,
   one after the other. Fold 5 (Edge Autonomy) went through a .decision-grid
   phase of its own for a while (scroll pin-and-walk, one photo per point)
   but is back to its original pattern — a click-to-flip grid (.edge-grid,
   edge-flip.js), each of its three points flipping in place to its own
   photo rather than swapping a shared media slot. What's below is what's
   actually specific to this page: Fold 2's plain-text passage and its
   pinned full-screen scene walk (gap-scroll.js), Fold 3's own divider and
   copy layout, Fold 4's tab-row scroll buttons (NEXUS's Operating Model
   tabs rely on native touch/trackpad scroll alone; this page's six tabs
   get explicit prev/next controls too), Fold 5's own flip grid, and
   Fold 6's comparison table, styled here for the first time.
   ═══════════════════════════════════════════════════════════════════════════ */


/* ── FOLD 1: HERO INTRO — lead/CTA pushed later ──────────────────────────
   motion.css's shared .intro-d3/.intro-d4 delays (480ms/600ms) were tuned
   for a single-line heading. This hero's own heading is now two lines
   (.intro-line:nth-of-type(2), motion.css), whose rise doesn't finish
   until ~520ms+900ms=1420ms, so the old delays landed the lead and CTA
   arriving before the second line had even started. Pushed back scoped
   to this page only, rather than in the shared file — Applications still
   has a single-line heading and wants the original timing. */
#top .hero-side .intro-d3{ animation-delay:700ms; }
#top .hero-side .intro-d4{ animation-delay:850ms; }


/* ── FOLD 3: MISSION LAYER — the network, drawn over the render ────────────
   Replaces the old inline-SVG flowchart and its sticky-column layout. The
   section is now a heading, one wide render, and the two copy blocks
   beneath it — a horizontal image has no business squeezed into half a
   column, and a vertical crop of the same scene was tried first and read as
   congested.

   .mission-net-stage holds the render and everything drawn on it as one
   registered unit: the SVG (viewBox = the render's native 1840x854, so line
   coordinates are plain image pixels and track it at any rendered width),
   and the labels (positioned in percentages of that same box). Re-measure all three if the render is ever replaced with a
   differently-composed image.

   Two tiers of line, same dotted/round-cap/glow technique as the Nexus
   Decision Layer: .mn-line--peer is the link between one asset and the next,
   dimmed right down so it only supports; .mn-line--spoke is the accent-yellow
   link from an asset into the VIPER screen, the one bright tier, since the
   spokes are the point of the picture. All lines are straight — curved ones
   read as scattered over the render, direct feedback. Every line is dotted by stroke-dasharray, which rules out
   drawing it in with dashoffset — so each carries an SVG <mask> whose own
   solid stroke (.mn-draw) is what animates in; the dots appear only where
   the mask has reached.

   Sequence once .in lands (all delays are authored per element, --dl, in
   viper.html): the render fades in while the ten asset-to-asset peer links
   start drawing at once, one by one, each over 1.5s; the nine spokes follow
   into the screen the same way with a dot landing at each end, then the
   VIPER Mission OS label — slowed down from the first pass on request.
   Each asset's name appears with the first line to reach it. No end-point dots — lines only, per direct request.
   Without JS, or if .in never arrives, none of the hidden states apply and
   the finished picture shows.

   The stage is also capped so the WHOLE render fits in one viewport height
   (width <= (viewport height - --space-9) * 1840/854, the render's own ratio) and
   sits centred — on a wide desktop screen the full-width render ran taller
   than the window, so the drawing was cut off until you scrolled, direct
   bug report. The reveal uses the default arrival trigger and the first
   line starts almost at once — direct request not to make the visitor wait
   on arrival.

   The render is a wide 1840x854 (about 2.15:1) so the stage fills the
   container width at one-screen height with terrain to both edges, instead
   of a 3:2 picture with empty bars either side — the earlier 3:2 version
   was extended outward by the image generator, not by hand.

   Below 720px the stage keeps that minimum width and the wrapper scrolls
   sideways — eleven names on a phone-width render would sit on top of each
   other, and the earlier mobile-overlap complaints on this site were
   exactly that. */
.mission-net{ margin:var(--section-y-sm) 0 0; }
.mission-net-scroll{ overflow-x:auto; scrollbar-width:thin; }
.mission-net-stage{ position:relative; width:min(100%, calc((100vh - var(--space-9)) * 1840 / 854));
  width:min(100%, calc((100svh - var(--space-9)) * 1840 / 854));
  min-width:720px; margin-inline:auto; line-height:0; background:var(--black);
  /* the render's terrain runs right to its left/right edges; this mask lets
     those edges dissolve into the page instead of stopping as a hard
     rectangle */
  -webkit-mask-image:linear-gradient(to right, transparent, #000 6%, #000 94%, transparent);
          mask-image:linear-gradient(to right, transparent, #000 6%, #000 94%, transparent); }
.mission-net-stage img{ display:block; width:100%; height:auto; }
/* Slight flat black wash over the render. It sits above the image but under
   the lines and labels (positioned later in the DOM), which stay full strength. */
.mission-net-stage::before{
  content:""; position:absolute; inset:0; background:rgba(var(--rgb-black),.25); pointer-events:none;
}
.mn-lines{ position:absolute; inset:0; width:100%; height:100%; overflow:visible; pointer-events:none; }
.mn-draw{ fill:none; stroke:var(--white); stroke-width:36; stroke-dasharray:1; stroke-dashoffset:0; }
.mn-line{ fill:none; stroke-linecap:round; animation:mn-flow 1.6s linear infinite; }
.mn-line--peer{
  stroke:color-mix(in srgb, var(--white) 62%, transparent); stroke-width:3; stroke-dasharray:.1 11;
  filter:drop-shadow(0 0 2px color-mix(in srgb, var(--white) 40%, transparent));
}
.mn-line--spoke{
  stroke:color-mix(in srgb, var(--color-accent) 85%, transparent); stroke-width:2.5; stroke-dasharray:.1 11;
  filter:
    drop-shadow(0 0 2px color-mix(in srgb, var(--color-accent) 50%, transparent))
    drop-shadow(0 0 6px color-mix(in srgb, var(--color-accent) 25%, transparent));
}
@keyframes mn-flow{ to{ stroke-dashoffset:-23; } }

.mn-label{
  position:absolute; left:var(--gx); top:var(--gy);
  color:var(--white); white-space:nowrap; pointer-events:none;
  text-shadow:0 0 var(--space-2) var(--black), 0 0 2px var(--black);
}
.mn-label--c{ transform:translate(-50%,0); }
.mn-label--r{ transform:translate(var(--space-3),-50%); }
.mn-label--l{ transform:translate(calc(-100% - var(--space-3)),-50%); }
.mn-label--core{ color:var(--color-accent);
  padding:var(--space-1) var(--space-2); background:color-mix(in srgb, var(--black) 82%, transparent); }

/* Hidden start state, only while JS is in charge and .in hasn't landed. */
.js .mission-net:not(.in) .mn-draw{ stroke-dashoffset:1; }
.js .mission-net:not(.in) .mn-label{ opacity:0; }
.js .mission-net.in .mn-draw{ animation:mn-draw 1500ms var(--ease-in-out) var(--dl) backwards; }
.js .mission-net.in .mn-label{ animation:mn-fade var(--dur-enter) var(--ease-out) var(--dl) backwards; }
@keyframes mn-draw{ from{ stroke-dashoffset:1; } to{ stroke-dashoffset:0; } }
@keyframes mn-fade{ from{ opacity:0; } to{ opacity:1; } }

@media (prefers-reduced-motion:reduce){
  .js .mission-net .mn-draw,
  .js .mission-net .mn-label{ animation:none !important; }
  .mn-line{ animation:none; }
}

.mission-net-copy{ display:grid; grid-template-columns:1fr; gap:var(--space-8); margin-top:var(--space-8); }
.mission-net-copy .gap-copy{ display:flex; flex-direction:column; gap:var(--space-4); }
@media (min-width:1024px){
  .mission-net-copy{ grid-template-columns:minmax(0,1fr) minmax(0,1fr); column-gap:var(--gutter-lg); }
  .mission-net-copy .gap-copy{ max-width:var(--measure-card); }
  .mission-net-copy .gap-copy:nth-child(2){ margin-left:var(--space-7); }  /* Platform State sits a little further right */
}
@media (min-width:1280px){
  .mission-net-copy .gap-copy:nth-child(2){ margin-left:var(--space-9); }
}

/* ── FOLD 2: EVERY SYSTEM SEES A PIECE — plain text passage ────────────────
   No visuals, no card treatment, no eyebrow, no muted/grey copy anywhere —
   just four groups (heading+subtext, the "but these systems..." paragraph,
   the constrained-by-interfaces list, the closing statement), centred, all
   full white, all set in the heading sizes (t-h1/t-h2/t-h3 — see the
   markup, there's no t-body left in this section) rather than reading-body
   scale, so the passage reads as one large deliberate statement instead of
   regular copy. Capped to the body measure and centred within the full
   bleed width so it doesn't run edge-to-edge at wide widths. This base
   rule is also the <1024px/reduced-motion/no-GSAP fallback: gap-scroll.js
   only ever adds behaviour on top via the [data-motion="pinned"] rules
   below, never removes this stacked layout. */
#gap .gap-copy{ display:flex; flex-direction:column; gap:var(--space-8);
  max-width:var(--measure-body); margin-inline:auto; text-align:center; }
/* Bigger than the surrounding body copy so it reads as the section's
   punchline, not just another paragraph — "in big font" per the brief. */
#gap .gap-statement{ margin:0; }
/* The 5-point interfaces list used to live here as a bordered .copy-list —
   moved out to its own #gap-result section/skyline (viper.html, and the
   rules further down this file) once the pin was split in two. Nothing
   #gap-scoped needed for .copy-list any more; the shared component itself
   (components.css) is untouched and still serves every other page that
   uses it (applications/nexus/privacy/terms). */

/* ── FOLD 2, PINNED: scroll-scrubbed, one scene revealed at a time ─────────
   >=1024px, GSAP present, motion not reduced (gap-scroll.js).

   Every value that changes in here — which scene is showing, how bright
   each phrase is, that box border's grey-to-white climb — is written
   directly onto the element as an inline style by gap-scroll.js on every
   scroll-driven update, not toggled by a class. transition:none
   throughout this block is deliberate: a lingering CSS transition would
   keep easing each new inline value in after the fact, which is exactly
   the "it keeps changing after I've stopped scrolling" feel this was
   rewritten to remove — scroll is the only thing that moves anything
   here, forward or back.

   No extra section padding either: the pinned stage below is already a
   full screen on its own, so the usual top/bottom section rhythm padding
   on top of it just added dead space around an already-full-height block
   — that's what was reading as too much space above and below. min-height
   is pulled in from a full 100svh too, for the same reason: a short scene
   (the opening heading+subtext, mainly) was floating in a lot of empty
   black above and below it.

   A narrower measure than the base rule above (max-width) — at these
   larger sizes, the full body measure would run so wide each scene sits
   on one or two lines only; capping it tighter here means more, shorter
   lines, closer to how the brief's reference reads.

   Scenes are stacked on top of each other (position:absolute, all sharing
   the same box) and cross-fade via opacity — gap-scroll.js computes that
   fade directly from scroll progress at each scene boundary, not a class
   toggle. .gap-copy needs an explicit height and position:relative for
   that stack to have something to centre inside and anchor to; centring
   is a plain top:50%/-50% shift rather than flex's justify-content:center,
   since flex can only centre a single in-flow child and these are all
   absolutely positioned on top of one another instead.

   The network-mesh illustration sits behind the whole walk, not per scene
   — background-attachment:fixed pins it to the viewport for as long as
   #gap is pinned, so it reads as one continuous backdrop the text plays
   over rather than four separate images swapping with the scenes. Scoped
   to [data-motion="pinned"] only, so it's a >=1024px thing; fixed
   attachment has known jank on mobile browsers anyway, and below that
   width the section is the plain stacked fallback with no full-bleed
   backdrop to hang it on. The flat scrim underneath the image is a
   plain-black overlay, not the section's own surface-dark token, because
   it has to stay dark enough for white text and yellow box outlines to
   read clearly without burying the terrain under it — .74 was doing the
   latter at the cost of the former (the image was barely readable);
   pulled down to .5, which still checked clean for text/border contrast
   against the image in testing. This is the one spot on the page tuned
   by eye against the image rather than pulled from the surface tokens. */
#gap[data-motion="pinned"]{
  padding-block:var(--section-y-sm);
  background-image:linear-gradient(rgba(6,6,6,.5),rgba(6,6,6,.5)),
    url("../img/viper-fold2-network.webp");
  background-size:cover; background-position:center; background-repeat:no-repeat;
  background-attachment:fixed;
}
#gap[data-motion="pinned"] .gap-copy{
  position:relative; min-height:92svh; max-width:var(--measure-lead);
}
#gap[data-motion="pinned"] .gap-scene{
  position:absolute; left:0; right:0; top:50%; transform:translateY(-50%);
  opacity:0; pointer-events:none; transition:none;
}
/* Only one div-wrapped scene left (heading+subtext) now that the
   intro+list scene and the closing statement have moved out to
   #gap-result below — still needs its own flex column + gap between
   children, unlike the remaining bare-<p> scene ("but these systems...",
   a single text node's worth of .brighten spans, which reads fine as
   plain block text; forcing flex on it would turn those inline spans into
   flex items and lay them out side by side instead of wrapping as a
   sentence). */
#gap[data-motion="pinned"] div.gap-scene{ display:flex; flex-direction:column; gap:var(--space-6); }

/* Every phrase/sentence/list item inside the active scene (.brighten,
   wrapped per phrase in the markup) starts dim and climbs to full white
   as gap-scroll.js scrubs its opacity in directly from scroll position —
   see the file header above for why there's no transition on it. */
#gap[data-motion="pinned"] .gap-scene .brighten{ opacity:.3; transition:none; }


/* ── FOLD 2b: THE RESULT — five-point skyline, then the merge ─────────────
   Base rule here is also the <1024px/reduced-motion/no-GSAP fallback,
   same convention as #gap above: gap-result-scroll.js only ever adds
   behaviour on top via the [data-motion="pinned"] rules further down,
   never removes this stacked, everything-visible layout. */
#gap-result .gap-result-stage{ display:flex; flex-direction:column;
  align-items:center; gap:var(--space-8); max-width:var(--measure-lead);
  margin-inline:auto; text-align:center; }
#gap-result .gap-result-title{ margin:0; }
#gap-result .gap-skyline{ list-style:none; margin:0; padding:0;
  display:flex; flex-direction:column; gap:var(--space-5); width:100%; }
/* Smaller than the section's own big-type default (t-h2 title above) —
   plain --text-body rather than echoing it as another heading size — but
   not the caption-scale .8 multiplier this briefly carried; bumped back
   up a step per direct follow-up ("too small now, make it a little big")
   while staying visibly more compact than the title above it. */
#gap-result .gap-point-text{ margin:0; font-size:var(--text-body);
  font-weight:400; line-height:var(--lh-snug); }
/* The stem + travelling dot are a >=1024px/pinned-only flourish — five
   columns of vertical lines side by side would be cramped reading on a
   narrow viewport, so the fallback here is the plain stacked list above,
   same as #gap's own boxed points used to fall back to. */
#gap-result .gap-stem, #gap-result .gap-dot{ display:none; }
#gap-result .gap-result-close{ margin:0; }
/* Same >=1024px/pinned-only flourish as the stem/dot above — this is the
   photo that only shows up behind the final merge beat (gap-result-bg,
   below), not something the plain stacked fallback needs to carry. */
#gap-result .gap-result-bg{ display:none; }

/* ── FOLD 2b, PINNED: scroll-scrubbed skyline + merge ──────────────────────
   >=1024px, GSAP present, motion not reduced (gap-result-scroll.js) — same
   "scroll position IS reveal progress" rule as #gap's own pin (see
   gap-scroll.js's file header for the full reasoning); nothing in here is
   a class toggle or a timed transition, everything is written straight
   from scroll progress on every ScrollTrigger update.

   The title stays in normal flow at the top of the stage (it's there from
   the start, not part of the point-by-point walk — "the title will be
   there" per the brief) and full brightness through all five points, only
   fading — per a later direct request — alongside the skyline once the
   merge slice starts. The skyline itself sits below it, also in normal
   flow. The close statement is the one thing pulled out of flow —
   absolutely centred over the SAME spot the skyline occupies, opacity 0
   until the final scroll slice — so the merge reads as the skyline (and
   the title above it) dissolving into the statement in place, not the
   statement arriving underneath them. */
/* position:relative here (not just on .gap-result-stage below) is what
   lets .gap-result-bg break out of .bleed's own side padding and sit
   truly edge-to-edge — the section itself spans the full viewport width
   with no inline padding of its own (.bleed carries that), so anchoring
   the photo to the SECTION's box instead of the padded .bleed/stage
   inside it is what "keep the image end to end on screen" needed; it was
   only filling the padded content column before. */
/* z-index:0 alongside position:relative is load-bearing, not decorative
   — position:relative on its own does NOT create a new stacking context
   (only an explicit z-index, or a handful of other properties, does), so
   without it .gap-result-bg's z-index:-1 below wasn't stacking against
   THIS section at all: with no ancestor between it and the page root
   establishing a context either, it painted at the very bottom of the
   ENTIRE page's stacking order — behind every other section's own
   opaque background — instead of just behind this section's own text.
   Root-caused after a direct "I can't see the bg image" report; z-index
   alone on .gap-result-bg wouldn't have fixed it without this. */
#gap-result[data-motion="pinned"]{ padding-block:var(--section-y-sm); position:relative; z-index:0; }
#gap-result[data-motion="pinned"] .gap-result-stage{
  position:relative; min-height:92svh; max-width:none; justify-content:center;
}
/* The merge-beat photo (direct request: a background image for THIS
   reveal section, only appearing "when this text comes" — not for the
   five-point walk before it). Absolutely covers the whole stage, same
   cover/center/no-repeat treatment #gap's own pinned background uses
   above, but doesn't reuse that background-attachment:fixed trick: this
   one lives and dies with a single scroll-scrubbed opacity value rather
   than riding along as a fixed backdrop for a multi-scene walk, so it's a
   plain absolutely-positioned layer instead. z-index:-1 (not DOM order)
   is what actually keeps it behind the title/skyline/close text — this
   stage is already position:relative, so a negative z-index here paints
   behind every one of its normal-flow and positioned children without
   needing z-index bumped on any of them in turn. Ships pre-tinted and
   grained (the source photo, not a filter here), so this file only ever
   needs a light scrim on top for text contrast, same idea as #gap's own
   flat-black overlay above. Starts at opacity:0 — gap-result-scroll.js
   scrubs it up to 1 in lockstep with the close statement's own
   mergeLocal fade, so the photo arrives WITH "VIPER is built to close
   that gap.", never before it. */
#gap-result[data-motion="pinned"] .gap-result-bg{
  display:block; position:absolute; inset:0; z-index:-1;
  background-image:linear-gradient(rgba(6,6,6,.45),rgba(6,6,6,.45)),
    url("../img/gap-result-close-bg.webp");
  /* contain, not cover — direct follow-up ("why is it so zoomed? keep it
     proportional and true to size"): cover was scaling the photo up
     past its own aspect ratio to fill this full-bleed section edge to
     edge with no crop left over, which read as an aggressive zoom on a
     section this wide relative to the image's native 1774x887. contain
     shows the whole photo at its own proportions with nothing cropped or
     stretched; the section's own surface-dark fill (base.css) shows
     through as plain letterboxing on whichever axis has room to spare,
     which reads as intentional rather than a stray gap since the whole
     section is already that same dark tone. */
  background-size:contain; background-position:center; background-repeat:no-repeat;
  background-attachment:fixed;
  opacity:0; transition:none; will-change:opacity;
}
/* position:absolute + background-attachment:fixed (NOT position:fixed on
   the element itself) — a position:fixed version of this box was tried
   and reverted: data-motion="pinned" is set once, near-immediately, by
   matchMedia('(min-width:1024px)') and stays set for the rest of the
   page's life at that width — it does NOT toggle per pin start/end. A
   position:fixed .gap-result-bg is therefore viewport-pinned PERMANENTLY
   once the page is desktop-width, not just while this fold is on screen;
   once the scroll-scrubbed opacity locks at 1 at the end of the merge
   (nothing ever resets it back down as you keep scrolling past), the
   photo kept covering every section below it too ("extending to the
   entire page" — direct follow-up bug report). position:absolute keeps
   the element's own box tied to the SECTION's normal document-flow
   position instead: it can only ever paint while some part of that box
   is on screen, i.e. only for as long as the fold itself is, exactly
   like every other absolutely-positioned overlay on the site.

   background-attachment:fixed is what makes that compatible with contain/
   center still reading correctly: #gap-result itself (unlike
   .gap-result-stage, the thing GSAP actually pins) is never visually
   fixed to the viewport — it's an ordinary tall block spanning this
   whole fold's scroll distance, several viewport-heights tall — so a
   plain scroll-attached background would size/centre the photo against
   that entire tall box instead of whatever slice is actually visible.
   background-attachment:fixed makes the background's own positioning
   area the viewport regardless of the element's box size, while the
   element's box (confined to the section, per above) still gates
   whether anything paints at all. Same split #gap's own pinned backdrop
   relies on, just done with an extra element instead of the section's
   own background-image, since this one needs an independently
   scroll-scrubbed opacity the section's own background can't carry.

   will-change:opacity below is the fix for the SEPARATE "image is
   missing on localhost" bug, root-caused by direct testing, not guessed:
   this element sits at display:none until the pin context above is
   created, then at opacity:0 from this rule for however long the user
   takes to scroll down to this fold — often several seconds with
   nothing about it changing. Chromium was found to sometimes never
   (re)promote a proper paint layer for an element that's gone that long
   fully transparent before its opacity is later driven upward by JS:
   swapping in a freshly-inserted clone of the exact same element
   rendered correctly instantly, proving the element's own stale DOM/
   layer identity — not its CSS, position, or the pin mechanism — was
   what made it invisible. will-change:opacity tells the browser up
   front that this element's opacity will change later, which keeps a
   live, correctly composited layer for it the whole time instead of
   letting a long static opacity:0 period lead it to stop being properly
   repainted. */
/* Title now also carries a scrubbed opacity (gap-result-scroll.js) — full
   brightness through all five points, fading out in step with the skyline
   once the merge slice starts ("the heading should disappear" once the
   close statement lands), so transition:none here matters just as much as
   it does on .brighten: no lingering .reveal fade-transition fighting the
   inline value gap-result-scroll.js writes every frame. */
#gap-result[data-motion="pinned"] .gap-result-title{ transition:none; }
#gap-result[data-motion="pinned"] .brighten{ opacity:.3; transition:none; }
/* margin-top pulled way in from --space-9 — per direct follow-up, point 3
   now goes tall enough to want to sit close under the title, not a fixed
   96px below it regardless of how tall the peak gets. */
#gap-result[data-motion="pinned"] .gap-skyline{
  flex-direction:row; align-items:flex-end; justify-content:space-between;
  gap:var(--space-4); margin-top:var(--space-5); transition:none;
}
#gap-result[data-motion="pinned"] .gap-point{
  display:flex; flex-direction:column; align-items:center; flex:1 1 0;
}
/* Narrower measure than the fallback's default — a tighter wrap keeps
   each point reading as a compact little block instead of a wide caption.
   Widened a touch from 15 to 18ch alongside the font-size bump above, so
   the bigger type doesn't wrap any tighter than it did before.

   min-height/flex/justify-content:flex-end is the fix for the skyline not
   actually reading as a skyline: these 5 sentences wrap to very different
   line counts at 18ch (6/4/6/5/5 measured), and with text stacked above
   the stem in a bottom-aligned column, that text height was the dominant
   term — point 1's 6-line block was taller overall than point 2's
   shorter-stem-but-4-line block despite point 2 having the taller stem,
   which is exactly the "2nd/4th should read taller than 1st/5th" bug
   reported live. Reserving the longest sentence's own line count (6) as a
   shared min-height for every point, and bottom-aligning each one's actual
   text inside that box fixes it: a <p>'s inline content becomes one
   anonymous flex item under display:flex — flex-direction:column here so
   the box's own width (max-width above) still governs where the text
   wraps, same as a plain block would; justify-content:flex-end then pins
   that whole text block, however many lines it wraps to, flush to the
   bottom of the reserved box, and align-items:center keeps it centred the
   same way text-align:center already reads for each wrapped line. Makes
   every point's text TOP land at the same height regardless of its own
   line count, so stem height is the only thing left driving how high each
   point's peak actually reads — which is what a skyline needs. */
#gap-result[data-motion="pinned"] .gap-point-text{
  max-width:18ch; margin:0 0 var(--space-3);
  min-height:calc(var(--text-body) * var(--lh-snug) * 6);
  display:flex; flex-direction:column; justify-content:flex-end; align-items:center;
}
/* pathLength isn't available on a plain CSS box the way the mission
   diagram's SVG paths get it (motion.css), so the stem draws via
   clip-path instead — same inset() technique #problem's connector-scan
   loop uses (motion.css's pd-scan), just driven by scroll progress here
   instead of a CSS animation. inset(100% 0 0 0) is fully hidden (the
   whole box clipped away from the top); gap-result-scroll.js scrubs that
   top inset down to 0% as the dot climbs, so the line appears to draw
   itself upward in lockstep with the dot's own travel. */
#gap-result[data-motion="pinned"] .gap-stem{
  display:block; width:var(--hair); height:var(--stem-h,var(--space-9));
  background:var(--color-border-strong); clip-path:inset(100% 0 0 0); transition:none;
}
/* Starts at the stem's own foot (no transform) and travels up by the
   stem's full height as its point's slice plays — gap-result-scroll.js
   reads --stem-h off each stem via getBoundingClientRect() rather than
   this file duplicating the raw pixel value, so the two never drift out
   of sync. Fades out over the last part of the climb, timed to land at
   0 exactly as its point's text reaches full brightness — see
   gap-result-scroll.js's CLIMB_END for where that handoff starts. */
#gap-result[data-motion="pinned"] .gap-dot{
  display:block; width:6px; height:6px; border-radius:50%; margin-top:-1px;
  background:var(--color-accent); opacity:0; transition:none;
}
/* Resting state before the final slice: sat over the skyline's own space
   (see .gap-result-stage above), invisible and non-interactive until
   gap-result-scroll.js scrubs it in during the merge. */
#gap-result[data-motion="pinned"] .gap-result-close{
  position:absolute; left:0; right:0; top:60%; transform:translateY(-50%);
  opacity:0; pointer-events:none; transition:none; text-align:center;
}



/* ── FOLD 4: OPERATING MODEL — tab row scroll buttons ───────────────────────
   Same reasoning as home.css's #mission .pillars-scroll-btn: a bare
   arrowhead (no box, no fill, two rotated border edges — the .nav-caret
   construction), grouped in a controls pair after the row it scrolls
   rather than flanking it, so the row's own left edge always lines up
   with the heading above it regardless of which controls are visible.
   .next dims once nothing is left to reveal; .prev stays invisible until
   the row has scrolled away from its start, so there's always a way back
   to the first tab without hand-scrolling. carousels.js's tabStripNav
   drives both, off the active tab's own index rather than scroll
   position — see the "Side-by-side arrows" note below for why. */
/* .pic-tabs (components.css) carries its own margin-top:section-y-sm, sized
   for when it sits directly as a section child right after .split. Wrapped
   in .pic-tabs-row instead, that margin doesn't disappear — it inflates the
   row's own cross-size (the tallest flex item's margin box sets the line
   height), which then throws off align-items:center for the shorter control
   buttons beside it: they end up centred against the tabs' empty margin
   space, well above the visible tab labels. Moving the gap-from-heading
   onto the row itself and zeroing it on .pic-tabs keeps the same spacing
   without the row growing taller than its visible content. */
/* Side-by-side arrows kept as-is here — no repositioning requested for this
   strip, only the Swiper engine swap: .pic-tabs-row's row layout is
   unchanged, and only .pic-tabs itself needs its manual scroll-snap CSS
   moved into a :not(.swiper-initialized) fallback (carousels.js drives the
   paging now) plus the is-end/is-visible classes swapped for Swiper's own
   .swiper-button-disabled toggle. */
#orchestration .pic-tabs-row{ display:flex; align-items:center; gap:var(--space-4);
  margin-top:var(--section-y-sm); }
/* Below 1024px the tabs are gone and the panels stack (components.css,
   "MOBILE: NO TAB ROWS"). This wrapper has to go with them, and the rule has
   to live HERE rather than beside that block: the display:flex directly above
   uses the same selector at the same specificity and this file loads after
   components.css, so the same rule written there loses and left an empty row
   with its prev/next buttons still sitting in it. The controls are tab
   controls (carousels.js) — with no tabs there is nothing for them to do. */
@media (max-width:1023px){
  #orchestration .pic-tabs-row{ display:none; }
}
#orchestration .pic-tabs{
  margin-top:0; flex:1 1 auto; min-width:0;
}
/* Swiper's own base CSS gives every .swiper-slide (so every .tab here)
   width:100% — meant for a one-slide-per-view carousel, not a compact tab
   bar, so left alone it stretches whichever tab is current across the
   entire row instead of sizing to its own label. slidesPerView:'auto'
   (carousels.js) is what's supposed to size each tab from its own content,
   but that only works if the tab's CSS width is already content-sized
   before Swiper reads it — max-content reclaims that regardless of what
   .swiper-slide set first. */
#orchestration .pic-tabs .tab.swiper-slide{ width:max-content; }
/* FALLBACK: if Swiper's vendor file didn't load, carousels.js never flips
   .swiper-initialized on and this row needs to still scroll on its own —
   the exact scroll-snap rules this used to carry unconditionally, now
   scoped so they only engage in that failure case. */
#orchestration .pic-tabs:not(.swiper-initialized){
  flex-wrap:nowrap; overflow-x:auto;
  scroll-snap-type:x proximity; scroll-behavior:smooth;
  scrollbar-width:none; -ms-overflow-style:none;
}
#orchestration .pic-tabs:not(.swiper-initialized)::-webkit-scrollbar{ display:none; }
#orchestration .pic-tabs:not(.swiper-initialized) .tab{ flex:none; scroll-snap-align:start; }
/* The row itself is .strip-controls in components.css now. This one is NOT
   one of the identical copies that component replaced: these arrows sit
   inline beside the tab row rather than in their own right-aligned row
   below a strip, so the shared justify-content/margin-top are overridden
   back off here. viper.css loads after components.css, so this wins. */
#orchestration .strip-controls{
  justify-content:flex-start; margin-top:0;
  /* padding-inline:0, against the component's default: .strip-controls insets
     itself by --edge so its buttons land on the container edge, but THIS
     instance is already inside a .bleed (section > .bleed > .pic-tabs-row >
     here), which has applied that inset once already. Keeping the default
     added it twice — the buttons stopped 48px short of the container edge and
     sat 64px off the end of the tab row instead of --space-2. Every other
     strip's controls sit outside a .bleed, which is why the padding belongs
     on the component and the exception belongs here. */
  padding-inline:0;
}
/* The prev/next pair is .strip-btn in components.css now — one filled
   component instead of this same block pasted into four page stylesheets.
   Nothing page-specific remains: #orchestration .pic-tabs-scroll-btn carried no rule
   the other three did not carry identically. Positioning stays local (the
   *-controls rule above); the button's own look does not. */

/* ── FOLD 4: OPERATING MODEL — "coming soon" tab ─────────────────────────
   Threat Classification & Detection isn't built yet, but earns a place in
   the row so the roadmap is visible rather than silently absent. .pill
   (components.css) carries the label; --soon fills it solid accent yellow
   instead of the shared bare-outline look — the same "yellow means
   attention" language the CTA buttons already use — so it reads as loud
   on purpose rather than blending in like every shipped tab's plain text
   label. Reused a second time next to the panel's own heading, so the
   label still reads clearly once a visitor has actually clicked through
   to this tab and the row above may have scrolled out of view.
   align-items:center on .tab--soon overrides .tab's own baseline
   alignment (components.css) — baseline put the pill's padded box a few
   px below the tab label's own baseline instead of visually centred
   beside it. */
/* SUBTLE, per direct request: "this coming soon must be subtle, it's too big
   and in the face."

   It was a solid --color-accent fill with accent-ink text — the loudest
   treatment in the system — sitting inside a tab button whose own active
   underline is already accent. That is two accent moments in one row, over
   the one-per-viewport budget (guidelines rule 6), and it made the least
   important thing in the row the first thing the eye landed on.

   Now: no fill, no accent, hairline border, muted text, and tighter padding
   so its box stops competing with the tab label it annotates. It still reads
   as a badge rather than as part of the label, which is the only job it has. */
.pill--soon{
  background:none; color:var(--color-text-muted);
  border-color:var(--color-border-subtle);
  padding:var(--space-1) var(--space-2);
  padding-right:calc(var(--space-2) - var(--track-label));
}
.tab--soon{ align-items:center; }
/* The panel's own "Coming soon" now sits in the middle of the picture instead
   of beside the heading (the tab-row pill is unchanged). The render is 16:9, so
   this panel's frame matches it rather than cropping the corner brackets off
   with the site-wide 16:10 cover crop. */
#orchestration [data-panel="7"] .pic-media{ aspect-ratio:1672 / 941; }
.pic-soon{
  position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
  padding:var(--space-3) var(--space-6);
  color:var(--white); white-space:nowrap; pointer-events:none;
  background:var(--scrim-82); border:var(--hair) solid var(--color-border);
}


/* ── FOLD 5: EDGE AUTONOMY — product designation ────────────────────────────
   A metadata line naming the agent under the heading. .t-meta carries no
   margin of its own (base.css — it is normally set on a span), and a <p>
   would otherwise take the browser's 1em default on both sides, so the
   offset is declared here rather than inherited. .split's gap only
   separates the two columns, never the stack inside one.
   Scoped to this page because it is the only designation line on the site;
   if a second product ever needs one, this moves to components.css. */
#edge .edge-designation{ margin:var(--space-4) 0 0; }


/* ── FOLD 5: EDGE AUTONOMY — the flip grid ──────────────────────────────────
   Replaces the old three-column .flow-strip. The heading above (.split,
   base.css) is untouched — eyebrow and h2 on the left, the lead paragraph
   beside it, same as every other fold on the site. What changed is what
   sits below it: a dark card (surface-dark + .ground, base.css, so it
   paints the flipped black-on-white palette regardless of this section's
   own surface-light) holding a 4x2 grid of the old flow-strip's three
   points (Edge Execution / Degraded Connectivity / Human Authority). Each
   of those three content cells is its OWN click target (edge-flip.js
   toggles .is-flipped on the cell itself, not a shared grid class;
   role="button" + tabindex on each cell's div since it's a custom
   control, not phrasing content a <button> could legally wrap): click one
   and it does a real 3D card flip — same mechanism as glossary.html's
   .g-card (perspective on the outer cell, preserve-3d + rotateY(180deg)
   on an inner wrapper, backface-visibility:hidden on each face) — from a
   copy face to a photo face, click it again and it flips back. The other
   two cells are untouched either way, and any combination of the three
   can be flipped at once. Nothing here is scroll-linked — this is a
   plain per-cell state toggle, no GSAP involved.

   Both faces are absolutely positioned inside a cell whose row height is
   an explicit fixed value (see --edge-row-h below), not derived from
   content — so unlike an opacity-crossfade approach, neither face's own
   size can ever move the grid: nothing needs measuring, capping, or
   duplicating to hold the row steady across whichever combination of
   01/02/03 happen to be flipped at once.

   The grid is 4 columns x 2 rows, eight equal cells, and that's exactly
   what the static (text) state stays — no spans, plain auto-placement,
   matching the reference as given. Flipping a cell is what reshapes it:
   01 and 02 each grow to swallow the empty cell beside them into a
   two-column tile, and 03 grows to swallow the one below it into a
   two-row tile — a photo gets a tile shaped for it instead of being
   forced into an identical box, but only once you've actually asked to
   see it, and independently of whatever the other two cells are doing.
   The two 1x1 units that are never absorbed (fill-2, fill-3) stay bare in
   every combination — literally empty <div>s, no image, no placeholder
   caption — so an empty cell reads as empty, not as an unfinished one.

   Below 1024px there's no room for any of this, so the grid becomes what
   the old .flow-strip already was on small screens: the three numbered
   points stacked plain, no photos, no click target. The five bare cells
   and the click affordance simply don't render below that width. */
#edge .edge-grid{
  margin-top:var(--section-y-sm); border:var(--hair) solid var(--color-border);
  padding:var(--space-5);
}
#edge .edge-cell{ display:none; }
/* A divider between stacked items, not under every item: :last-of-type
   would look like the obvious tool here, but it's scoped by tag name, not
   class, and every cell — content or filler — is a plain <div>, so it can
   never single out "the last .edge-cell--content" the way it reads. The
   general sibling combinator does what's actually needed instead: give a
   top border to any content cell that has an earlier content cell among
   its siblings, filler <div>s in between or not. */
#edge .edge-cell--content{
  display:flex; flex-direction:column; gap:var(--space-3); padding:var(--space-5) 0;
}
#edge .edge-cell--content ~ .edge-cell--content{ border-top:var(--hair) solid var(--color-border-subtle); }
/* num + copy stack plainly below 1024px, num above heading above body,
   same shape either way the flip apparatus renders — no wrapper needed
   for that, .edge-cell--content's own column flex above already does it. */
#edge .edge-cell-face--front{ display:flex; flex-direction:column; gap:var(--space-3); }
#edge .edge-cell-copy{ display:flex; flex-direction:column; gap:var(--space-2); }
/* Below 1024px the flip apparatus is inert: .edge-cell-flip is unwrapped
   with display:contents so .edge-cell-face--front becomes a direct flex
   child of .edge-cell--content instead of an absolutely positioned face;
   the photo face and the click hint never render at all down here. */
#edge .edge-cell-flip{ display:contents; }
#edge .edge-cell-face--back{ display:none; }
#edge .edge-cell-hint{ display:none; }

@media (min-width:1024px){
  /* Explicit row height, not content-driven: the whole point of a real
     flip card (see below) is that both faces sit absolutely inside a box
     whose size nothing about their own content can move. 230px
     comfortably fits the longest static copy (02's four-line body) at
     the compact size set further down, with room to spare in every
     other combination — verified across every flip combination, not
     just guessed. */
  #edge .edge-grid{
    --edge-row-h:230px;
    position:relative; padding:0;
    display:grid; grid-template-columns:repeat(4,1fr); grid-template-rows:repeat(2,var(--edge-row-h));
  }
  /* No grid-column/grid-row here — all eight cells are plain auto-placed
     items, landing in DOM order into the eight units exactly as they
     read in the markup (01, fill-1, fill-2, 03, fill-3, 02, fill-4,
     fill-5 → row1 then row2, left to right). Borders come from position
     alone, same nth-child scheme as any other uniform grid on the site:
     every cell gets a right and bottom rule, the rightmost column drops
     the right one, the bottom row drops the bottom one. */
  #edge .edge-cell{
    display:block; position:relative; overflow:hidden;
    border-right:var(--hair) solid var(--color-border-subtle);
    border-bottom:var(--hair) solid var(--color-border-subtle);
  }
  #edge .edge-cell:nth-child(4n){ border-right:0; }
  #edge .edge-cell:nth-child(n+5){ border-bottom:0; }
  /* Padding moves to each individual face (below) instead of sitting
     here — .edge-cell-flip needs to fill the cell edge to edge for its
     two faces' inset:0 to mean "the whole cell", not "the padded box". */
  #edge .edge-cell--content{ padding:0; cursor:pointer; perspective:1200px; }

  /* Flipping a cell is the only time any of this grid reshapes, and each
     of the three does it on its own — the rules below key off the CELL's
     own .is-flipped (edge-flip.js puts it there directly now, not on a
     shared grid-level class), so 01 can be flipped while 02 and 03 stay
     put, in any combination. 01 and 02 each take the two columns they and
     their neighbour used to occupy separately; 03 takes both rows in its
     own column. fill-1, fill-4 and fill-5 are the three cells that get
     swallowed that way — the general sibling combinator (~) reaches a
     filler from whichever content cell absorbs it, since is-flipped now
     lives on that cell rather than a shared ancestor; display:none removes
     the filler from the grid outright rather than leaving an invisible
     gap. fill-2 and fill-3 (the two that never get absorbed) need no rule
     here at all regardless of which cells are currently flipped: CSS
     Grid's own auto-placement slots every still-auto-positioned item into
     whatever's left once however many explicit spans have claimed their
     space. 03's own border-bottom is explicitly zeroed once it spans into
     the last row — its default border-bottom (from the uniform rule
     above, since position 4 isn't in the bottom row normally) would
     otherwise draw a redundant line right on top of the grid's own outer
     edge. Row height is fixed (above), so none of this spanning ever
     moves the grid regardless of which cells are flipped — a spanned
     cell's two faces just get more width to render into. */
  /* .is-expanded, NOT .is-flipped, and the difference is the whole fix for
     the jagged flip reported here.

     grid-column, grid-row and display are not animatable properties. Both
     classes used to be the same class, so the instant it was applied the
     cell's box doubled in one frame and its filler neighbour vanished in
     the same frame, while the 3D rotation underneath took --dur-enter
     (900ms) to play. Geometry snapping against a running animation is
     exactly what read as jagged.

     Splitting them lets edge-flip.js apply the geometry at the MIDPOINT of
     the rotation, where the card is edge-on at 90deg and occupies no visible
     width — so the resize still happens in one frame, but in the one frame
     where there is nothing to see. Nothing here animates that could not
     animate before; the change is purely WHEN the snap happens.

     Both classes must stay in lockstep: a cell left with .is-expanded but
     not .is-flipped is a double-width cell showing its front face. See the
     paired timers in edge-flip.js, which is also why it cleans up its own
     pending timer if the cell is clicked again mid-flip. */
  #edge .edge-cell--01.is-expanded{ grid-column:1 / 3; grid-row:1; }
  #edge .edge-cell--01.is-expanded ~ .edge-cell--fill-1{ display:none; }
  #edge .edge-cell--03.is-expanded{ grid-column:4; grid-row:1 / 3; border-bottom:0; }
  #edge .edge-cell--03.is-expanded ~ .edge-cell--fill-5{ display:none; }
  #edge .edge-cell--02.is-expanded{ grid-column:2 / 4; grid-row:2; }
  #edge .edge-cell--02.is-expanded ~ .edge-cell--fill-4{ display:none; }

  /* The flip itself: same construction as glossary.html's .g-card-inner —
     perspective on the outer, non-rotating cell (above); a full-size,
     still non-positioned wrapper here so it fills that cell; two
     absolutely-positioned faces inside it, backface-visibility:hidden so
     only whichever one is currently facing forward ever paints.
     display:block undoes the base rule's display:contents (mobile only,
     in spirit, but written unconditionally there) — a transform has no
     effect on a boxless contents element, so without this override the
     cell would carry .is-flipped but never visibly rotate. */
  #edge .edge-cell-flip{
    display:block; position:relative; width:100%; height:100%;
    transform-style:preserve-3d;
    transition:transform var(--dur-enter) var(--ease-in-out);
  }
  #edge .edge-cell--content.is-flipped .edge-cell-flip{ transform:rotateY(180deg); }
  #edge .edge-cell-face{
    position:absolute; inset:0; overflow:hidden;
    backface-visibility:hidden; padding:var(--space-5);
  }
  #edge .edge-cell-face--front{ gap:var(--space-2); }
  /* display:block overrides the base rule's display:none (mobile only, in
     spirit, but written unconditionally there — same fix as .edge-cell-flip
     above) — without it the back face never generates a box, so the photo
     and duplicated numeral/heading inside it never render even once the
     cell carries .is-flipped and its parent has actually rotated. */
  #edge .edge-cell-face--back{ display:block; transform:rotateY(180deg); }

  /* Static copy, compact: card titles' usual t-h3/t-body pairing (26px
     heading, 18px body) read too big once the row settled to a fixed,
     denser height above — sized down a tier and tightened here instead
     of reusing those classes as given. */
  #edge .edge-cell-copy h3{
    font-family:var(--font-display); font-weight:500;
    font-size:var(--text-lead); line-height:var(--lh-snug); letter-spacing:var(--track-tight);
  }
  #edge .edge-cell-copy p{
    font-family:var(--font-body); font-weight:400;
    font-size:var(--ui-md); line-height:var(--lh-body);
  }

  /* Photo face: the render fills it edge to edge (inset:0 against the
     face's own padding box bleeds under the padding, same as any other
     full-bleed media slot on the site), the duplicated numeral + heading
     sit on top of it inset by that padding. The heading here is set to
     read exactly like the numeral beside it — same .t-meta/.accent
     styling in the markup — rather than the full card-title size the
     static face carries. */
  #edge .edge-cell-img{
    position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
  }
  /* Soft fade: the source photos read brighter than the rest of this dark
     card once dropped in at full brightness — reported directly ("don't
     look so bright"). A gradient tint (darkest along the top, where the
     numeral/heading sit, easing off through the middle so the photo itself
     still reads, settling slightly darker again at the bottom) rather than
     a flat filter, so it stays a "fade" and not a haze over the whole
     image. 12,14,14 is --black (tokens.css) spelled out in rgb() rather
     than referenced — CSS can't tint a var() through alpha without the
     relative-color rgb(from …) syntax, which nothing else on this site
     uses yet, so this stays a plain literal like the .edge-cell-face-head
     text-shadow just below does for the same reason. Sits between the
     photo and .edge-cell-face-head in source order — the head's own
     z-index:1 is what actually keeps it on top, not paint order, since
     this and the <img> are both z-index:auto. */
  #edge .edge-cell-face--back::after{
    content:''; position:absolute; inset:0; pointer-events:none;
    background:linear-gradient(180deg,
      rgba(12,14,14,.58) 0%,
      rgba(12,14,14,.22) 45%,
      rgba(12,14,14,.4) 100%);
  }
  #edge .edge-cell-face-head{
    position:relative; z-index:1;
    display:flex; align-items:baseline; gap:var(--space-3);
    text-shadow:0 1px 4px rgba(0,0,0,.85);
  }

  /* Click affordance: the same bare-arrowhead construction used
     everywhere else on the site (two rotated border edges — see
     .pillars-scroll-btn::before in home.css), rotated diagonal instead
     of left/right, paired with a "TAP" label small enough to read as
     a tag, not a caption. Lives outside .edge-cell-flip entirely,
     directly on the cell, so it never rotates with either face and reads
     identically whichever one is currently showing. */
  #edge .edge-cell-hint{
    display:flex; align-items:center; gap:6px;
    position:absolute; right:var(--space-5); bottom:var(--space-5); z-index:2;
    font-family:var(--font-display); font-weight:500; font-size:10px;
    letter-spacing:var(--track-label); text-transform:uppercase;
    color:var(--color-accent); pointer-events:none;
  }
  #edge .edge-cell-hint-icon{
    width:7px; height:7px; flex:none;
    border-right:2px solid var(--color-accent); border-bottom:2px solid var(--color-accent);
    transform:rotate(-90deg);
  }
}
@media (prefers-reduced-motion:reduce){
  #edge .edge-cell-flip{ transition:none; }
}

/* ── FOLD 6: COMPARISON TABLE — dull left, bright right ─────────────────────
   First real rules for .compare-table/.compare-row/.compare-answer — the
   markup existed on this page before but had no CSS behind it. Same "thin
   rule, not a filled block" divider language as .tabs and .flow-strip: a
   hairline under the head and under every row, single column below 800px,
   two beside each other above it.

   The emphasis the brief asked for ("left dull, right bright, that's
   Driif") is a colour swap only: the left column stays the section's own
   muted ink, the right column — head label and every row answer — sets
   in plain white, no tinted band, no border rule, no yellow at all. The
   contrast alone (white against the left column's own muted ink) is what
   reads as "the side that advances." */
#comparison .compare-table{ display:flex; flex-direction:column;
  margin-top:var(--space-7); border-top:var(--hair) solid var(--color-border-subtle); }
#comparison .compare-head{ display:grid; grid-template-columns:1fr; gap:var(--space-3);
  padding:var(--space-4) 0; border-bottom:var(--hair) solid var(--color-border-subtle); }
#comparison .compare-head span:last-child{ color:var(--color-accent); }
#comparison .compare-row{ display:grid; grid-template-columns:1fr;
  border-bottom:var(--hair) solid var(--color-border-subtle); }
#comparison .compare-row p{ margin:0; padding:var(--space-6) 0; }
#comparison .compare-answer{ color:var(--color-text); font-weight:500; }
@media (min-width:800px){
  #comparison .compare-head, #comparison .compare-row{
    grid-template-columns:1fr 1fr; column-gap:var(--gutter-lg);
  }
  #comparison .compare-row p:first-child{ padding-right:var(--gutter-lg); }
}
/* Column alignment with the intro above: from 1024px the heading/lead
   .split above the table (base.css) runs an 11fr / 6fr asymmetric pair,
   but this table stayed an even 1fr 1fr, so the VIPER column started far
   left of the lead text sitting over it — direct bug report. Same track
   ratio and same gutter here makes "The Approach Now" line up under the
   heading and "VIPER Mission OS" under the lead. Below 1024px the split
   stacks to one column, so the even two-up above stays. */
@media (min-width:1024px){
  #comparison .compare-head, #comparison .compare-row{
    grid-template-columns:minmax(0,11fr) minmax(0,6fr); column-gap:var(--gutter-lg);
  }
  /* Equal text-box widths. The 11fr column starts under the heading and
     the 6fr one under the lead (alignment kept), but the left text was
     filling its whole 11fr track while the right wrapped at 6fr, so the
     left lines ran far longer — direct bug report. The left paragraph is
     capped at 6/11 of its own track, which is exactly the 6fr track's
     width (11fr * 6/11 = 6fr), so both text boxes wrap at the same
     measure; the old right-padding is redundant and dropped. */
  #comparison .compare-row p:first-child{ max-width:calc(100% * 6 / 11); padding-right:0; }
}

/* ── LETTER SPLIT (gap-scroll.js) ──────────────────────────────────────────
   .split-w is an inline-block so a word cannot break across lines — without
   it, per-letter spans let the browser break mid-word and the headings came
   apart. .split-c stays inline: inline-block on a letter would kill the
   kerning between every pair.

   transition:none is the same reasoning as the .brighten rules above — these
   opacities are written straight from scroll position every frame, so a
   transition would make them chase the scrollbar instead of tracking it. */
.split-w{ display:inline-block; }
.split-c{ display:inline; transition:none; }
