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


/* ── HERO ─────────────────────────────────────────────────────────────────
   The shell (full viewport, video-backed, bottom-anchored copy — .hero and
   .hero-inner) and the two-column split itself (.hero-grid/.hero-headline/
   .hero-side) both now live in components.css — the split moved there once
   viper.html needed the identical headline-left/lead-and-CTA-right layout
   for its own hero. What stays here is specific to the homepage: its own
   alignment within that shared split (below), and its exact two-line
   headline treatment further down — per-line weight, and the accent on the
   word that lands the sentence.

   ALIGNMENT: the shared .hero-grid bottom-anchors both columns as a whole
   (align-items:end, components.css) — right for viper.html's single-line
   headline, but not for this page's now that the kicker sits above a
   two-line h1: bottom-anchoring the column put "Driif Mission OS" well
   above the lead paragraph beside it instead of level with it. Stretching
   the row so both columns share one height, then letting .hero-side alone
   space-between its two children, pins the lead paragraph to the row's
   top — level with the kicker atop the headline column — and the button to
   the row's bottom, which is exactly where the headline column's own last
   line ("Autonomous Missions.") already sits, since that line is what
   makes the headline column the taller of the two. .hero-headline itself
   needs no override: left alone it just flows from the top, which is all
   "top-aligned" means for the taller column. */
.hero-grid{ align-items:stretch; }
.hero-side{ justify-content:space-between; }

/* "Driif Mission OS" keeps VIPER/NEXUS's kicker size and spacing (that's
   .hero-kicker, components.css) but reads as a quiet label here rather than
   an accent-yellow product name — grey instead, homepage only. Scoped to
   .hero-headline so this never touches viper.html/nexus.html, which don't
   load this file. */
.hero-headline .hero-kicker{ color:var(--color-text-muted); }

/* Heading directly above the subtext, not beside it: the shared .split
   (base.css) runs heading|lead as two columns from 1024px up, which read
   fine elsewhere but was asked to stack here specifically — one voice
   running top to bottom rather than a diagram/statement pairing. Scoped to
   #mission so base.css's own two-column behaviour is untouched everywhere
   else that uses .split. Below 1024px .split already stacks on its own,
   so this only has anything to override at the wider breakpoint. */
@media (min-width:1024px){
  #mission .split{ grid-template-columns:1fr; }
}

/* Per-card icon replacing the old signal-marker hatch: sits above the
   title, sized to the card's own width rather than a small fixed icon
   slot — these are illustrative, detailed line-art pieces (drones, relay
   diagrams, sensor terrain) that read as noise at a small icon size, so
   full card width is what keeps them legible. Scoped to #mission's pillar
   cards only — card-notch itself is shared with VIPER/NEXUS.

   aspect-ratio:1 + object-fit:contain: the six icons will arrive from
   different exports at different native proportions, but each card should
   read as the same shape regardless — a fixed square slot with the
   artwork contained inside it (never cropped, never stretched) keeps the
   row visually even card to card instead of every card being exactly as
   tall as its own icon happens to be.

   height:auto is not decorative here — it's the fix for a real bug. Each
   <img> carries real width/height attributes (for the browser's normal
   pre-load layout reservation), and those attributes count as a specified
   height exactly like a CSS height would — with a second, explicit width
   also specified, both axes are already resolved before aspect-ratio gets
   a say, so it would otherwise be silently ignored. Setting height:auto
   here overrides the attribute (author CSS beats presentational attributes
   regardless of specificity), leaving height the one unresolved axis
   aspect-ratio actually fills in from width.

   Capped at 300px (was width:100%, i.e. up to ~376px at this card's own
   440px width minus its padding): the full-bleed icon was the single
   biggest driver of the card's overall height once cards were widened to
   440px, so this trims it back — centred with margin:auto now that it no
   longer always spans the card's full content width — while keeping it a
   clearly legible, still-prominent size rather than shrinking it down to
   the ~220px that read as too small on a previous pass. margin-bottom
   drops a step too (--space-5 → --space-4), same "trim proportionally,
   not just the icon" reasoning applied to the gap underneath it. */
#mission .pillars .card-notch .mission-icon{
  display:block; width:min(100%, 300px); height:auto; aspect-ratio:1; object-fit:contain;
  margin:0 auto var(--space-4);
}

/* Slack sits BELOW the copy, so the titles line up across the row.
   This rule used to be `margin-top:auto` on the title, pinning the text
   block to the card's bottom edge instead. The reason given was that "the
   six icons arrive at different native proportions/heights" — that is no
   longer true: .mission-icon above carries aspect-ratio:1, so all six
   render as the same 284px square and the icon can no longer push anything
   out of line.
   What DOES differ now is the copy — four of the six paragraphs run to four
   lines and two to three. Bottom-pinning turns that difference into a
   staggered title row: measured at 768px, the two short-copy cards had
   their title 25px lower than the other four. Since the slides stretch to
   equal heights (see the stretch rule above), that slack has to go
   somewhere, and the bottom of the card is the only place it does not
   disturb something that is meant to align.
   margin-bottom:auto on the paragraph is the whole mechanism — .inner is
   already a column flex box (components.css), so the auto margin absorbs
   whatever height the tallest card contributed and the icon/title/copy
   stack stays flush to the top. */
#mission .pillars .card-notch p{ margin-bottom:auto; }

/* ── MISSION LAYER: one scrolling filmstrip, not a wrapping grid ───────────
   Six cards read better here as one long horizontal row than a 2/3-column
   grid — smaller cards, a single line, paged along with a bare arrowhead
   icon (no box, no fill — just a grey chevron, same two-line construction
   as .nav-caret) instead of wrapping to more rows. Scoped to #mission only:
   .pillars/.card-notch stay the shared wrapping grid everywhere else
   (VIPER, NEXUS capability sets) — this page overrides them locally rather
   than changing the shared component both of those still rely on.

   Edge-to-edge, arrows below: same treatment as #applications and for the
   same direct request — .pillars-row stacks the strip over its controls
   (was a row with the controls beside it) rather than flanking it on
   either side. Swiper (carousels.js) drives the paging now instead of a
   hand-rolled scrollLeft watcher; .next dims once nothing is left to
   reveal and .prev stays invisible until the strip has moved, both via
   Swiper's own .swiper-button-disabled toggle rather than bespoke
   is-end/is-visible classes (see the scroll-btn rules further down). */
#mission .pillars-row{ display:flex; flex-direction:column;
  margin-top:var(--section-y-sm); }
/* width:100% here for the same reason as .app-tiles above: this sits in a
   flex-direction:column row (#mission .pillars-row) now, where Swiper's own
   auto cross-axis margins would otherwise override stretch and collapse it
   to shrink-to-fit. */
#mission .pillars{ width:100%; margin-top:0; }
/* FALLBACK: exact scroll-snap rules this strip used to carry
   unconditionally, scoped to only engage if Swiper's vendor file failed to
   load and carousels.js never flipped .swiper-initialized on. */
#mission .pillars:not(.swiper-initialized){
  display:flex; flex-wrap:nowrap; gap:var(--space-5);
  overflow-x:auto; scroll-snap-type:x proximity; scroll-behavior:smooth;
  scrollbar-width:none; -ms-overflow-style:none;
}
#mission .pillars:not(.swiper-initialized)::-webkit-scrollbar{ display:none; }
/* NO CSS WIDTH ON AN INITIALISED SLIDE. carousels.js gives this strip a
   NUMERIC slidesPerView (1 / 2 / 3), so Swiper derives each slide's width
   from the container and writes it as an inline style. A CSS width — and
   especially a flex-basis, which beats width outright when deciding a flex
   item's size — only competes with that. It is not a theoretical conflict:
   with a flex-basis here, Swiper set an inline width:234px and the card
   still rendered at 444px, because the basis won.
   The container inset lives on the strip as padding now, instead of
   Swiper's slidesOffsetBefore/After — see carousels.js for why those two
   cannot be used with a numeric slidesPerView. Swiper measures its own
   `size` from the content box, so the padding is room the slides are never
   given and the row tiles exactly inside it. */
#mission .pillars{
  padding-inline:var(--edge);
  /* Swiper's own overflow:hidden clips at the BORDER box, which includes the
     padding above — so the padding is room the slides are not given, but it
     is still room they are drawn in. That only shows wherever --edge is
     wider than the gap between slides: at 1280 (--edge 48, gap 24) the three
     cards end at x=1232 and the fourth begins at 1256, putting a 24px sliver
     of it inside the right inset. Below 1024 the two are both 24 and the
     next card starts exactly at the viewport edge, which is why this is
     invisible there.
     clip-path trims exactly the padding back off, so the strip is clipped
     where it is inset and a card is never drawn in the gutter — mid-drag
     either. */
  clip-path:inset(0 var(--edge));
}
#mission .pillars .card-notch{ min-height:240px; }
/* EVERY CARD AS TALL AS THE TALLEST. The copy is not the same length in all
   six cards — measured at 768px, four paragraphs wrap to four lines and two
   wrap to three, so those two cards came out 25px (one line) shorter than
   their neighbours and the row's bottom edge stepped up and down.
   The paragraph min-height further down reserves three lines, which sets a
   floor but cannot equalise a card whose copy runs past it.
   Stretching is the fix that keeps working when the copy changes again:
   the slides are flex children of .swiper-wrapper, so stretch makes each one
   take the line's full cross size, i.e. the height of the tallest card.
   height:auto is the other half of it — a slide carrying its own resolved
   height has a definite cross size, and a definite cross size is exactly
   what opts an item out of stretching.
   The slack is sent BELOW the copy (see the `p{ margin-bottom:auto }` rule
   further down), so the icons and titles stay aligned across the row and the
   bottoms line up too. */
#mission .pillars .swiper-wrapper{ align-items:stretch; }
#mission .pillars .card-notch{ height:auto; }
/* FALLBACK ONLY. 440px is the no-Swiper width — the one place a CSS width is
   still correct, because with the vendor file missing nothing else sizes the
   card. An initialised strip is sized by Swiper (above). */
#mission .pillars:not(.swiper-initialized) .card-notch{ flex:0 0 440px; scroll-snap-align:start; }

/* ── FALLBACK SIZING, BELOW 1024 ─────────────────────────────────────────
   These sized BOTH the Swiper slide and the no-Swiper fallback until Swiper
   was given a numeric slidesPerView; now Swiper writes the slide's width
   itself, so sizing it here would just fight that (a flex-basis here beat an
   inline width:234px and rendered a 444px card). Scoped to
   :not(.swiper-initialized) only.

   The fallback is a plain overflow-x strip with no JS at all, so a constant
   card width has the same flaw there that it had on the real strip: it has
   no relationship to the screen. One card below 640, two above it, measured
   from the viewport, so the fallback tiles inside the same --edge inset the
   padding on .pillars establishes.

   100vw, not 100%: a percentage would resolve against the flex row, which is
   as wide as all six cards combined. 100vw includes a classic scrollbar
   where one exists, so on a desktop browser dragged narrow the card is ~15px
   wider than the room for it; mobile scrollbars are overlays, so the case
   this is actually for is exact. */
@media (max-width:639px){
  #mission .pillars:not(.swiper-initialized) .card-notch{
    flex-basis:calc(100vw - var(--edge) * 2);
  }
}
@media (min-width:640px) and (max-width:1023px){
  /* Two cards plus the one --space-5 gutter between them. */
  #mission .pillars:not(.swiper-initialized) .card-notch{
    flex-basis:calc((100vw - var(--edge) * 2 - var(--space-5)) / 2);
  }
}
/* NO AUTHOR MARGIN BETWEEN SLIDES. Swiper owns the gap, via carousels.js's
   spaceBetween, and it must be the only thing that sets it.

   A `#mission .pillars .card-notch:not(:last-child){ margin-right:…}` rule
   used to sit here, added on the belief that "live, that inline margin isn't
   showing up". It is showing up — all six slides carry Swiper's own inline
   margin-right:24px — and the author rule was double-counting the gap,
   because Swiper measures each slide as offsetWidth PLUS its computed
   margin-right and then adds spaceBetween again when it builds slidesGrid.

   Measured at 492px with a 444px card: the grid stepped 492px per slide
   where a slide is only 468px (444 + 24), so every snap drifted one gap
   further left than the one before it — card 1 landed at x=24 as intended,
   card 2 at 0, card 3 at −24, down to −96 by card 6. The same 24px-per-slide
   error also left 5 × 24 = 120px of dead space at the end of the strip,
   where the last card stopped short of the right inset. One rule, both
   symptoms.

   The `:not(.swiper-initialized)` fallback further up carries its own
   `gap:var(--space-5)`, so the no-Swiper path keeps its spacing without
   this. */
/* Widened 300px → 440px, per direct request: at 300px the longest title
   ("Shared Operational Picture") wrapped to two lines, which read as
   broken rather than intentional. Checked by rendering the actual site
   font (Space Grotesk 500, the .t-h3/h1-h4 pairing — base.css/tokens.css)
   at its own worst-case size — --text-h3's clamp caps at 26px, which it
   reaches above ~1909px viewport width, so that's the size that needs the
   most room, not the smaller sizes narrower viewports get — measured
   against 440px minus the card's own padding (.inner, components.css).
   Every title clears with margin to spare; "Shared Operational Picture"
   and "Heterogeneous Integration" (the two longest) are the closest, with
   about 44px of breathing room each at that worst-case size, so this
   should hold even allowing for small differences between that measurement
   and an actual browser's own text shaping — worth a quick live check once
   the dev server's back up, since this wasn't confirmed in a rendered page.
   The min-height below is now exactly one line — was two, reserved back
   when titles still wrapped — since every title fits on one line now, that
   reservation would just leave a dead gap under every card. Kept as a
   min-height rather than removed outright for the same alignment reason it
   was added for originally: the icon above is a fixed square (.mission-icon
   above), but nothing else pins the title's own baseline across the row
   without it. */
#mission .pillars .card-notch h3{
  min-height:calc(var(--text-h3) * var(--lh-snug) * 1);
}
/* Same reasoning as the title above, applied to the copy below it —
   card-notch's .inner needs a definite height for its own height:100%
   to resolve against (see the title comment above for why), so this still
   reserves the longest paragraph's worst-case line count rather than
   leaving it to each card's own content height. Re-measured at the new
   440px width the same way as the title (rendering the real body font —
   IBM Plex Sans, .t-body — at --text-body's own worst-case size, 18px):
   every paragraph in this set now wraps to at most three lines, down from
   five at the old 300px, so three is what's reserved rather than five —
   the extra two lines' worth of dead space under every shorter paragraph
   is exactly what "nicer" spacing was asking to fix here. margin-top sits
   at --space-5 (the shared value every other .card-notch consumer already
   uses, components.css) rather than the --space-6 this rule briefly
   carried — that extra step was trimmed back along with the icon cap
   above, as part of the same "shorten the box, not just the icon" pass. */
/* margin-top pulled in from --space-5 (24px, the shared card-notch
   default, components.css) to --space-3 (12px) — direct follow-up:
   "the gap between heading and sub heading inside the individual boxes"
   (each pillar card's own h3-to-p spacing), not the section-level
   heading/lead gap above it. Scoped to #mission only, same as every other
   card-notch override on this page — components.css's shared --space-5
   still governs every other .card-notch consumer (viper.html's Edge
   Autonomy grid, etc.). */
/* opacity:.8 on top of the full-strength text color every other .t-body
   uses — direct follow-up ("subtext inside boxes, reduce opacity little
   bit"): a plain transparency dial rather than swapping in .muted's own
   --color-text-muted token, since opacity is literally what was asked
   for. Scoped to #mission's own cards only. */
#mission .pillars .card-notch p{
  margin-top:var(--space-3);
  min-height:calc(var(--text-body) * var(--lh-body) * 3);
  opacity:.8;
}
/* Right-aligned rather than centred — same treatment, and the same reason,
   as #applications' own controls below the tile strip. */
/* The row itself is .strip-controls in components.css now (one component,
   was three identical copies). Only genuinely local rules stay here. */
/* bare icon button: no background, no border, no chamfer — just a chevron
   drawn from two rotated border edges (the same construction as .nav-caret)
   in the muted grey text tone, so it reads as "an arrowhead" and nothing
   more. Sized as a generous square hit target even though the visible mark
   is small. */
/* 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: #mission .pillars-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. */
@media (max-width:639px){
  /* The 260px card this used to pin here is gone — the card is sized from
     the viewport now, further up this file, so that the next card stops at
     the container inset instead of being sliced by the screen edge. The
     paragraph reservation below still belongs here: it is about line count
     at this width, not about card width.

     Six lines, not the seven this reserved against a 260px card. A card that
     now fills the width wraps the longest paragraph shorter, and reserving
     for seven left a band of empty card under the text. */
  #mission .pillars .card-notch p{
    min-height:calc(var(--text-body) * var(--lh-body) * 6);
  }
}
/* Two display lines now, not three ("Autonomous Missions." reads as one
   phrase on its own line) — but still three weights: the first line stays
   light and defers to the second, and within that second line the two
   words split further, "Missions." landing the sentence in the heaviest
   weight and the hero's only accent moment. .hero-l2/.hero-l3 style just
   the word now rather than the whole line, nested inside one shared
   .line-i so both words still rise together as a single animated line. */
.hero-l1{ font-weight:300; }   /* light */
.hero-l2{ font-weight:500; }   /* medium */
.hero-l3{ font-weight:700; color:var(--color-accent); }

/* ── PROBLEM ───────────────────────────────────────────────────────────────
   Single fold, not two rows a scroll apart: this used to be an editorial
   four-quadrant layout (escalation top-left, statement top-right, photograph
   below it, conclusion bottom-left) with a large row-gap between the two
   rows, which read as two separate sections while scrolling rather than one
   argument. Rebuilt per direct request as one row — diagram on the left,
   the whole copy stack (including the close statement, which used to be its
   own grid cell) on the right — with the photograph dropped entirely so
   nothing competes with the diagram for attention on a single screen.

   Top-aligned with the heading, not the eyebrow above it, and stretched to
   match the lead paragraph's bottom — not the close statement's, which used
   to also live in .prob-copy and pulled the diagram's stretch target down
   past it. Three earlier attempts at the top-alignment/height part didn't
   hold up: a fixed margin-top guessed at the eyebrow's rendered height still
   landed the diagram above the heading's actual glyph top (line-height
   leading isn't something a hand-rolled px estimate tracks); spanning the
   diagram across .prob-copy's own rows let the diagram's height inflate
   those row tracks, which stretched the eyebrow/heading/lead/close spacing
   as an unwanted side effect; and stretching the diagram against the whole
   of .prob-copy (heading + lead + close together) ran the boxes on past the
   lead paragraph, down to the close statement's bottom, per the same
   mechanism the previous fix relied on. Fixed properly now: eyebrow AND the
   close statement each get pulled into their own row, leaving .prob-copy
   holding only the heading and lead paragraph — its internal spacing is
   still untouched (one flex column, same gap, same per-child margins it
   always had). The diagram and .prob-copy share one row with
   align-items:stretch, so the diagram's top matches the heading's top and
   its height stretches to match heading+lead only; the close statement sits
   in its own row below, sized and spaced independently, unaffected by
   whatever height the diagram ends up at.
   ───────────────────────────────────────────────────────────────────────── */
.prob-grid{ display:grid; grid-template-columns:minmax(0,1fr); gap:var(--space-8); }
/* gap:var(--space-6), not --space-5 — site-wide standardisation pass:
   heading-to-lead used to be var(--space-5) PLUS an extra margin-top on
   the lead paragraph itself (36px total, and neither of the site's own
   two standard values), a leftover from before this became a flex
   column. Matches the shared heading-to-lead gap every plain .split
   section uses (base.css), same as every other section-intro on site;
   .prob-eyebrow-to-heading still gets its own separate --space-5 from
   .eyebrow's own margin-bottom (base.css), untouched. */
.prob-copy{ display:flex; flex-direction:column; gap:var(--space-6); }
.prob-copy .t-lead{ max-width:var(--measure-lead); }
/* The close statement used to sit in its own grid cell, paired with the
   photograph; it's the section's punchline, so it still keeps a bigger gap
   above it than the eyebrow/heading/lead get between themselves — echoed by
   its own later reveal delay in motion.css — but a smaller one than before,
   per direct request to bring it up closer under the lead paragraph. */
.prob-close{ margin-top:var(--space-6); max-width:36ch; }

@media (min-width:1024px){
  .prob-grid{
    grid-template-columns:minmax(0,7fr) minmax(0,5fr);
    grid-template-areas:
      ".       eyebrow"
      "diagram copy"
      ".       close";
    column-gap:var(--gutter);
    row-gap:var(--space-5);
    align-items:stretch; }
  .prob-eyebrow{ grid-area:eyebrow; }
  .prob-diagram{ grid-area:diagram;
    /* auto rows (below, unconditional) only size to the boxes' own content;
       1fr rows here instead divide whatever height .prob-diagram gets
       stretched to evenly between the two rows of boxes, so the boxes
       themselves grow to fill it — each stays top-packed inside (its own
       flex-column layout doesn't change), so the extra room shows up as
       breathing space below "Operator", not as an oversized gap. */
    grid-template-rows:repeat(2, 1fr); }
  .prob-copy{ grid-area:copy; }
  .prob-close{ grid-area:close; }
}

/* ── the four stacks ──────────────────────────────────────────────────────
   FOUR STACKS, NOT ONE FUNNEL — every feed brings its own operator, and
   nothing runs between them. That argument used to be carried by open space
   and a hairline dotted connector alone, which read as unclear/too subtle;
   it's now carried by an actual border around each stack, so the boxes
   themselves — not just the animation — say "these are four separate,
   sealed pipes." 2x2 rather than 4-across so each box stays wide enough to
   read comfortably now that the diagram only has ~7/12 of the fold's width
   instead of running the full bleed. */
/* The control-room photograph is back, per direct request — it was cut
   entirely when this section moved to the diagram-only layout (see the
   "PROBLEM" comment above), and now sits BEHIND the four stacks as a
   background-image rather than as its own element: no extra markup, and
   it scales with whatever height .prob-diagram is stretched to (cover +
   center) instead of needing its own aspect-ratio to manage.
   position:relative + overflow:hidden turn it into the containing box
   for the scrim below; padding gives the boxes breathing room off the
   photo's raw edge instead of sitting flush against it; the hairline
   border matches how every other photo/video box on the site (.scene,
   .pic-media) frames its content. */
.prob-diagram{ display:grid; grid-template-columns:repeat(2, minmax(0,1fr));
  gap:var(--space-5); position:relative; overflow:hidden; padding:var(--space-5);
  border:var(--hair) solid var(--color-border-subtle);
  background:url("../img/problem.webp") center/cover no-repeat; }
/* Dark scrim between the photo and the stacks — one overlay, not the
   photo and the diagram competing for attention as separate things.
   A plain ::before rather than dimming the photo's own filter/opacity:
   that would flatten the photo AND whatever sits on top of it together,
   where the point is the photo staying full-contrast underneath while
   only the layer above it darkens. z-index:0 keeps it under the stacks —
   a positioned element paints above static content regardless of DOM
   order, so .pd-stack below needs its own z-index to end up on top
   rather than getting covered by this. */
.prob-diagram::before{ content:""; position:absolute; inset:0; z-index:0;
  background:rgba(0,0,0,.68); }
.pd-stack{ position:relative; z-index:1;
  border:var(--hair) solid var(--color-border);
  padding:var(--space-5) var(--space-4);
  display:flex; flex-direction:column; align-items:flex-start; gap:var(--space-4); }
.pd-feed{ color:var(--white); }
/* solid, not dashed — a solid line reads as "a real link" at a glance,
   where the old dotted trace needed a close look to register as a
   connector at all rather than decoration. Thin and white rather than
   accent-colored: the accent is reserved for the operator dot and label
   below it, so the line reads as plumbing, not as another thing to notice. */
.pd-link{ display:block; width:1px; height:36px; background:var(--white); }
.pd-op{ display:flex; align-items:center; gap:var(--space-3); }
/* Accent marks the operators and only the operators — a repeated motif
   reading as one thing, which is where the cost actually lands. */
.pd-op::before{ content:""; flex:none; width:5px; height:5px; background:var(--color-accent); }
.pd-op .t-meta{ color:var(--color-accent); }

@media (max-width:1023px){
  /* single column: lead with the statement, not with the diagram — and the
     diagram's top box was landing under the sticky nav when it came first
     in the stack. .prob-close is its own element now (used to be
     .prob-copy's last child), so it needs the same order:1 explicitly —
     left on its default order:0, it would jump ahead of .prob-copy's
     order:1 and land between the eyebrow and the heading instead of after
     the lead paragraph where it belongs. */
  .prob-copy{    order:1; }
  .prob-close{   order:1; }
  .prob-diagram{ order:2; }
}

/* .pillars (the 3-up capability-card grid below) moved to components.css —
   viper.html reuses it for its own six capability cards, which is the reuse
   test that decides where a rule lives. */

/* ── ENABLES ─────────────────────────────────────────────────────────────── */
.steps{ list-style:none; margin:var(--section-y-sm) 0 0; padding:0;
  display:grid; grid-template-columns:repeat(2,1fr); gap:var(--gutter); }
.steps li{ display:flex; flex-direction:column; gap:var(--space-3);
  padding-top:var(--space-4); border-top:var(--hair) solid var(--color-border-subtle); }
@media (min-width:1024px){ .steps{ grid-template-columns:repeat(5,1fr); } }

/* Quieter than the filled block it replaces: a hairline row, an index number,
   and a 2px accent rule that slides to the active tab. Nothing inverts, so the
   row stops competing with the panel content below it.

   Sticky so the bar stays reachable on a tall panel (scene + copy can run
   past a viewport). top reuses the same effective-nav-height expression the
   html element already uses for scroll-padding-top, rather than a maintained
   pixel value. z-index only has to clear panel content painted after it in
   source order; nav is 100, this stays well under that. Background is
   --color-bg, not a literal, so it reads as the section's own ground (dark or
   light) while panel content scrolls under it.

   Known trade-off: the nav also hides on scroll-down via transform, and this
   offset does not shrink to match, so scrolling back up shows a brief gap
   between the returning nav and the bar before it closes. Left as-is — a
   seam, not a bug — flag if it should be tightened. */
/* .tabs/.tab base + .tab-panels/.tab-panel base moved to components.css
   earlier — viper.html reuses that mechanism. The rest of what made this
   section's tabs its own thing — the sticky bar, the scene+copy grid, the
   switch animation, and the whole pinned-scroll layout — has now moved
   there too, as "OPERATE STAGE", once NEXUS's Operational Loop needed the
   exact same tabs-with-a-video-scene pattern (and its scroll module,
   pin-tabs.js) for itself.

   One thing IS still homepage-only: the Plan/Task/Fuse/Sense/Execute/Adapt
   footage is a dark tactical map with small HUD labels (mission id,
   asset callouts, track state) rendered close to true black, and per
   direct request the on-screen text was reading as too dim to follow
   comfortably. Scoped to #operates rather than lifted onto the shared
   .scene-v rule in components.css, since NEXUS's Operational Loop reuses
   that same element for its own footage, which wasn't reported as a
   problem and has different contrast to begin with — a global change
   would have re-graded video nobody asked to change. brightness/contrast
   over saturate: these clips are already near-monochrome (map line art,
   a little accent yellow/red), so lifting luminance and separation reads
   as "clearer," where pushing saturation would just make the accent
   colours louder without helping the grey label text next to them. */
#operates .scene-v{ filter:brightness(1.3) contrast(1.1); }

/* ── APPLICATIONS ─────────────────────────────────────────────────────────
   Edge-to-edge again, arrows moved below rather than beside: per direct
   request, the strip runs flush to the section edges (no --edge gutter
   reserved for side controls any more) and the prev/next pair sits in its
   own centred row underneath instead. Swiper (carousels.js) now drives the
   paging — same visual strip, same custom chevron buttons, but the drag/
   swipe, the slide sizing per breakpoint and the disabled-state bookkeeping
   are Swiper's, not a hand-rolled scrollLeft watcher. One tile per view
   below 1024px, three per view at and above it — set in carousels.js
   (breakpoints: slidesPerView), not here: Swiper sizes its own slides via
   JS once it initialises, so a CSS flex-basis alongside that would just be
   dead weight it immediately overrides. */
/* .app-tiles-wrap stacks the strip over its controls now (was a row with
   the controls beside it) — this is the only layout change the arrows-
   below request needed, since the controls were already a sibling of the
   strip in the markup; flipping the wrap to a column is all it takes. */
.app-tiles-wrap{ display:flex; flex-direction:column; }
/* width:100% is load-bearing, not decoration: Swiper's own base CSS sets
   margin-left/right:auto on every .swiper root (meant for centering a
   block-level element with a fixed width in normal flow) — inside our
   flex-direction:column wrap, an auto cross-axis margin on a flex item
   overrides align-items:stretch entirely (CSS Flexbox spec 8.1: auto
   margins win over alignment), so without an explicit width this strip
   collapses to shrink-to-fit content instead of filling the wrap, and
   Swiper's own width measurement (taken from this same box) comes back
   wrong. */
.app-tiles{ width:100%; margin-top:var(--section-y-sm);
  border-block:var(--hair) solid var(--color-border-subtle); }
/* FALLBACK: if Swiper's vendor file didn't load, carousels.js never flips
   .swiper-initialized on and this strip needs to still be a strip — the
   exact scroll-snap rules this used to carry unconditionally, now scoped so
   they only engage in that failure case instead of fighting Swiper's own
   layout the rest of the time. */
.app-tiles:not(.swiper-initialized){ display:flex; flex-wrap:nowrap;
  overflow-x:auto; scroll-snap-type:x mandatory; scroll-behavior:smooth;
  scrollbar-width:none; -ms-overflow-style:none; }
.app-tiles:not(.swiper-initialized)::-webkit-scrollbar{ display:none; }
.tile{ min-width:0;
  border-right:var(--hair) solid var(--color-border-subtle); }
/* the strip's border-block (.app-tiles) frames top and bottom only, so every
   tile's own border-right is what closes its right edge — including the
   last tile (Maritime Security): with no border-inline on .app-tiles itself,
   dropping it there left that whole edge open instead of closed like the
   rest of the frame. Kept on every tile, last one included. Nothing closed
   the left edge of the first tile (Border Security) either, so it gets the
   same hairline on that side. */
.tile:first-child{ border-left:var(--hair) solid var(--color-border-subtle); }
.app-tiles:not(.swiper-initialized) .tile{ flex:0 0 100%; scroll-snap-align:start; }
@media (min-width:1024px){
  .app-tiles:not(.swiper-initialized) .tile{ flex-basis:33.3333%; }
}
/* Controls sit in their own row below the strip, pinned to the right edge
   rather than centred — reads as "more this way" rather than a centred
   pair that implies the strip itself is centred content. */
/* The row itself is .strip-controls in components.css now (one component,
   was three identical copies). Only genuinely local rules stay here. */
/* Same bare grey chevron as #mission's own scroll buttons — plain ground
   beside the strip now rather than tile artwork underneath, so the old
   drop-shadow safety net (for a button sitting on top of a photo) is gone
   too; nothing behind these buttons needs it any more. */
/* 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: #applications .app-tiles-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. */
/* The ground flips on hover, not just the image. Both renders exist because
   they are separately lit — dark lines for the white ground, light lines for
   the black — so the crossfade and the background change have to happen
   together or one of them reads wrong. Re-declaring the tokens the tile uses
   makes the ink follow the ground automatically.

   Sized by aspect-ratio, not viewport height: these renders are landscape
   (~3:2), and the old min-height:88svh forced them into a box nearly three
   times taller than wide. object-fit:contain doesn't stretch to fill that
   mismatch, it letterboxes — so the actual picture rendered small and
   centred in a mostly-empty column, and the opaque caption band (a fixed
   height regardless of the tile's own height) then ate a much bigger bite
   out of that small picture than intended, reading as the image being cut
   off. A box close to the render's own ratio leaves it filling the frame
   with only a sliver of letterboxing, so the caption crops far less of it
   and the whole tile stops dwarfing the photo it's showing. */
.tile{ position:relative; overflow:hidden; background:var(--color-bg);
  aspect-ratio:4/5; display:flex; flex-direction:column; justify-content:flex-end;
  padding:var(--space-5);
  /* bind ink to the token, or the hover ground flip has nothing to flip:
     the tile no longer carries .surface-*, so without this the text keeps the
     section's colour and goes black-on-black the moment the ground inverts */
  color:var(--color-text);
  transition:background var(--dur-ui) var(--ease-out), color var(--dur-ui) var(--ease-out); }
.tile h3, .tile p, .tile .tile-idx{ transition:color var(--dur-ui) var(--ease-out); }
/* stacking for .tile children is declared once, in MEDIA PLACEMENT below —
   a blanket `.tile > *` here silently stripped position:absolute off .media */
.tile-idx{ top:var(--space-5); left:var(--space-5); }
/* The caption sits on its own band of ground colour rather than floating on the
   render. Masking the image bottom was the alternative, but a real part of
   whatever's in frame always lives down there and would have been clipped.
   The band also guarantees legibility on BOTH grounds — white text on the
   light-lit render was invisible. Spans the full tile width via negative
   margins against the tile padding; heading down a size (t-h3, in the
   markup) and the padding trimmed to match the smaller tile keep the band
   itself from being the thing making the tile feel oversized. */
.tile-cap{
  position:relative; z-index:1;
  margin:0 calc(var(--space-5) * -1) calc(var(--space-5) * -1);
  padding:var(--space-4) var(--space-5) var(--space-5);
  background:var(--color-bg);
  border-top:var(--hair) solid var(--color-border-subtle);
  transition:background var(--dur-ui) var(--ease-out),
             border-color var(--dur-ui) var(--ease-out);
}
.tile h3{ margin-bottom:var(--space-2); }
/* Same fixed-lines trick as .card-product .t-lead below: because .tile-cap
   grows upward from the tile's bottom edge (flex column, justify-content:
   flex-end), a heading that wraps to one line (Autonomous Logistics) makes
   a shorter caption band than one that wraps to two (Border Security &
   Surveillance, Critical Infrastructure Protection, ISR, Maritime Security)
   — so the border-top divider between image and caption sat at a different
   height per tile instead of reading as one aligned line across the row.
   Reserving two lines' worth of height on the heading itself means every
   tile's caption band is the same height regardless of how its own title
   wraps, so the divider lines up across the whole row. */
.tile-cap h3{ min-height:calc(var(--text-h3) * var(--lh-snug) * 2); }
/* .t-label's default line-height (--lh-flat, 1) is tuned for chrome — nav,
   pills, eyebrows — where the box needs to be exactly cap-height. This one
   caption underneath a heading reads tight at that setting, so it gets a
   scoped bump; every other .t-label on the site keeps the flat metric. */
.tile-cap .t-label{ line-height:1.4; }

/* ── PRODUCTS ────────────────────────────────────────────────────────────── */
/* One card = a bordered black container split into an image zone and a text
   zone. The border wraps both, so it reads as a single object rather than an
   image with a caption beneath it. */
.product-grid{ display:grid; grid-template-columns:1fr; gap:var(--gutter);
  margin-top:var(--section-y-sm); }
.card-media{ position:relative; aspect-ratio:16/10; overflow:hidden;
  border-bottom:var(--hair) solid var(--color-border); }
/* Viper and Nexus are rendered in colour but the page is a two-pole system —
   a CSS filter desaturates them without re-exporting the assets, so the choice
   stays reversible and the source renders remain the source renders. A touch of
   contrast recovers the separation that pulling the colour out costs. */
.card-media > img{ width:100%; height:100%; object-fit:cover;
  filter:grayscale(1) contrast(1.06); }
.card-body{ padding:var(--space-7) var(--space-6); display:flex; flex-direction:column; flex:1; }
/* top-aligned pill spacing now lives on .card-product .pill-row in
   components.css, since it no longer depends on flex's margin-top:auto */
@media (min-width:1024px){
  .product-grid{ grid-template-columns:repeat(2,1fr); }
  /* Side by side, VIPER's copy runs two lines and NEXUS's runs three, so the
     bordered tag row below lands at a different height per card. Reserve
     three lines here so both cards' tags start at the same top edge. */
  .card-product .t-lead{ min-height:calc(var(--text-lead) * var(--lh-body) * 3); }
}


/* .cta-band / .cta-inner (and their media-placement rules below) moved to
   components.css — viper.html reuses the same full-bleed closing band. */



/* ═══ MEDIA PLACEMENT ══════════════════════════════════════════════════════
   Every media slot is a flat image or video under a flat scrim, with content
   layered above it. Content sits at z-index 1, media at 0 — set explicitly
   rather than relying on source order, because the badge and index labels are
   absolutely positioned and would otherwise fall behind the image.
   ═════════════════════════════════════════════════════════════════════════ */

/* problem visual: full-bleed frame with the node labels over it */
.problem-visual > img{ position:absolute; inset:0; width:100%; height:100%;
  object-fit:cover; }

.problem-node{ z-index:2; }

/* product cards: heavy scrim, because lead copy sits directly on the image */
/* :not(.media) is TWO classes, so it outranks .badge-hex / .tile-idx and would
   silently strip their position:absolute. Exclude them by name. */
.card-product > .media{ z-index:0; }
.card-product > *:not(.media):not(.badge-hex){ position:relative; z-index:1; }
.card-product > .badge-hex{ position:absolute; z-index:2; }

/* application tiles: static frame crossfades to the hover frame.
   Both images are stacked; only opacity animates, so nothing relayouts. */
.tile > .media{ z-index:0; }
.tile > *:not(.media):not(.tile-idx){ position:relative; z-index:1; }
.tile > .tile-idx{ position:absolute; z-index:2; }
/* contain, not cover: the whole render has to be visible end to end. cover was
   cropping a landscape render into a portrait tile and showing a zoomed detail.
   Padding then comes from scaling the render down inside its box, and hover
   scales it back to full — transform only, so nothing relayouts. */
.tile .media img{ position:absolute; inset:0; width:100%; height:100%;
  object-fit:contain; object-position:center;
  transform:scale(.86); transform-origin:center;
  transition:transform var(--dur-ui) var(--ease-out); }
/* the ISR render is a flat side-profile elevation, not an isometric render
   like the other four tiles — tilted to echo their diagonal, ascending-
   left-to-right composition instead of sitting dead level. Applies in both
   static and hover frames since both are the same flat elevation. */
.tile--isr .media img{ transform:scale(.86) rotate(-20deg); }
.tile--isr:hover .media img{ transform:scale(1) rotate(-20deg); }
.tile .tile-cap{ z-index:2; }
.tile .tile-static, .tile .tile-hover{ transition:opacity var(--dur-ui) var(--ease-out); }
.tile .tile-hover{ opacity:0; }

/* ── HERO POSTER ─────────────────────────────────────────────────────────── */
/* The black floor and sizing live in motion.css; only the image is per page.
   Without this the homepage hero has no fallback behind the video. */
.hero .media{ background-image:url("../img/hero-poster.jpg"); }
