/* ═══════════════════════════════════════════════════════════════════════════
   DRIIF — COMPONENTS
   Every rule here is reusable on any page; that is the entry test for this
   file. Nav and footer live here rather than in home.css because they are site
   chrome — in the homepage file a second page could not have reached them.
   ═══════════════════════════════════════════════════════════════════════════ */


/* ── BUTTON ────────────────────────────────────────────────────────────────
   The outline is TWO stacked clipped layers, not a border. clip-path cuts
   the element's box INCLUDING its border, so a bordered + clipped button
   loses its diagonal entirely and its top edge overshoots the cut. Here the
   element's own background IS the 1px line, and ::before is the fill inset
   by --hair at z-index:-1 — above the line, below the label.
   ───────────────────────────────────────────────────────────────────────── */
.btn{
  font-family:var(--font-display); font-weight:500; font-size:var(--ui-xs);
  line-height:var(--lh-flat); letter-spacing:var(--track-label); text-transform:uppercase;
  display:inline-flex; align-items:center; gap:var(--space-2);
  padding:var(--space-4) var(--space-6); border:0;
  position:relative; isolation:isolate;
  background:var(--color-text); color:var(--color-text);
  clip-path:polygon(0 0, calc(100% - var(--chamfer)) 0, 100% var(--chamfer), 100% 100%, 0 100%);
  transition:color var(--dur) var(--ease);
}
.btn::before{
  content:""; position:absolute; inset:var(--hair); z-index:-1;
  background:var(--color-bg);
  clip-path:polygon(0 0, calc(100% - var(--chamfer)) 0, 100% var(--chamfer), 100% 100%, 0 100%);
  transition:background var(--dur) var(--ease);
}
.btn::after{ content:"→"; transition:transform var(--dur-fast) var(--ease); }
/* bracketed labels carry their own delimiters, so the arrow is redundant */
.btn.plain::after{ content:none; }
.btn:hover::after{ transform:translateX(3px); }
.btn:focus-visible{ outline:2px solid var(--color-accent); outline-offset:3px; }
.btn.ghost:hover{ color:var(--color-bg); }
.btn.ghost:hover::before{ background:var(--color-text); }
.btn.primary{ background:var(--color-accent); color:var(--color-accent-ink); }
.btn.primary::before{ background:var(--color-accent); }
.btn.primary:hover{ color:var(--color-accent); }
.btn.primary:hover::before{ background:var(--color-bg); }

/* ── STRIP CONTROL: the prev/next pair under every horizontal strip ────────
   One component, four former copies. This exact rule set was pasted
   byte-identically into home.css twice (#mission .pillars-scroll-btn,
   #applications .app-tiles-scroll-btn), applications.css
   (#missions .app-tiles-scroll-btn) and viper.css
   (#orchestration .pic-tabs-scroll-btn) — four ID-scoped blocks that had to
   be edited in lockstep and, being ID-scoped, could not be reused by a
   fifth strip without a fifth copy. It lives here because it is reusable on
   any page, which is the entry test for this file.

   FILLED, not outlined. It used to be a bare grey chevron — no box, no
   fill — which read as decoration rather than a control: per direct
   feedback it was hard to find, so nobody discovered there was more of the
   strip to see. It is now a solid ink block with the chevron knocked out of
   it, which is the strongest "this is a button" signal available here
   without inventing a colour.

   The fill is --color-text, NOT --color-accent, and that is deliberate: the
   accent budget allows one accent moment per viewport (guidelines rule 6),
   and in the Mission Layer strip that moment is already spent on the six
   cards' hatch meters. Two accent-filled buttons beside them would be three
   accent moments in one viewport. Accent appears on hover only, where it is
   transient punctuation and reads as reward rather than a second text
   colour — and as a fill, never as text, which rule 11 requires on a light
   ground.

   Deriving both colours from --color-text/--color-bg means the block flips
   itself with the surface: an ink square on .surface-light, a white square
   on .surface-dark, correct contrast in both without a single override.

   The chevron's optical centre sits off the box's geometric centre — an
   L of two borders has its mass toward one corner, and rotating it turns
   that into a visible horizontal offset once there is a filled box around
   it to be off-centre within. The translate on each direction corrects it;
   it was invisible while the button had no box, which is why it was never
   needed before.

   -1.75px is measured, not guessed: the L's ink centroid sits 2.47px off
   centre once rotated, and because the translate runs INSIDE the rotated
   frame it moves the ink 1.41px (cos45 + sin45) per 1px written, so
   2.47 / 1.41 = 1.75. Both directions take the same value and land at
   exactly 0 — the geometry is symmetric. Changing the 9px box or the 2px
   borders above invalidates it. */
/* The row the pair sits in. Was three identical ID-scoped copies
   (#mission .pillars-controls, #applications .app-tiles-controls,
   #missions .app-tiles-controls) for the same reason .strip-btn was four.

   Right-aligned rather than centred: it reads as "more this way" instead of
   implying the strip is centred content.

   ALWAYS INSET TO THE CONTAINER, on every strip, per direct request:
   "all swiper / carousel controls must be inside the primary container, not
   at the edge of the screen."

   This overrules a distinction that was briefly drawn here and was wrong.
   The reasoning had been that a control should align to whatever its own
   slides align to, so a full-bleed strip (the Applications tiles, which run
   artwork to the viewport edge) would get viewport-edge buttons while a
   contained strip (the Mission Layer cards) got container-edge ones. But a
   control is chrome, not content: it belongs on the page's own text grid
   with the eyebrow and the headline it sits under, regardless of how far
   the artwork beside it bleeds. A button pinned to the viewport edge reads
   as floating off the layout, and it read that way on exactly the strip the
   old rule said was correct.

   So it is padding on the base rule and there is no modifier to forget. A
   strip whose SLIDES should also stop at the container edge does that
   separately, through slidesOffsetBefore/After in carousels.js — the two
   are independent, and only the Mission Layer strip wants both. */
.strip-controls{
  flex:none; display:flex; align-items:center; justify-content:flex-end;
  gap:var(--space-2); margin-top:var(--space-4);
  padding-inline:var(--edge);
}

.strip-btn{
  flex:none; width:40px; height:40px; padding:0;
  display:flex; align-items:center; justify-content:center;
  background:var(--color-text); color:var(--color-bg);
  border:0; border-radius:var(--radius); cursor:pointer;
  transition:background var(--dur-fast) var(--ease-out),
             color var(--dur-fast) var(--ease-out),
             opacity var(--dur-fast) var(--ease-out);
}
.strip-btn::before{
  content:""; width:9px; height:9px;
  border-right:2px solid currentColor; border-bottom:2px solid currentColor;
}
.strip-btn:hover{ background:var(--color-accent); color:var(--color-accent-ink); }
.strip-btn:focus-visible{ outline:2px solid var(--color-accent); outline-offset:4px; }
.strip-btn.next::before{ transform:rotate(-45deg) translate(-1.75px,-1.75px); }
.strip-btn.prev::before{ transform:rotate(135deg) translate(-1.75px,-1.75px); }
/* Swiper toggles .swiper-button-disabled on whichever elements are passed as
   navigation.nextEl/prevEl (carousels.js) — next dims and stops responding,
   prev disappears entirely since there is nothing behind the first slide. */
.strip-btn.prev.swiper-button-disabled{ opacity:0; pointer-events:none; }
.strip-btn.next.swiper-button-disabled{ opacity:.35; pointer-events:none; }

/* ── CTA ROW ───────────────────────────────────────────────────────────────
   A primary action plus a plain/ghost secondary, side by side. Split out of
   home.css's hero when viper.html needed the same two-button pattern for its
   own hero — generic enough for any page's hero or CTA band. */
.cta-row{ display:flex; align-items:center; gap:var(--space-5); flex-wrap:wrap; }


/* ── HERO SHELL ────────────────────────────────────────────────────────────
   Full-viewport, video-backed, bottom-anchored copy. Moved here from
   home.css once viper.html and nexus.html both needed the same shell for
   their own heroes — no right-side diagram or figure on either page
   anymore, a background video sits behind the copy instead once footage
   exists (the .media slot is wired and commented until then, same
   convention as every other unshot slot on this site).

   home.css still layers its own two-column .hero-grid on TOP of this shell
   (headline left, lead+CTA right — a homepage-specific split tied to its
   exact three-line headline treatment). Pages with one stacked block of
   copy instead use .hero-copy, right below. */
.hero{ position:relative; min-height:100svh; display:flex; overflow:hidden; }
.hero-inner{ display:flex; flex-direction:column; justify-content:flex-end;
  padding-block:var(--section-y-sm) var(--space-8); position:relative; z-index:2; width:100%; }

/* ── HERO COPY (single column) ────────────────────────────────────────────
   For a hero with one stacked block of copy — eyebrow, display heading,
   lead, CTA row — rather than the two-column headline/lead-CTA split below.
   Currently used by nexus.html; viper.html moved to the two-column split
   once its hero was asked to match the homepage's layout. Still lives here
   rather than in nexus.css since any future page could want either. */
.hero-copy{ display:flex; flex-direction:column; align-items:flex-start; gap:var(--space-6);
  max-width:var(--measure-lead); }

/* ── HERO PRODUCT KICKER ───────────────────────────────────────────────────
   A dedicated product page (NEXUS, VIPER) names its product above the
   tagline — .eyebrow's small, muted, section-kicker treatment reads as a
   category label ("The Decision Layer"), not a product name, and got lost
   above a hero-scale headline. This is the same slot but sized and weighted
   to hold its own: bold, accent-coloured, roughly sub-heading scale rather
   than UI-label scale. margin:0 rather than .eyebrow's own bottom margin —
   .hero-copy already spaces its children with flex gap, so a second margin
   here would just stack on top of it. Currently opted into by nexus.html
   only; drop the same class into viper.html's hero if it wants the same
   treatment. */
.hero-kicker{
  font-family:var(--font-display); font-weight:700; font-size:var(--text-h2);
  line-height:var(--lh-flat); letter-spacing:0.04em; text-transform:uppercase;
  color:var(--color-accent); margin:0;
}

/* ── HERO COPY (two column: headline left, subtext+CTA right) ──────────────
   The alternative to .hero-copy above: the header (kicker + display heading)
   holds the larger left column, the lead paragraph and CTA(s) sit in a
   smaller column on the right, both bottom-anchored so they share a
   baseline. Originally homepage-only (home.css tied it to its exact
   three-line headline treatment) — promoted here once viper.html needed
   the identical split for its own hero, same "second page wants it, it
   moves to components" convention as every other shared pattern on this
   site. Single column below 1024px, same as every other hero variant. */
.hero-grid{ display:grid; grid-template-columns:1fr; gap:var(--space-8); align-items:end; }
/* kicker-to-headline spacing: .hero-copy's own flex gap (above) already
   spaces NEXUS's stacked kicker+h1 by --space-6. This split's headline
   column has no equivalent — .hero-headline was plain block flow, so its
   kicker (margin:0, by design — see .hero-kicker above) sat almost flush
   against the h1 below it with only line-height for breathing room, well
   short of NEXUS's gap. Matching that gap here brings VIPER's hero in
   line with it. Homepage's .hero-headline has no kicker at all (just the
   multi-line h1), so this is a no-op there — flex gap only inserts space
   BETWEEN children, and a single child has no sibling to gap against. */
.hero-headline{ display:flex; flex-direction:column; gap:var(--space-6); }
.hero-side{ display:flex; flex-direction:column; align-items:flex-start; gap:var(--space-6); }
/* releases the headline's own measure cap once the grid column becomes the
   real constraint — home.css's three-line headline needs the room, and any
   other page's shorter one-line headline is unaffected either way. */
.hero-headline .t-display{ max-width:none; }
@media (min-width:1024px){
  .hero-grid{ grid-template-columns:minmax(0,8fr) minmax(0,4fr); column-gap:var(--gutter); }
}


/* ── CONSOLIDATION DIAGRAM: many inputs → one result ────────────────────────
   Moved here from viper.css (renamed from .oem-row, .oem-box, .consolidate
   and .viper-env) once nexus.html needed the same "separate inputs merge into
   one operational layer" argument for its own problem section — the boxes
   are platform control systems on one page and sensor views on the other,
   so the names were generalised. flex-wrap, not a fixed grid: the row
   needs to hold 3 boxes on one page and 4 on the other with no
   column-count variant, and wrapping is exactly what a row of short labels
   should do if a screen can't hold them all on one line. */
.problem-resolve{ display:grid; grid-template-columns:1fr; gap:var(--space-8);
  margin-top:var(--section-y-sm); align-items:center; }
@media (min-width:1024px){
  .problem-resolve{ grid-template-columns:minmax(0,5fr) minmax(0,7fr);
    column-gap:var(--gutter-lg); }
}
.problem-resolve > .t-h2{ max-width:28ch; }
/* a heading alone in this slot still gets .problem-resolve > .t-h2 above;
   once a list follows it, the pair sits in a wrapper div instead, so the
   28ch cap doesn't apply — the grid column's own width already keeps this
   narrow enough for a list. */

/* Chevron-prefixed list for prose bullets, not tied to a bordered card, for
   bullets sitting directly in body copy. Was a plain "—" dash; moved to a
   "›" per direct request, in --color-accent to give each point a small lit
   marker rather than a neutral one — same everyday-dash-to-signal-mark
   upgrade the rest of the site already makes with .hatch. */
.copy-list{ list-style:none; margin:var(--space-5) 0 0; padding:0;
  display:flex; flex-direction:column; gap:var(--space-3); }
.copy-list li{ padding-left:var(--space-5); position:relative; }
.copy-list li::before{ content:"›"; position:absolute; left:0; color:var(--color-accent);
  font-weight:600; font-size:1.1em; line-height:1; }
/* Same reasoning as .hatch's own surface-light override just above: lime
   reads too pale on the cream ground to register as a marker, so light
   sections send it to ink instead of leaving it in accent. */
.surface-light .copy-list li::before{ color:var(--color-text); }

.merge-stack{ display:flex; flex-direction:column; gap:var(--space-5); align-items:center; }
.merge-row{ display:flex; flex-wrap:wrap; justify-content:center; gap:var(--space-3); width:100%; }
.merge-box{ flex:1 1 160px; min-width:0; border:var(--hair) solid var(--color-border); padding:var(--space-4);
  text-align:center; }
.merge-box .t-meta{ color:var(--color-text-muted); }
.merge-arrow{ color:var(--color-text-muted); font-size:var(--ui-md); line-height:var(--lh-flat); }
/* the single resolution the diagram argues for */
.merge-result{ display:flex; flex-direction:column; align-items:center; gap:var(--space-2);
  width:100%; padding:var(--space-6); text-align:center;
  border:var(--hair) solid var(--color-accent); background:var(--color-accent-dim); }
.merge-result .t-h3{ color:var(--color-accent); }
.resolve-note{ text-align:center; }


/* ── ARCHITECTURE STACK ───────────────────────────────────────────────────
   Moved here from viper.css once nexus.html needed the same "vertical
   stack of boxes connected by arrows, ending in a chip row" pattern —
   already generic (any number of .arch-box/.arch-connector pairs), so no
   rename was needed, just relocation. Centred, not a .split — this is a
   single small diagram, not an argument with supporting copy either side.
   ───────────────────────────────────────────────────────────────────────── */
.arch{ text-align:center; }
.arch-heading{ max-width:32ch; margin:0 auto var(--space-8); }
.arch-support{ max-width:var(--measure-lead); margin:var(--space-8) auto 0; }
.arch-stack{ display:flex; flex-direction:column; align-items:center; gap:var(--space-3);
  max-width:var(--measure-lead); margin:0 auto; }
.arch-box{ width:100%; padding:var(--space-4) var(--space-6);
  border:var(--hair) solid var(--color-border-strong); }
.arch-box .t-label{ color:var(--color-text-muted); }
/* the one accent moment in the viewport this is used in */
.arch-box.accent{ border-color:var(--color-accent); background:var(--color-accent-dim); }
.arch-box.accent .t-label{ color:var(--color-accent); }
.arch-connector{ color:var(--color-text-muted); font-size:var(--ui-md); line-height:var(--lh-flat); }
.arch-chips{ justify-content:center; margin-top:var(--space-2); }


/* ── FLOW STRIP: N-step process ───────────────────────────────────────────
   Moved here from viper.css once nexus.html needed the same numbered,
   hairline-divided step sequence for its own operational loop. Columns at
   md+, stacked rows below — the same "thin rule, not a filled block"
   language as .tabs and .compare-row. */
.flow-strip{ margin-top:var(--section-y-sm); border-top:var(--hair) solid var(--color-border-subtle); }
.flow-step{ display:flex; flex-direction:column; gap:var(--space-3);
  padding:var(--space-6) 0; border-bottom:var(--hair) solid var(--color-border-subtle); }
@media (min-width:1024px){
  .flow-strip{ display:grid; grid-template-columns:repeat(4,1fr);
    border-top:0; }
  .flow-step{ padding:0 var(--space-6); border-bottom:0;
    border-left:var(--hair) solid var(--color-border-subtle); }
  .flow-step:first-child{ padding-left:0; border-left:0; }
}


/* ── CARD A — product ──────────────────────────────────────────────────── */
.card-product{
  position:relative; background:var(--black);
  border:var(--hair) solid var(--color-border);
  display:flex; flex-direction:column;   /* image zone, then text zone */
  cursor:pointer;
}
.card-product:hover .badge-hex{ transform:translate(3px,-3px); }
.card-product .tag{ margin-bottom:var(--space-4); }
.card-product h3{ margin-bottom:var(--space-5); }
.card-product h3{ margin-bottom:var(--space-5); }
.card-product p{ max-width:var(--measure-card); margin-bottom:var(--space-7); }
/* Sits right under the lead paragraph rather than pushed to the card's
   bottom edge — NEXUS's pill labels run much longer than VIPER's, so
   letting flex-wrap decide per-row count gave the two cards a mismatched
   rhythm (3+1 vs a lopsided wrap). A fixed two-column grid keeps both
   cards at a steady 2-and-2 regardless of label length. */
.card-product .pill-row{
  margin-top:var(--space-6);
  display:grid; grid-template-columns:repeat(2,minmax(0,1fr));
  gap:var(--space-3);
}
/* --lh-flat (1) is tuned for a single-line label — fine everywhere else
   .pill is used, but here NEXUS's longer tags wrap to two lines and sit
   with no breathing room between them. Scoped bump, not a change to the
   shared token, so every other --lh-flat consumer (nav, buttons, tabs,
   the .arch-chips pills) keeps its tight single-line rhythm. */
.card-product .pill{ white-space:normal; line-height:1.4; }

/* ── CARD B — capability (notched) ─────────────────────────────────────────
   Outline is two stacked clipped layers so the hairline follows the diagonal
   instead of being severed by clip-path. Same reason as the button.
   ───────────────────────────────────────────────────────────────────────── */
.card-notch{ position:relative; min-height:340px; }
.card-notch .edge, .card-notch .face{
  position:absolute; pointer-events:none;
  clip-path:polygon(0 0, calc(100% - var(--notch)) 0, 100% var(--notch), 100% 100%, 0 100%);
}
.card-notch .edge{ inset:0; background:var(--color-border-strong); }
.card-notch .face{ inset:var(--hair); background:var(--color-bg); }
/* on a light ground the card's own darkness IS the separation — a white
   hairline there would be invisible, so it matches the fill */
.surface-light .card-notch .edge{ background:var(--black); }
.card-notch .inner{
  position:relative; z-index:1; height:100%;
  display:flex; flex-direction:column; padding:var(--space-6);
}
/* fixed offset, never margin-top:auto — so meters align across the set
   regardless of how many lines the copy below them runs to */
.card-notch .hatch{ margin-top:var(--space-9); }
.card-notch p{ margin:var(--space-5) 0 0; max-width:var(--measure-card); }

@media (max-width:639px){
  .card-notch{ min-height:240px; }
  .card-notch .hatch{ margin-top:var(--space-7); }
}

/* ── PILLARS: 3-up capability grid (Card B set) ─────────────────────────────
   Moved here from home.css when viper.html needed the same six-card layout
   for its own capability set — the reuse test this file is named for. Six
   items read as two rows of three at lg; four columns would leave a widowed
   row of two. */
.pillars{ display:grid; grid-template-columns:1fr; gap:var(--gutter);
  margin-top:var(--section-y-sm); }
@media (min-width:640px){  .pillars{ grid-template-columns:repeat(2,1fr); } }
@media (min-width:1600px){ .pillars{ grid-template-columns:repeat(3,1fr); } }


/* ── POINTS + PINNED MEDIA: a named-points list against a media slot ───────
   Moved here from nexus.css once viper.html needed the same "step through
   named points, media holds across all of them" pattern for its own Fold 3
   — the reuse test this file is named for. VIPER later moved that fold to
   its own plain layout instead (a wide render with a drawn network, .mission-net
   in viper.css — a heading, one image, two copy blocks) — but has since come back to this same pattern for its
   Edge Autonomy fold instead (#edge, viper.html), this time in the
   SCROLL PIN-AND-WALK mode described below rather than a bespoke layout —
   this stays here rather than folding back into nexus.css because the
   shared contract (.decision-point/.decision-media/decision-scroll.js) is
   genuinely shared across pages now, not just held open for one. A
   handful of named points stacked in a left column (title + description
   only, no icon) against a media slot on the right.

   Base state (no JS, <1024px, or reduced motion): every point is listed
   and visible at once, media stays sticky (position:sticky) so it holds
   in view while the list scrolls past it — a CSS-only fallback that's
   fully usable without decision-scroll.js.

   JS-enhanced state (>=1024px, decision-scroll.js — mirrors #operates'
   pin-and-walk pattern in pin-tabs.js/home.css, simplified since a text
   swap needs no DWELL-paced walk): the host section gains
   [data-motion="pinned"], the grid is pinned by GSAP so the sticky rule
   below is switched off, and only the .is-active point stays visible
   while the page scrolls through the pinned region. decision-scroll.js
   finds its own section by walking up from the first .decision-grid on
   the page, so any page can drop this pattern in without a JS change —
   just needs at least two .decision-point children for the pin to engage.
   Live on VIPER's Edge Autonomy fold now (#edge, viper.html) — the mode
   was written and left in place well before that fold actually used it,
   specifically so a future .decision-grid could pick it up for free just
   by omitting the accordion opt-in, and that's exactly what happened.
   NEXUS's Define runs the accordion mode below instead, on the same
   shared markup contract.

   ACCORDION state (data-decision-mode="accordion" on the grid — currently
   just NEXUS's Define): a second, click-driven mode decision-scroll.js
   also serves from the same markup contract, instead of the scroll-pin
   above. .decision-point-head/.decision-point-panel (below) replace
   .decision-point-body for this mode's points — a clickable header plus a
   collapsing panel rather than an always-visible paragraph — and .is-active
   means "expanded", not "the one step currently showing during a pin", but
   it drives the exact same video swap either way. No [data-motion="pinned"]
   here: this mode never pins, so it runs unchanged at every width and under
   reduced motion (its own collapse transition already respects that on its
   own, see .decision-point-panel below).

   align-items:center rather than start: in the JS-enhanced state only
   one short point is ever showing next to the full-height media box, so
   centering keeps that single point level with the middle of the box
   instead of stranded up against its top edge. The no-JS fallback (all
   points stacked, taller than the media box) still gets sticky's
   full travel either way — align-items only shifts the media's resting
   position within the row, not how much room sticky has to move. Also why
   the accordion mode's own collapsed-by-default points don't fight this:
   only one point (Track) is ever tall on load, same single-tall-item shape
   the scroll mode already centers correctly. */
.decision-grid{ display:grid; grid-template-columns:1fr; gap:var(--space-8);
  margin-top:var(--space-7); }
@media (min-width:1024px){
  .decision-grid{ grid-template-columns:minmax(0,5fr) minmax(0,7fr);
    column-gap:var(--gutter-lg); align-items:center; }
}
/* --space-5, not --space-7: each point now draws its own divider below
   (padding-bottom + border-bottom, below) rather than relying on a bare
   gap between items, so the gap only needs to be the space AFTER that
   line — padding-bottom and gap add together (24px + 24px), landing back
   on the same 48px --space-7 used to give the whole rhythm on its own. */
.decision-points{ display:flex; flex-direction:column; gap:var(--space-5); }
/* column, not the implicit row default: harmless with the old single-child
   .decision-point-body (a lone flex item stacks the same either way), but
   load-bearing now that the accordion's .decision-point has TWO children
   (.decision-point-head, .decision-point-panel) — row would sit them side
   by side instead of head-then-panel, which is exactly the bug this caught
   at narrow widths (the panel rendering as a second column next to the
   title instead of unfolding below it). */
/* padding-bottom + border-bottom: a divider under every point, including
   the last (matches the reference layout this accordion was asked to
   match — a line closes off each row, not just the ones with a
   neighbour below them). See .decision-points above for how the gap
   above was trimmed to keep the overall rhythm the same as before. */
.decision-point{ display:flex; flex-direction:column; gap:var(--space-5); align-items:flex-start;
  padding-bottom:var(--space-5); border-bottom:var(--hair) solid var(--color-border-subtle); }
/* the signal marker (.hatch, the same meter the Mission Layer cards use)
   sits above each point's title rather than beside it — "on top of" the
   point, echoing how the meter sits above the title inside a .card-notch. */
.decision-point-body .hatch{ margin-bottom:var(--space-4); }
/* A plain step numeral (.t-meta.accent.num — the same combination the old
   #edge flip grid used) is the other thing that can sit above a point's
   title instead of a .hatch meter — VIPER's Edge Autonomy uses this, not
   the meter. .t-meta carries no display of its own (base.css; it's used
   inline elsewhere), so margin-bottom would be a no-op without block here
   — the same reason .hatch above needs no equivalent override, since
   .hatch's own base rule (display:flex) already makes it a block-level
   box. */
.decision-point-body .num{ display:block; margin-bottom:var(--space-2); }
.decision-point-body h3{ margin-bottom:var(--space-2); }

/* ── ACCORDION MODE: clickable head + collapsing panel ───────────────────
   Only used by a .decision-point inside a [data-decision-mode="accordion"]
   grid (currently NEXUS's Define) — .decision-point-body above is untouched
   and still what every scroll-mode grid (VIPER's own reuse included) uses,
   so none of this can affect them.

   The head is a real <button> (decision-scroll.js) rather than a div with a
   click listener, so it's reachable and activatable by keyboard for free,
   and carries its own aria-expanded — the browser's default button styling
   is stripped below so it reads as the same plain title block .decision-
   point-body's h3/hatch always were, not as a visible button. */
.decision-point-head{
  display:flex; flex-direction:column; align-items:flex-start;
  width:100%; background:none; border:0; margin:0; padding:0;
  font:inherit; color:inherit; text-align:left; cursor:pointer;
}
.decision-point-head .hatch{ margin-bottom:var(--space-4); }
.decision-point-title{ display:flex; align-items:center;
  justify-content:space-between; gap:var(--space-4); width:100%; }
/* h3 loses the margin-bottom .decision-point-body's h3 carries (there's no
   paragraph directly under it any more to space from — the panel below
   supplies its own gap instead) but keeps its box from stretching to fill
   .decision-point-title's height, which would otherwise misalign the caret
   against a two-line title. */
.decision-point-title h3{ margin-bottom:0; align-self:center;
  color:var(--color-text-muted); transition:color var(--dur-fast) var(--ease-out); }
/* the active point's title reads at full strength, same contrast the
   reference layout uses to separate the one open row from the collapsed
   ones sitting quietly beneath/above it. */
.decision-point.is-active .decision-point-title h3{ color:var(--color-text); }
/* Same construction as .nav-caret (components.css, "Products dropdown")
   — two rotated border edges, no separate icon asset. rotate(45deg) points
   it down (collapsed, "expand me"); the .is-active flip to 225deg points it
   up (expanded, "collapse me"), the identical closed/open pair .nav-caret
   already uses for its own dropdown. */
.decision-point-caret{ flex:none; width:8px; height:8px; margin-top:2px;
  border-right:1.5px solid currentColor; border-bottom:1.5px solid currentColor;
  color:var(--color-text-muted);
  transform:rotate(45deg);
  transition:transform var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out); }
.decision-point-head:hover .decision-point-caret{ color:var(--color-text); }
.decision-point.is-active .decision-point-caret{ transform:rotate(225deg); color:var(--color-text); }
/* The collapse itself: grid-template-rows between 0fr and 1fr, not
   height:auto (a plain height transition can't animate to/from "auto") and
   not max-height (an arbitrary cap either clips a longer definition or
   leaves a lot of empty transition time for a short one). A 0fr row track
   sizes to zero regardless of its content's own height, but only once that
   content's min-height stops fighting it — grid items default to
   min-height:auto (their own max-content size), which is exactly what
   min-height:0 below overrides; overflow:hidden on the same element then
   hides whatever the now-too-short box can't fit during the transition. */
/* width:100% because .decision-point is a column flex container with
   align-items:flex-start (above) — without an explicit width, a flex
   item's cross size (here, horizontal) shrink-wraps to its own content
   under flex-start instead of filling the row, same class of bug as
   .decision-point-head needing its own width:100% above. */
.decision-point-panel{ display:grid; grid-template-rows:0fr; width:100%;
  transition:grid-template-rows var(--dur-drawer) var(--ease-drawer); }
.decision-point.is-active .decision-point-panel{ grid-template-rows:1fr; }
.decision-point-panel-inner{ overflow:hidden; min-height:0; }
.decision-point-panel-inner p{ margin-top:var(--space-2); }
@media (prefers-reduced-motion:reduce){
  .decision-point-panel{ transition:none; }
  .decision-point-caret{ transition:none; }
}
/* the media box is a .card-notch styled to match the Mission Layer's
   Heterogeneous Integration card exactly — a plain "Video placeholder"
   label, centred, standing in for real footage later. It carries no meter
   of its own: the signal marker is the points' language, not the video
   slot's, so it only ever appears above a point's title. */
.decision-media .inner{ display:flex; flex-direction:column;
  align-items:center; justify-content:center; text-align:center; height:100%; }
/* --video variant: real footage instead of the placeholder label above —
   NEXUS's Define (Track/Identity/Mission State/Airspace). Operational Loop
   carried this too when it first shipped, but has since moved to its own
   .operate-stage/pin-tabs.js pattern (see nexus.html's own markup comment)
   — nothing below is Loop-specific, this note just no longer applies there.
   Scoped to this modifier class
   rather than to .decision-media itself so VIPER's still-placeholder box
   keeps its padded, centred text untouched.

   Every point's clip sits in the DOM already (all <video> children of
   .inner, one per point, each preloaded) rather than one shared <video>
   whose <source> src decision-scroll.js used to rewrite on the fly — that
   swap needed a load() before the new clip could show, and load() clears
   the current frame first, so switching points flashed an empty black
   beat before the next clip appeared. Preloaded and stacked, switching is
   just an opacity toggle (.is-shown) between clips that were already
   playing underneath — decision-scroll.js only ever adds/removes that
   class now, nothing to load.

   A border directly on each video (rather than trying to make .edge/.face's
   own ring show through) can't cover this box's cut corner either way — a
   rect border only ever draws along the box's four straight sides, and the
   notch is a diagonal clip, not one of them. So each video is inset by
   --hair instead, sized and clipped to match .face's own notch exactly,
   letting .edge's colour peek through as a ring on all four sides AND the
   diagonal — the same ring trick .edge/.face already use for the
   placeholder variant, just now with the video sitting where .face does
   instead of covering it. */
.decision-media--video .inner{ padding:0; position:relative; }
.decision-media--video video{
  position:absolute; inset:var(--hair);
  width:calc(100% - var(--hair)*2); height:calc(100% - var(--hair)*2);
  object-fit:cover; display:block;
  clip-path:polygon(0 0, calc(100% - var(--notch)) 0, 100% var(--notch), 100% 100%, 0 100%);
  opacity:0; transition:opacity var(--dur-fast) var(--ease-out);
}
.decision-media--video video.is-shown{ opacity:1; }
/* Observe (Operational Loop) has no clip of its own — is-empty hides the
   video so the face's plain background shows through instead, rather than
   holding the last-played clip or a broken empty source. */
.decision-media--video.is-empty video{ opacity:0; }
/* The placeholder variant's .inner gets its real, non-zero height for free
   from its own in-flow content (icon + label text), so .card-notch .inner's
   height:100% never actually has to resolve — .inner's flex layout sizes
   itself to that content regardless, and .card-notch (a grid item sized to
   content, not stretched) follows suit, with min-height only ever acting as
   a floor. This variant's .inner holds nothing but absolutely-positioned
   video children instead — nothing in normal flow — so with no CSS fix its
   content height is zero, .card-notch collapses to zero too (min-height
   doesn't count: a percentage height still can't resolve against an
   ancestor whose own height is 'auto', no matter what its rendered size
   ends up being), and every video, the .edge/.face ring, and the notch
   border along with it disappear. height (not min-height) here gives
   .card-notch itself a genuinely definite size so .inner's height:100% —
   and in turn each video's inset-based sizing above — has something real
   to resolve against; the two breakpoints match .card-notch's own
   min-height values below so the box stays the same size it always was. */
.decision-media--video{ height:340px; }
@media (max-width:639px){
  .decision-media--video{ height:240px; }
}

/* --photo variant: a plain rectangular photo per point instead of --video's
   notched/ringed card — VIPER's Edge Autonomy (#edge) asked for a plain
   bordered image swapping under the fixed text, matching a supplied
   reference directly rather than the .card-notch diagonal-cut treatment
   --video wears elsewhere on the site, so this skips .card-notch/.edge/
   .face entirely rather than reusing them. Same crop and border language
   .pic-media already uses site-wide (object-fit:cover inside a hairline
   border over --color-surface) — just stacked and opacity-swapped instead
   of a single <img>, the same way --video's clips are: every point's
   photo already sits in the DOM (all <img> children of .inner, one per
   point), so decision-scroll.js only ever toggles .is-shown, nothing to
   load. Same fixed height as --video too (340/240px), not because the
   photos need it but so the sticky math right below — 170px is half of
   340px — works unchanged for either variant. */
.decision-media--photo .inner{ padding:0; position:relative; }
.decision-media--photo img{
  position:absolute; inset:0; width:100%; height:100%; object-fit:cover;
  opacity:0; transition:opacity var(--dur-fast) var(--ease-out);
}
.decision-media--photo img.is-shown{ opacity:1; }
.decision-media--photo{
  height:340px; border:var(--hair) solid var(--color-border-subtle);
  background:var(--color-surface); overflow:hidden;
}
@media (max-width:639px){
  .decision-media--photo{ height:240px; }
}

@media (min-width:1024px){
  /* Sticks at roughly the vertical middle of the viewport while the points
     list scrolls past it, and stays stuck all the way to the real bottom
     of the column instead of releasing early.

     top:50% + transform:translateY(-50%) used to do the centring here —
     visually correct, but a transform never participates in layout, and
     it never participates in the browser's own "how much further can I
     stay stuck before my containing block's bottom arrives" math either.
     That math is computed against the card's real, UNTRANSFORMED box —
     top edge sitting at the 50% mark, not the card's true, shifted-up
     centre — so it released the card back into normal flow the moment
     that untransformed box reached the bottom of the column, well before
     the card had visually scrolled anywhere near there. That mismatch was
     the "stops scrolling before the end" bug.

     Putting the finished position straight into a plain `top` on
     .decision-media itself (no transform, no wrapper) fixes THAT
     mismatch, but the accordion's points column is only ever ~530px
     tall — shorter than a typical viewport — so .decision-media's own
     containing block (this grid) has nowhere near enough room to hold a
     340px card at a viewport-centred offset without the card poking out
     the bottom of the grid; it barely engages before un-sticking again.
     Fixed height alone can't buy back that travel room.

     So: an invisible wrapper (.decision-media-sticky, in the markup —
     .decision-media itself, the actual card, is untouched inside it)
     that's genuinely tall enough to give the sticky math real room to
     work with. padding-top pushes the card down inside the wrapper by
     half the viewport's height (real box height, not a visual effect);
     margin-bottom of the same size then cancels that added height back
     out of the GRID ROW's own sizing, so the row still sizes to the
     points column and nothing below this section shifts. */
  /* CENTRED IN THE ROW, not viewport-centred by sticky.

     Per direct report the video "isn't aligned properly, it must be aligned
     in center of the white section beside auto-close accordion" — measured,
     its centre sat 460px BELOW the accordion column's centre.

     What was here: position:sticky with padding-top:50vh, margin-bottom:-50vh
     and top:-170px, an apparatus for holding the card at the VIEWPORT's
     vertical centre while it stuck. The 50vh padding is a real box offset
     that pushes the card down inside its wrapper, and top:-170px only pulls
     it back once the element is actually stuck — so at rest the card sat
     ~390px low, which is the misalignment.

     The apparatus was also buying nothing here. It exists to keep a media
     card in view while a long points column scrolls past it, but this grid
     runs the ACCORDION mode: one panel open at a time keeps the points
     column ~540px tall, shorter than a viewport, so there is no scrolling
     past for the card to survive. align-items:center on .decision-grid was
     already trying to centre it and the padding was overriding that.

     STICKY IS KEPT — per follow-up, "the video here must be sticky" — but
     it no longer manufactures its travel room with padding. The two
     positions are now stated independently and both are correct:

       align-self:center      where it RESTS: the row's vertical centre,
                              level with the accordion beside it.
       top:calc(50vh - 170px) where it STICKS: the viewport's vertical
                              centre, 170px being half of
                              .decision-media--video's own 340px height.

     The old rule could only express the second, and paid for it with a
     50vh padding that displaced the first by ~390px. Nothing here displaces
     anything, so at rest it is centred and while scrolling it holds at the
     viewport centre.

     The trade is travel room: sticky can only hold for as long as its
     containing block has room, which here is the row height minus the card
     (~200px) rather than the half-viewport the padding used to buy. That is
     the honest amount for a section this short — the accordion keeps the
     points column near 540px — and it holds the card centred across the
     whole scroll through the section, which is what the stickiness is for.

     The [data-motion="pinned"] reset below is kept — a future
     .decision-grid that omits the accordion opt-in still gets the
     scroll-pin mode, and that mode must not inherit either offset. */
  .decision-media-sticky{
    align-self:center;
    position:sticky; top:calc(50vh - 170px);
  }

  /* Pinned mode (still just VIPER's own possible future reuse — see this
     rule's neighbours below) doesn't use any of this: GSAP pins the whole
     .decision-grid directly (position:fixed), so the wrapper's own sticky
     centring would just be fighting that. .decision-media itself (the
     .card-notch) keeps its position:relative from its own base rule
     either way — unaffected by whatever the wrapper's position is — so
     .edge/.face's absolutely-positioned ring never depended on this
     wrapper and needs no reset of its own here. */
  [data-motion="pinned"] .decision-media-sticky{
    position:static; align-self:auto; padding-top:0; margin-bottom:0;
  }
  [data-motion="pinned"] .decision-point{ display:none; }
  [data-motion="pinned"] .decision-point.is-active{
    display:flex; animation:decision-point-in .4s var(--ease);
  }
  /* GSAP's pin fixes the grid at the trigger's start position — the top of
     the grid sitting flush against the top of the viewport — so without
     this the pinned box (a few hundred px tall) sat stranded near the top
     of a much taller viewport instead of holding centre stage. Two rules,
     not one: min-height gives the grid the full viewport to work with, but
     a grid's single implicit row still packs to the start of that box by
     default (align-items:center only centres the two COLUMNS against each
     other within their shared row, not the row itself within the taller
     container) — align-content is what positions the row.

     This used to bias toward the top third instead (align-content:start
     plus a fixed padding-top) specifically to hold clear room for a second
     row below — Operational Loop's .loop-note, back when Loop still ran
     this same pinned mode. Loop has since moved to its own .operate-stage/
     pin-tabs.js pattern (see nexus.html's own markup comment) and Define
     never pins at all (its own accordion mode opts out, above), so VIPER's
     Edge Autonomy is the only live user of this rule now, and it has no
     second row to protect headroom for. Reported directly once Edge
     Autonomy shipped: the pinned frame read as too much dead ground both
     above and below the point+photo row, which is exactly the asymmetric
     top-biased placement this rule used to produce on purpose — correct
     for a section with a row below to clear, wrong for one without.
     align-content:center (with no padding-top needed to bias it) splits
     that ground evenly instead, which is the same choice .decision-media
     itself already makes against its points column (align-items:center,
     base rule above) — this is that same reasoning one level up, applied
     to the row within the taller pinned viewport.

     row-gap is left in place though nothing currently uses it (no second
     row live to space against) — harmless as a no-op and ready again the
     same way this rule's neighbours above were, should a future reuse
     bring a row back below. column-gap is untouched either way (still
     --gutter-lg, set below), so the point/media split itself never moves. */
  [data-motion="pinned"] .decision-grid{ min-height:100svh; align-content:center;
    row-gap:var(--space-5); }
}
@keyframes decision-point-in{ from{ opacity:0; transform:translateY(12px); } to{ opacity:1; transform:none; } }
@media (prefers-reduced-motion:reduce){
  [data-motion="pinned"] .decision-point.is-active{ animation:none; }
}


/* ── FOOTER ──────────────────────────────────────────────────────────────── */
.footer{ border-top:var(--hair) solid var(--color-border-subtle);
  padding-block:var(--section-y-sm); }
.footer-inner{ display:grid; grid-template-columns:1fr; gap:var(--section-y-sm); }
.footer-brand .logo{ margin-bottom:var(--space-5); }
/* Row of platform icon links, now BELOW the address block (under the email
   line) rather than under the logo — solid brand-mark svgs (currentColor
   fill, no circular badge) that stay quiet by default and light up
   accent-yellow on hover/focus. The 44px hit target is invisible, not a
   circle — click area only, sized for touch/pointer comfort without
   drawing a shape around the mark.
   margin-top, not margin-bottom: it's the last item in .footer-brand now,
   so the gap it needs is above itself, matching the same --space-4 used
   between every other item in this block (see .footer-h and .footer
   address a below) so "Contact Us" → address → email → socials reads as
   one even rhythm instead of one gap being noticeably tighter than the
   rest.
   margin-left:-9px on the row: the 44px hit target centers each 26px mark
   inside itself, which is exactly what keeps it comfortable to click, but
   it also insets the mark 9px ((44-26)/2) from the hit target's own edge.
   Left uncorrected that reads as the row starting well to the right of
   "Contact Us"/the address/the email above it. Pulling the whole row back
   by that same 9px lines the first mark's actual left edge up with theirs
   without touching the hit target's size or its centering. */
.footer-social{ display:flex; gap:var(--space-3); margin-top:var(--space-4); margin-left:-9px; }
.footer-social a{
  display:flex; align-items:center; justify-content:center;
  width:44px; height:44px; flex:none; color:var(--color-text-muted);
  transition:color var(--dur-fast) var(--ease-out);
}
.footer-social svg{ width:26px; height:26px; }
.footer-social a:hover,
.footer-social a:focus-visible{
  color:var(--color-accent);
}
.footer-h{ margin:0 0 var(--space-4); }
.footer address{ font-style:normal; color:var(--color-text-muted);
  max-width:var(--measure-card); }
/* --space-4 here too (was --space-3) so the address-line-to-email gap
   matches the label-to-address gap above and the email-to-socials gap
   below it — one consistent step throughout the whole Contact Us block. */
.footer address a{ display:block; margin-top:var(--space-4); color:var(--color-text); }
/* four columns now (Platform · Solutions · Company · Policies): two at mobile
   so the labels do not wrap, four once there is room at md */
.footer-cols{ display:grid; grid-template-columns:repeat(2,1fr); gap:var(--gutter); }
.footer-cols a{ display:block; color:var(--color-text-muted); margin-bottom:var(--space-3);
  transition:color var(--dur-fast); }
.footer-cols a:hover{ color:var(--color-accent); }
.footer-bottom{ margin-top:var(--section-y-sm); padding-top:var(--space-5);
  border-top:var(--hair) solid var(--color-border-subtle); }
@media (min-width:1024px){
  .footer-inner{ grid-template-columns:minmax(0,7fr) minmax(0,10fr);
    column-gap:var(--gutter-lg); }
  .footer-cols{ grid-template-columns:repeat(4,1fr); }
}


/* ── HATCH METER: 11 diagonal ticks, N lit ─────────────────────────────── */
/* The unlit ticks derive from the active pole, not from a dimmed accent.
   Accent-at-24% over cream measures barely above the ground, so on a light
   card the meter stopped reading as a meter at all. Pole-derived unlit ticks
   keep the lit/unlit contrast legible on either ground. */
.hatch{ display:flex; gap:var(--space-1); }
.hatch i{ width:8px; height:18px; display:block;
  background:rgba(var(--pole),.18); transform:skewX(-24deg); }
.hatch i.on{ background:var(--color-accent); }
/* on a light ground, lime is too pale to signal — the lit ticks go to ink */
.surface-light .hatch i.on, .tile .hatch i.on{ background:var(--color-text); }
/* Smaller, quieter build of the same meter for NEXUS's Decision Layer —
   per direct report, the full-size ticks (8x18px) read as too heavy next
   to a plain heading + one line of body copy here, unlike home.css's
   .card-notch, where the meter sits under a whole card title and has more
   visual weight to answer. Home's Mission pillars keep the original size
   — only this one is scaled down, gap included so the tighter ticks don't
   just leave the same footprint with more air. VIPER's own Mission Layer
   carried this same scaled-down mark until it was dropped from that
   section outright (viper.html) — #mission-layer is gone from the
   selector below rather than left pointing at markup that no longer
   exists. */
#define .hatch{ gap:3px; }
#define .hatch i{ width:5px; height:12px; }



/* ── HEX BADGE ─────────────────────────────────────────────────────────── */
.badge-hex{
  position:absolute; top:var(--space-5); right:var(--space-6);
  width:var(--hex); height:calc(var(--hex) * .9);
  background:var(--color-accent); color:var(--color-accent-ink);
  clip-path:polygon(25% 0%, 75% 0%, 100% 50%, 75% 100%, 25% 100%, 0% 50%);
  display:grid; place-items:center; transition:transform var(--dur) var(--ease);
}
.badge-hex svg{ width:44%; height:44%; }



/* ── MEDIA SLOT ────────────────────────────────────────────────────────────
   Where footage goes. The video fills the frame, a flat scrim sits over it,
   and content sits above that. The scrim is what keeps display type legible
   over moving footage — it is not optional, and it must stay flat (a gradient
   scrim is the usual way one creeps back into this system).

     <div class="media">
       <video autoplay muted loop playsinline poster="…"><source …></video>
       <div class="media-scrim"></div>
     </div>

   Always ship a poster: it is the first frame, the reduced-motion fallback,
   and what shows if the file fails.
   ───────────────────────────────────────────────────────────────────────── */
.media{ position:absolute; inset:0; overflow:hidden; z-index:0; }
.media video, .media img{ width:100%; height:100%; object-fit:cover; }
.media-scrim{ position:absolute; inset:0; background:var(--scrim-45); }
.media-scrim.heavy{ background:var(--scrim-82); }
@media (prefers-reduced-motion:reduce){
  .media video{ display:none; }
  .media{ background:var(--color-surface); }
}



/* ── NAV ───────────────────────────────────────────────────────────────────
   Hides on scroll down, returns on scroll up. Seen constantly, so it has to be
   near-imperceptible: transform only, both directions ease-out, and the return
   is FASTER than the hide because that one is the system answering intent.
   A transition (not keyframes) so reversing direction mid-move retargets from
   wherever it currently is instead of restarting. translateY(-100%) is its own
   height, so the value never needs maintaining. */
.nav{ position:fixed; top:0; left:0; right:0; z-index:100; background:var(--scrim-82);
  backdrop-filter:blur(7px); border-bottom:var(--hair) solid var(--color-border-subtle);
  transform:translateY(0); transition:transform 180ms var(--ease-out); will-change:transform; }
.nav[data-hidden="true"]{ transform:translateY(-100%); transition-duration:240ms; }
@media (prefers-reduced-motion:reduce){
  /* movement removed entirely — the nav simply stays put */
  .nav, .nav[data-hidden="true"]{ transform:none; transition:none; }
}
.nav-inner{ display:flex; align-items:center; justify-content:space-between; gap:var(--gutter);
  padding-block:var(--space-4); }
/* the real brand mark's paths spell out the full wordmark themselves (an
   icon + "DRIIF" lettering, both vector), so .logo no longer wraps an icon
   plus a separate text node — the typography properties that used to style
   that text label are gone with it. currentColor on the wordmark paths and
   var(--color-accent) on the icon paths (set inline, in the markup) mean
   this still tracks both tokens the way the placeholder svg always did. */
.logo{ display:inline-flex; align-items:center; }
.logo svg{ height:20px; width:auto; display:block; }
.nav-links{ align-items:center; gap:var(--gutter); }
.nav-links a{ color:var(--color-text-muted); transition:color var(--dur-fast); }
.nav-links a.active, .nav-links a:hover{ color:var(--color-accent); }

/* ── CURRENT PAGE INDICATOR ────────────────────────────────────────────────
   Colour alone was the whole indicator, and colour alone is also the hover
   state — so "the page you are on" and "the link under your cursor" looked
   identical, and neither read as a position. A mark separates them: hover
   still only changes colour, current adds a bar.

   Two shapes for the two layouts, because the nav is a row on desktop and a
   stack on mobile, and a bar has to sit across the axis of travel to read as
   a position within it. Underneath the item in a row; down its leading edge
   in a stack.

   .active is set by nav-current.js from the URL, not from the markup — see
   that file for why the hand-written classes could not be trusted.

   ::after is free on these anchors: the dropdown triggers carry their caret
   as a real <span class="nav-caret">, not a pseudo-element, so there is
   nothing to collide with. */
.nav-links a.active{ position:relative; }
@media (min-width:1024px){
  .nav-links > a.active::after,
  .nav-links > .nav-item > a.active::after{
    content:""; position:absolute; left:0; right:0; bottom:calc(var(--space-2) * -1);
    height:2px; background:var(--color-accent);
  }
}
@media (max-width:1023px){
  /* inset box-shadow rather than a border: the stacked rows already carry a
     border-top hairline for separation, and a border-left would shift every
     row's text by its own width while only the current row has one.

     Scoped to skip the .nav-item trigger (Products/Resources): unlike
     desktop, the dropdown panel is always open in the stack, so a trigger
     lit alongside its already-lit child drew two bars at once, one nested
     inside the other. The trigger still reads as current through .active's
     colour rule above — only the actual current page gets a bar. */
  .nav-links > a.active,
  .nav-drop-panel a.active{ box-shadow:inset 3px 0 0 var(--color-accent); }
}
@media (min-width:1024px){ .nav-links{ display:flex; } }
/* CTA + mobile menu button grouped together so justify-content:space-between
   on .nav-inner still only ever spreads two things apart (the logo and this
   group) rather than three, regardless of whether .nav-toggle is visible —
   see .nav-toggle below for why it's hidden at >=1024px. */
.nav-actions{ display:flex; align-items:center; gap:var(--space-5); }

/* ── NAV: mobile menu ─────────────────────────────────────────────────────
   Below 1024px .nav-links had nowhere to go before this — display:none in
   the base rule above, with no toggle to ever un-hide it, so every link
   except the logo and the CTA button was simply unreachable on a phone.
   nav-toggle.js adds the button and drives .nav[data-open]; every visual
   consequence of that state lives here, scoped under .js so the collapse
   itself never exists without a script able to open it back up again —
   same "nothing is hidden unless .js is present" rule .reveal follows
   (motion.css). Without JS, .nav-links just stays what it already is
   below 1024px: a plain flex column under the logo, fully visible, no
   button needed to reach it. */
.nav-toggle{ display:none; }
@media (max-width:1023px){
  .js .nav-toggle{
    flex:none; width:28px; height:28px; padding:0; background:none; border:0;
    display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px;
    cursor:pointer;
  }
  .nav-toggle-bar{ display:block; width:20px; height:1.5px; background:var(--color-text);
    transition:transform var(--dur-fast) var(--ease-out), opacity var(--dur-fast) var(--ease-out); }
  /* the hamburger becomes a close X — middle bar fades, outer two rotate to
     meet in its place, same 7px offset as the bar gap above so they cross
     dead centre rather than off to one side. */
  .nav[data-open="true"] .nav-toggle-bar:nth-child(1){ transform:translateY(6.5px) rotate(45deg); }
  .nav[data-open="true"] .nav-toggle-bar:nth-child(2){ opacity:0; }
  .nav[data-open="true"] .nav-toggle-bar:nth-child(3){ transform:translateY(-6.5px) rotate(-45deg); }

  .nav-links{ display:flex; flex-direction:column; align-items:stretch; gap:0; }
  /* Collapsed drawer, JS-only (see file header above). Sits right under the
     nav bar itself (top:100% of .nav-inner's own containing block, .nav,
     which is already position:fixed) rather than the viewport, so it opens
     directly beneath the bar regardless of scroll position. transform +
     opacity only, per this site's motion rule — visibility is along for
     the ride so a closed menu's links are never keyboard/screen-reader
     reachable, not just invisible. */
  .js .nav-links{
    position:absolute; top:100%; left:0; right:0;
    max-height:calc(100svh - 100%); overflow-y:auto;
    background:var(--color-bg); border-top:var(--hair) solid var(--color-border-subtle);
    padding:0 var(--edge) var(--space-6);
    opacity:0; visibility:hidden; pointer-events:none; transform:translateY(-8px);
    transition:opacity var(--dur-ui) var(--ease-out), transform var(--dur-ui) var(--ease-out), visibility var(--dur-ui);
  }
  .js .nav[data-open="true"] .nav-links{
    opacity:1; visibility:visible; pointer-events:auto; transform:none;
  }
  /* padding-left leaves room for the active bar (box-shadow:inset 3px,
     above) to sit clear of the text — without it the bar draws flush
     against the row's own edge, which is also where the glyph starts, and
     overlaps the first letter. Applied to every row, active or not, so a
     row's text doesn't jump sideways the moment it becomes the current page. */
  .nav-links > a, .nav-links > .nav-item > a{
    padding-block:var(--space-4); padding-left:var(--space-3);
    border-top:var(--hair) solid var(--color-border-subtle);
  }
  .nav-links > a:first-child, .nav-links > .nav-item:first-child > a{ border-top:0; }
  /* The Products/Resources dropdowns are a hover reveal on desktop — there
     is no hover on a touch screen, so below 1024px they render as a plain
     always-expanded sub-list instead of dead weight behind a gesture that
     will never fire. Every selector here is doubled up with an extra .nav
     ancestor class (".nav .nav-drop", not just ".nav-drop") purely to win
     specificity against the hover-reveal rules further down this same
     file (.nav-drop/.nav-drop-panel/.nav-caret, under "Products dropdown")
     — those sit later in source order, so at equal specificity they would
     otherwise win regardless of this media query. */
  .nav .nav-item{ flex-direction:column; align-items:stretch; }
  .nav .nav-item > a{ justify-content:space-between; }
  .nav .nav-caret{ display:none; }
  .nav .nav-drop{ position:static; opacity:1; visibility:visible; pointer-events:auto; padding-top:0; }
  .nav .nav-drop-panel{ position:static; background:none; border:0; backdrop-filter:none;
    transform:none; min-width:0; padding:0 0 var(--space-2) var(--space-5); }
  .nav .nav-drop a + a{ margin-inline:0; padding-inline:0; }
}
@media (prefers-reduced-motion:reduce){
  .js .nav-links{ transition:none; }
  .nav-toggle-bar{ transition:none; }
}

/* Products dropdown: a plain hover/focus reveal, no JS. .nav-drop sits
   directly under the trigger with no margin-gap of its own — the gap is
   padding-top on .nav-drop instead, which stays part of its hoverable box,
   so moving the mouse down into the panel never drops out of :hover between
   the link and the panel. .nav-drop-panel carries the actual visible box
   (border/background/motion) so the invisible gap above it stays invisible. */
/* flex (not block): a plain block box here reserves an extra whitespace-driven
   line box around the .nav-drop sibling markup, taller than the .t-label
   trigger inside it — align-items:center on .nav-links then centers that
   taller box instead of the trigger itself, nudging "Products" a couple of
   pixels off the other labels' baseline. Flex collapses that phantom box so
   .nav-item's height comes from the trigger alone, same as its siblings. */
.nav-item{ position:relative; display:flex; align-items:center; }
.nav-item > a{ display:inline-flex; align-items:center; gap:var(--space-2); }
.nav-caret{ width:6px; height:6px;
  border-right:1.5px solid currentColor; border-bottom:1.5px solid currentColor;
  transform:translateY(-2px) rotate(45deg); transition:transform var(--dur-fast) var(--ease-out); }
.nav-item:hover .nav-caret, .nav-item:focus-within .nav-caret{
  transform:translateY(0) rotate(225deg); }

.nav-drop{ position:absolute; top:100%; left:0; padding-top:var(--space-3);
  opacity:0; visibility:hidden; pointer-events:none;
  transition:opacity var(--dur-fast) var(--ease-out), visibility var(--dur-fast); }
.nav-item:hover .nav-drop, .nav-item:focus-within .nav-drop{
  opacity:1; visibility:visible; pointer-events:auto; }

/* Bigger box, and the gap between options carried by each link's own
   padding-block rather than the panel's flex gap — that's what leaves room
   to run a divider through the middle of that space (border-top on every
   link but the first) without it crowding either option's text. */
.nav-drop-panel{ min-width:180px; padding:var(--space-2) var(--space-5);
  background:var(--scrim-82); backdrop-filter:blur(7px);
  border:var(--hair) solid var(--color-border-subtle);
  display:flex; flex-direction:column;
  transform:translateY(-4px); transition:transform var(--dur-fast) var(--ease-out); }
.nav-item:hover .nav-drop-panel, .nav-item:focus-within .nav-drop-panel{
  transform:translateY(0); }
/* flex + align-items:center rather than relying on padding-block alone —
   at this size a tight, flat line-height (.t-label's own default) leaves
   a glyph's ink sitting slightly off-centre in its own line box, so equal
   padding above/below the box still reads uneven around it. Centering the
   line box itself in a fixed-height flex row keeps Viper and Nexus's text
   visually centred regardless of that. */
.nav-drop a{ color:var(--color-text-muted); padding-block:var(--space-3);
  display:flex; align-items:center; min-height:1.4em; }
@media (max-width:1023px){
  /* Same clearance as the top-level rows above, for the same reason: the
     active bar on a dropdown child (Viper/Nexus) would otherwise overlap
     the first letter. Scoped to .active (only the current page needs the
     room) and to three classes deep so it out-specificities
     `.nav .nav-drop a + a`'s `padding-inline:0` above (2 classes + 2
     elements) — a plain `.nav-drop-panel a` or even `.nav .nav-drop-panel a`
     both still lose to that rule, which is why Nexus (the second link, the
     one `+ a` matches) kept the bug after Viper (the first link, which
     `+ a` never matches) looked fixed. */
  .nav .nav-drop-panel a.active{ padding-left:var(--space-3); }
}
.nav-drop a:hover, .nav-drop a:focus-visible{ color:var(--color-accent); }
/* the divider needs to span the panel's full box, not just the link's own
   text column — negative margin-inline bleeds it out to the panel's edges,
   padding-inline of the same size pulls the link's own text right back to
   where it always sat, so only the border-top line reaches edge-to-edge.
   Same var(--hair) 1px weight every other hairline on the site uses, and
   the same neutral --color-border-subtle every other hairline divider on
   the site draws in (per direct request, in place of the accent yellow
   this shipped with first — a plain grey line reads as ordinary menu
   structure rather than competing with the accent's other, meaningful
   uses elsewhere on the page). */
/* padding-top trimmed by the border's own width: border-top adds a pixel
   above this link that padding-bottom has no matching pixel for below it,
   so equal padding-block left the text sitting that pixel low in its own
   row. Shaving it back off padding-top (not adding it to padding-bottom)
   keeps the row's total height matched to every other link's. */
.nav-drop a + a{ border-top:var(--hair) solid var(--color-border-subtle);
  margin-inline:calc(var(--space-5) * -1); padding-inline:var(--space-5);
  padding-top:calc(var(--space-3) - var(--hair)); }


/* ── PILL ──────────────────────────────────────────────────────────────────
   The padding fix. A pill previously inherited body line-height (1.55),
   which injected ~5px of invisible leading into the vertical box: declared
   8/16 padding (2:1) rendered as 13.1/18.4 optical — 1.40:1 — and the
   bottom ran heavier than the top because uppercase sits high in a line box.

   Three things make the padding mean what it says:
     line-height:var(--lh-flat)     no leading in the box at all
     inline-flex + align-items      vertical centering independent of metrics
     text-box:trim-both cap alphabetic   trims the box to cap height and
                                        baseline, so padding is truly optical
   Target optical ratio is ~1.8:1 horizontal to vertical.
   ───────────────────────────────────────────────────────────────────────── */
.pill{
  font-family:var(--font-display); font-weight:500; font-size:var(--ui-xs);
  line-height:var(--lh-flat); letter-spacing:var(--track-label);
  text-transform:uppercase; white-space:nowrap;
  display:inline-flex; align-items:center;
  text-box:trim-both cap alphabetic;
  padding:var(--space-3) var(--space-5);
  /* letter-spacing adds a trailing space AFTER the last character, so a
     symmetric padding value renders ~1px heavier on the right. Subtract the
     tracking back out. Applies to any tracked uppercase label in a box. */
  padding-right:calc(var(--space-5) - var(--track-label));
  border:var(--hair) solid var(--color-pill-border);
  color:var(--color-text);
}
.pill-row{ display:flex; flex-wrap:wrap; gap:var(--space-3); }


/* ── TEXT INPUT ────────────────────────────────────────────────────────────
   Moved here from contact.css once glossary.html needed the same base input
   styling for its search field — contact.html's field/field-row/field-check
   layout (the multi-column form grid, the checkbox) stays page-specific
   since nothing else on the site uses that shape, but the input itself is
   the site's only text-entry primitive and belongs wherever a second page
   can reach it, same reuse test as everything else in this file. */
.field-input{
  font-family:var(--font-body); font-size:var(--ui-md); color:var(--color-text);
  background:var(--color-surface); border:var(--hair) solid var(--color-border);
  padding:var(--space-4); width:100%;
}
.field-input::placeholder{ color:var(--color-text-muted); }
.field-input:focus{ outline:2px solid var(--color-accent); outline-offset:-2px; border-color:var(--color-accent); }
textarea.field-input{ resize:vertical; min-height:120px; font-family:inherit; }


/* ── TABS ──────────────────────────────────────────────────────────────────
   A row of labels with one active, plus the panels they switch between.
   Moved here from home.css when viper.html needed the same click-to-switch
   mechanism for its "What Makes It Different" tabs. This base still carries
   the underline mechanics (.tab-ind, motion.css) for anything that doesn't
   opt into .pic-tabs below, but every tab row on the site now DOES opt into
   .pic-tabs — see that block for why the box-with-yellow-border look is the
   one standard, not two competing tab styles.

   home.css layers homepage-only extensions on TOP of this base: sticky
   positioning for the long, video-backed Operates section, and a grid that
   pairs a scene with its copy — this base assumes neither. */
.tabs{ display:flex; flex-wrap:wrap; gap:var(--space-7);
  border-bottom:var(--hair) solid var(--color-border-subtle); }
.tab{ background:transparent; border:0; color:var(--color-text-muted);
  display:flex; align-items:baseline; gap:var(--space-3);
  padding:0 0 var(--space-4); margin-bottom:calc(var(--hair) * -1);
  transition:color var(--dur-fast) var(--ease-out); white-space:nowrap; }
.tab .tab-n{ font-size:var(--ui-xs); color:var(--color-text-muted);
  transition:color var(--dur-fast) var(--ease-out); }
.tab .tab-t{ letter-spacing:var(--track-label); }
.tab:hover{ color:var(--color-text); }
.tab.active{ color:var(--color-text); }
.tab.active .tab-n{ color:var(--color-accent); }
.tab:focus-visible{ outline:2px solid var(--color-accent); outline-offset:4px; }

.tab-panels{ margin-top:var(--section-y-sm); }
.tab-panel{ display:none; }
.tab-panel.active{ display:block; }


/* ── PICTURE TABS: swipe tabs with a picture per panel ──────────────────────
   Moved here from viper.css (renamed from .diff-tabs/.diff-panels/.diff-copy/
   .diff-pic) once nexus.html needed the same "click through claims, each one
   pairs with a picture on the right" pattern for its own Outcome section.

   The active state is a 2px accent underline under the label, drawn as the
   tab's own border-bottom rather than tabs.js's sliding .tab-ind element —
   deliberately. .tab-ind works by scaleX()-stretching a 1px base element,
   which is fine for a solid fill (a differently-sized flat color looks
   identical either way) but distorts anything with its own fixed
   thickness: a 2px line painted pre-scale renders at 2px × scaleX
   post-scale. That's a correctness issue, not a style call, so it's turned
   off here (.tab-ind{display:none}) and each tab instead carries its own
   real border-bottom, toggled by the .active class tab-panels.js already
   sets on click — no JS changes needed. A transparent border-bottom is
   reserved on every tab up front so activating one never shifts layout.

   This used to be a plain bordered box (all four sides, plus a filled
   background) rather than just the bottom edge — changed to an
   underline-only mark so a tab's active state reads the same way .tabs'
   own sliding indicator does elsewhere on the site, just without the
   slide. No clip-path either way: the chamfer idiom (.btn, .card-notch) is
   a diagonal cut on the upper-right corner, and it's explicitly not
   wanted on these tabs.

   Mixed case, not caps: some panels are full phrases ("Connected Through
   the Mission"), where caps would fight readability, so the base style is
   mixed case throughout — including the homepage's Operates tabs (Plan,
   Task, Fuse…), which used to run their own all-caps underline style
   before every tab row on the site was standardised onto this one look.
   The active mark is --color-accent, so it reads as a clear yellow
   underline everywhere a tab appears.

   Each panel pairs its claim with a picture on the right, the same
   text-left/media-right idea as home.css's .tab-copy + .scene pairing for
   the Operates tabs — a separate block instead of reusing that one, since
   the Operates version is wired to specific 3fr/9fr video-scene
   proportions this pattern has no use for. 5fr/7fr here instead: the copy
   per tab is a full title + sentence, not a short caption, so it earns
   more than a sliver before the picture takes the rest.

   No image asset exists for any panel yet, so .pic-media renders as the
   same flat placeholder-box convention already used elsewhere on this site
   for unshot media — drop an <img> inside it once footage/renders are
   ready; nothing else needs to change.
   ───────────────────────────────────────────────────────────────────────── */
.pic-tabs{ margin-top:var(--section-y-sm); gap:var(--space-6); }
.pic-tabs .tab-ind{ display:none; }
.pic-tabs .tab{
  font-family:var(--font-display); font-weight:500; font-size:var(--ui-md);
  letter-spacing:normal; text-transform:none;
  padding:var(--space-4) var(--space-5) calc(var(--space-4) - 3px); margin-bottom:0;
  /* 3px, not 2px: thickened per direct feedback (see .active below) — the
     reservation has to match whatever the active state actually draws, or
     activating a tab shifts the row by the difference. */
  border:0; border-bottom:3px solid transparent;
  position:relative;
  transition:color var(--dur-fast) var(--ease-out);
}
/* ── THE UNDERLINE IS A PROGRESS BAR ───────────────────────────────────────
   Drawn as a scalable ::after rather than the border-bottom-color it used to
   be, because a colour cannot be partially filled and this has to report how
   far through the active scene the scroll is. Per direct request: inside the
   pinned Operate Stage a slow scroll produced no visible change until it
   crossed a whole segment boundary, so the section read as stalled — as if
   scrolling had stopped working. The bar fills continuously instead, so the
   input always visibly registers, and it also previews that something is
   about to happen when it approaches full.

   --tab-progress is written on the tab BAR by pin-tabs.js and inherits down
   to whichever tab is active. It DEFAULTS TO 1, which is what keeps every
   other tab row on the site unchanged: below 1024px, under reduced motion,
   with GSAP absent, and on VIPER/NEXUS's click-only rows, nothing sets the
   property and the bar draws full — identical to the solid underline this
   replaces. One mechanism, not a pinned special case.

   bottom:-3px, not 0: an absolutely positioned child is offset from the
   padding box, and the 3px reservation above is a BORDER, which sits outside
   it. -3px is what puts the fill exactly where the border is, so the
   reservation and the drawn line still agree on position as well as width.

   The transition is on the inactive->active retract only. While pinned the
   active bar is rewritten every frame, and a transition there would make it
   chase the scroll a beat behind rather than track it — see the [data-motion]
   rule below. */
.pic-tabs .tab::after{
  content:""; position:absolute; left:0; right:0; bottom:-3px; height:3px;
  background:var(--color-accent);
  transform:scaleX(0); transform-origin:left center;
  transition:transform var(--dur-fast) var(--ease-out);
}
.pic-tabs .tab.active::after{ transform:scaleX(var(--tab-progress,1)); }
[data-motion="pinned"] .pic-tabs .tab.active::after{ transition:none; }
.pic-tabs .tab.active{
  /* Per direct feedback: the active state read as too quiet to tell which
     scene was actually showing, especially on wide viewports where the
     tab row and the scene it labels can sit a long way apart. Bolding the
     label leans on the accent colour already doing that job, rather than
     adding a background block the row was deliberately kept without (see
     "PICTURE TABS" above); the underline itself was thickened on the base
     rule above instead of here, so the reserved space and the active
     state never disagree on width. */
  font-weight:700;
}

.pic-panels .tab-panel.active{ display:grid; grid-template-columns:minmax(0,1fr); gap:var(--space-7); }
@media (min-width:1024px){
  .pic-panels .tab-panel.active{ grid-template-columns:minmax(0,5fr) minmax(0,7fr);
    column-gap:var(--gutter-lg); align-items:center; }
}
.pic-copy{ display:flex; flex-direction:column; gap:var(--space-4); }
.pic-copy h3{ margin-bottom:0; }
.pic-copy .t-lead{ max-width:var(--measure-lead); }
.pic-media{ position:relative; aspect-ratio:16/10; overflow:hidden;
  border:var(--hair) solid var(--color-border-subtle); background:var(--color-surface);
  display:grid; place-items:center; }
.pic-media img{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
/* Same fill as img above, now that real footage exists for some panels
   (VIPER's Operating Model) — a .pic-media can hold either, this rule
   doesn't care which. */
.pic-media video{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }
/* Below 1024px the two-column split above is already a single column, so
   the image/video added height without adding width — on a narrow screen
   that read as a lot of scroll per tab for very little information. Copy
   alone is the content; the media is illustration. */
@media (max-width:1023px){ .pic-media{ display:none; } }


/* ── OPERATE STAGE: click-through tabs with a scene per panel ────────────
   Moved here from home.css (was "OPERATES: PIN LAYOUT" and its
   surrounding rules, #operates-scoped) when NEXUS's Operational Loop
   needed the exact same tabs+video pattern — same reuse test that moved
   .pic-tabs and .cta-band here before it. Every selector below is
   rewritten from the old #operates ID down to the shared .operate-stage
   wrapper class (and the bare [data-motion="pinned"] attribute, the same
   way the shared decision-grid rules above key off it) so any section
   that drops in this markup gets the identical look and pin behaviour —
   see pin-tabs.js, which drives every .operate-stage on the page the same
   way decision-scroll.js drives every .decision-grid.

   .tabs/.tab base + .tab-panels/.tab-panel themselves already live
   further up this file (see "TABS"); what's here is specific to the
   scene-plus-copy pairing and its pin, not the tab row itself.

   Quieter than a filled block: a hairline row, an index number, and a 2px
   accent rule that slides to the active tab (that part's the shared
   .pic-tabs rule above). 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. Scoped to .operate-stage .tabs, not a
   bare .tabs, since other tab rows on the site (NEXUS's own Operating
   Model, VIPER's) sit in a single-row layout that doesn't want to stick.

   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. */
.operate-stage .tabs{ margin-top:var(--section-y-sm);
  position:sticky; top:calc(var(--space-8) + var(--space-4)); z-index:5;
  background:var(--color-bg); }

/* Left-right, 3/9. The copy takes a narrow column, centered vertically against
   the scene (align-self:center on .tab-copy, below) rather than sitting on its
   baseline — per direct request. At 9/12 the scene lands near 1030px, close
   enough to its authored 1600 that its own readouts still hold together. */
.operate-stage .tab-panel.active{ display:grid; grid-template-columns:minmax(0,1fr);
  gap:var(--space-7); }
.operate-stage .tab-copy{ display:flex; flex-direction:column; gap:var(--space-4); }
/* No size bump any more: this used to force h3/.t-lead two sizes up
   per earlier direct feedback ("copy too small"), but a later direct
   request standardised Home's Operate stage and NEXUS's Loop onto the
   same smaller heading/lead VIPER's Operating Model (.pic-copy) uses,
   so both now fall through to their plain .t-h3/.t-lead base sizes
   (base.css) with no override here at all. */
/* This section's copy+video layout used to have two versions: a plain
   grid fallback here, and a taller, height-locked flex layout further
   down gated behind [data-motion="pinned"] (set only once pin-tabs.js
   had actually locked the section via ScrollTrigger). They're merged
   into one unconditional layout now (see "OPERATE STAGE: PIN LAYOUT"
   below) — the grid fallback let a longer paragraph or the video's own
   aspect-ratio push the whole block taller than one screen whenever the
   pin hadn't engaged yet, which read as "the text/video gets cut off, I
   have to scroll" — per direct report. */

/* ── THE SCENE ────────────────────────────────────────────────────────────
   A rendered video (or, for a step with no footage yet — NEXUS's own
   Observe — an empty bordered box, .scene left with nothing inside it
   rather than a broken source or borrowed footage), not a drawn diagram.
   Two reasons: the look can depend on raster effects vector hairlines
   can't produce (bloom, atmospheric depth, a hillshade pass), and a
   rendered loop needs no animation code, so there is no JS dependency, no
   blank-panel risk, and nothing exotic for anyone to inherit.

   The video carries no words. Every label in the panel is real HTML text, so
   it stays crisp at any resolution, editable without a re-render, translatable
   and readable by a screen reader. */
.scene{ position:relative; margin:0; aspect-ratio:16/10; overflow:hidden;
  border:var(--hair) solid var(--color-border-subtle);
  background:var(--black); }
.scene-v, .scene > img{ display:block; width:100%; height:100%;
  object-fit:cover; }
/* a scene still in production shows the same stage, unmarked */
.scene-still > img{ opacity:.72; }

/* ── THE SWITCH ───────────────────────────────────────────────────────────
   Changing tabs used to be a hard display swap. With scenes that can share a
   plate, a camera and a palette, that read as a flicker rather than a change —
   the eye was never told to look.

   Two cues, and no JavaScript for either: a panel goes display:none -> grid
   when it activates, and that alone restarts a CSS animation. So the switch
   animates with the motion layer dead, and it cannot desync from the tab state
   because it IS the tab state.

   Deliberately NOT a crossfade: dissolving between two near-identical
   plates genuinely does look like nothing happened. The accent-yellow scan
   line that used to sweep across the scene on every switch (a ::after bar
   animating left 0%->100%) was removed per direct request — it read as a
   distracting flash rather than a cue, on both this section and NEXUS's
   Operational Loop (same shared rule, since both use .operate-stage).

   A fade-through-black overlay was tried here per direct request and
   reverted the same day: a ::before veil over .scene, opacity 1->0 on
   activate. It shipped with a real bug — every panel except the one
   already active on page load ended up permanently black, not just
   Plan's — and rather than re-diagnose live on the production wipe, the
   clip-path wipe below is restored to the known-working version until a
   fade-based replacement is built and actually verified before pushing. */
@keyframes scene-reveal{ from{ clip-path:inset(0 100% 0 0); } to{ clip-path:inset(0); } }
@keyframes copy-in{ from{ opacity:0; transform:translateY(10px); } to{ opacity:1; transform:none; } }
/* THIRD CUE, added per direct request: the switch was still not apparent
   enough. The ask offered either "a light flash over the content" or a
   fade — the flash is the option this section already tried and lost: the
   accent scan line noted above was removed as distracting, and re-adding a
   bright wash would spend an accent moment (guidelines rule 6) on the one
   viewport that already spends it on the tab underline. So: the fade.

   It is a fade-IN of the arriving panel, not a crossfade. Two reasons —
   .tab-panel toggles display:none/grid, so the outgoing panel is gone
   before the incoming one exists and there is nothing to cross-dissolve
   against; and the note above already records that dissolving between two
   near-identical plates looks like nothing happened. Coming up from zero
   does not have that problem: the panel is unambiguously absent, then
   present.

   --dur-fast (180ms), well short of the 1000ms wipe it layers over: the
   fade's job is to mark the instant of the change, and the wipe's is to
   carry the eye across the new scene. Keeping it short also keeps it from
   strobing when the scroll is fast enough to switch scenes back to back —
   the anti-strobe floor in pin-tabs.js is 140ms, so consecutive fades
   abut rather than overlap. */
@keyframes panel-in{ from{ opacity:0; } to{ opacity:1; } }

/* --dur-scene (1000ms), not --dur-ui or --dur-enter: 240ms read as an
   unclear flicker, and even --dur-enter's 600ms was still called "really
   fast" for a wipe crossing a full-width scene — this needed its own,
   slower token (tokens.css) rather than stretching --dur-enter itself,
   which several unrelated reveals elsewhere on the site still want at
   600ms. --ease-in-out, not --ease-out, to match: tokens.css reserves
   ease-in-out specifically for "moving on screen", which is exactly what
   a left-to-right wipe is, rather than the enter/exit curve every other
   reveal on the site uses. */
.operate-stage .tab-panel.active{ animation:panel-in var(--dur-fast) var(--ease-out); }
.operate-stage .tab-panel.active .scene{ animation:scene-reveal var(--dur-scene) var(--ease-in-out); }
/* the copy is the part people actually read, so it gets its own arrival —
   staggered behind the scene so the two cues do not land on top of each other */
.operate-stage .tab-panel.active .tab-copy > *{ animation:copy-in var(--dur-enter) var(--ease-out) backwards; }
.operate-stage .tab-panel.active .tab-copy > *:nth-child(1){ animation-delay:var(--dur-press); }
.operate-stage .tab-panel.active .tab-copy > *:nth-child(2){ animation-delay:var(--dur-ui); }

@media (prefers-reduced-motion:reduce){
  .operate-stage .tab-panel.active,
  .operate-stage .tab-panel.active .scene,
  .operate-stage .tab-panel.active .tab-copy > *{ animation:none; }
}

/* MOBILE. This is the real cost of baking the scene into video: its labels are
   pixels, so they cannot scale down. At 16/10 on a 390px screen the frame is
   214px tall and nothing in it is readable. Interim treatment — go taller and
   crop into the port so the objective/subject geometry at least reads as
   something specific. The proper fix is a second render authored for this
   size, portrait, with type sized for it. */
@media (max-width:1023px){ .scene{ aspect-ratio:16/11; } }
@media (max-width:639px){
  .scene{ aspect-ratio:4/5; }
  .scene-v, .scene > img{ object-position:24% 60%; }
}

/* ── MOBILE: NO TAB ROWS, EVERYTHING STACKED ───────────────────────────────
   Per direct request: "on all tab like sections across all pages, get rid of
   the tab like structure and place it one below another — or suggest if you
   have a better idea."

   The suggestion asked for, and why stacking is the right call here rather
   than a compromise: a horizontal tab row is the wrong control on a phone
   regardless of styling. VIPER's Operating Model carries EIGHT tabs and
   Home's Operate Stage six, so most labels sit off-screen behind a sideways
   scroll nobody is told about, and the pinned scroll-walk that makes the
   Operate Stage legible does not run below 1024px at all — so on a phone the
   tabs were a row of hidden options wrapping an already-static panel.

   Two things make stacking cheap rather than reckless, and both were checked
   rather than assumed:

   · EVERY PANEL ALREADY CARRIES ITS OWN <h3> with the same text as its tab.
     So hiding the tab row loses no label — the stack reads as headed
     sections, which is what it now is. Nothing was added to the markup.

   · EVERY VIDEO BUT THE FIRST IS preload="none". Stacking therefore
     downloads no extra video; each panel paints its WebP poster and nothing
     more. What it does not do on its own is ever play them, which is what
     scene-in-view.js is for — one clip at a time, the one you are looking
     at. Six autoplaying videos stacked on a phone was the outcome worth
     avoiding, and this avoids it without hiding content behind a control.

   The alternative considered and rejected: turning each tab row into a
   mobile accordion. It keeps the page short, but it hides every panel behind
   a tap on a page whose whole job is to show what the product does — and it
   would have been a second, parallel disclosure pattern to maintain beside
   the one #define already has. */
@media (max-width:1023px){
  /* UNIVERSAL, not scoped per section. components.css already records that
     "every tab row on the site now DOES opt into .pic-tabs", so this one
     selector is every tab row there is — and a fifth one added later is
     covered without anyone remembering to come back here. A scoped version
     of this rule missed #edge-authority on the first pass, whose row is the
     only .pic-tabs not inside an .operate-stage. */
  .tabs.pic-tabs{ display:none; }
  /* VIPER's tab row is WRAPPED (.pic-tabs-row, which also holds the prev/next
     pair) and hiding the wrapper has to happen in viper.css, not here:
     viper.css sets display:flex on the same #orchestration .pic-tabs-row
     selector, identical specificity, and loads after this file — so a copy
     of the rule here loses the cascade and left an empty row with its
     controls still in it. See viper.css's own max-width block. */

  .tab-panels > .tab-panel{ display:block; }
  .tab-panels > .tab-panel + .tab-panel{ margin-top:var(--section-y-sm);
    padding-top:var(--section-y-sm); border-top:var(--hair) solid var(--color-border-subtle); }

  /* The switch cues are for a switch. Nothing switches in a stack, and a
     panel that animates its own arrival here would just be a second reveal
     fighting .reveal's. */
  .operate-stage .tab-panel.active,
  .operate-stage .tab-panel.active .scene,
  .operate-stage .tab-panel.active .tab-copy > *{ animation:none; }

  /* #define's accordion, per the same request — "just get rid of accordion
     and keep the tabs one below another with video". Every panel is open and
     the heads stop being buttons in appearance; the media card stays where
     it is in the flow and scene-in-view.js drives which clip it shows from
     whichever point is on screen, so the video still follows the reading
     position without a control to operate. */
  [data-decision-mode="accordion"] .decision-point-panel{ grid-template-rows:1fr; }
  [data-decision-mode="accordion"] .decision-point-head{ cursor:default; }
  [data-decision-mode="accordion"] .decision-point-caret{ display:none; }
}

/* ── OPERATE STAGE: PIN LAYOUT ────────────────────────────────────────────
   Unconditional at >=1024px now, no longer gated on data-motion="pinned"
   (still set by ScrollTrigger.matchMedia in pin-tabs.js, just no longer
   load-bearing for THIS layout — pin-tabs.js still drives the actual
   scroll-lock/tab-walk behaviour, this block just no longer waits for it
   to size the box). A failed CDN, GSAP missing, or prefers-reduced-motion
   used to fall back to a shorter grid layout that could end up taller
   than one screen; now the height-locked box applies regardless, so the
   one-screen guarantee never depends on the pin actually engaging.

   Keeps the locked proportions rather than reinventing them: copy fixed at
   25% (the 3-of-12 the grid used), scene takes the rest, edge to edge — no
   aspect-ratio override, so it fills 100% of both the remaining width and
   the full height and just gets cropped by the object-fit:cover already on
   .scene-v/img, the same mechanism the mobile/tablet breakpoints already
   use to recrop this exact video into a different box shape. align-self on
   .tab-copy centers vertically against the scene (align-self:center),
   matching the rest of the layout — per direct request, replacing the
   baseline-anchored bottom alignment this used to have. */
@media (min-width:1024px){
  /* Unconditional now, not gated behind [data-motion="pinned"] any more:
     this used to apply only once pin-tabs.js had actually locked the
     section (ScrollTrigger pin engaged), with a separate, shorter grid
     layout as the fallback the rest of the time. But that fallback let
     the video's aspect-ratio + a longer paragraph push the whole block
     taller than one screen whenever the pin hadn't engaged yet (or never
     does — JS not ready, blocked, or simply a moment before the scroll
     trigger fires), which read as "the text/video gets cut off, I have to
     scroll" — per direct report. Making this the ONE layout at this
     breakpoint means the one-screen guarantee is pure CSS: .operate-stage
     is always exactly 100svh tall with overflow hidden, independent of
     whatever pin-tabs.js does. The eyebrow/heading/lead above it still
     scrolls normally, same as before — only the stage itself (tabs +
     panel) is height-locked. A closing note after the panels (NEXUS's own
     .loop-note) is free to ride along as a further flex child — flex:none
     keeps it from stretching to fill the space .tab-panels gives up for it. */
  .operate-stage{
    position:sticky; top:0; height:100svh; overflow:hidden; padding-block:var(--space-8);
    display:flex; flex-direction:column; justify-content:center;
  }
  /* margin-top mirrors .tab-panels' own margin-top above (both
     var(--section-y-sm)) so the video reads with equal breathing room on
     both sides instead of the note hugging its bottom edge — per direct
     request, comparing this section's balance against Home's Operate
     stage (which has no trailing element, so its video already sits
     centered between the tabs above and the padding below). */
  .operate-stage > .loop-note{ flex:none; margin-top:var(--section-y-sm); }
  /* position:relative, not static — .tab-ind is absolutely positioned against
     .tabs as its containing block; static would let it escape and land
     against whatever positioned ancestor is next, which is the bug that put
     it at the bottom of the stage instead of under the tab row. relative
     still cancels the sticky-tabs fix from the non-pinned layout. */
  /* top:auto is REQUIRED, not decorative: the base .tabs rule above is
     position:sticky with top:calc(--space-8 + --space-4) = 80px. Switching to
     relative here does not clear that offset — relative honours `top` too, so
     the bar was being pushed 80px down out of its own layout slot (which is
     what put an 80px gap above it and made it overlap the panel below). */
  .operate-stage .tabs{
    flex:none; position:relative; top:auto; margin-top:0;
  }
  .operate-stage .tab-panels{ flex:1 1 auto; min-height:0; }
  .operate-stage .tab-panel.active{
    height:100%; display:flex; align-items:stretch; gap:var(--gutter-lg);
  }
  /* The scene keeps its AUTHORED 16/10 here, driven from the locked height —
     it must not be cropped. A frame that carries its own header/status
     readout hard against its left and right edges loses text to
     object-fit:cover eating into either side if the box floats to whatever
     ratio the row leaves over, so the box has to match the frame instead.
     The copy column then absorbs whatever width is left instead of a gap
     opening up — so the row is still edge to edge, and the leftover space is
     content rather than a hole. min-width keeps it from collapsing on a short,
     wide viewport where the scene wants nearly the whole row. align-self:
     center — not flex-end — per direct request: the copy used to sit on the
     scene's baseline, now it centers against it vertically instead. */
  .operate-stage .tab-copy{
    flex:1 1 0; min-width:200px; align-self:center;
  }
  .operate-stage .scene{
    flex:0 0 auto; width:auto; height:100%; aspect-ratio:16/10;
  }
}


/* ── CTA BAND ──────────────────────────────────────────────────────────────
   Moved here from home.css when viper.html needed the same full-bleed,
   media-backed closing band for its own CTA. position:relative is load-
   bearing, not decoration: it is what makes .media's position:absolute;
   inset:0 resolve against THIS section instead of the initial containing
   block — without it the media fills the whole document from the top of
   the page, not just this band. */
.cta-band{ position:relative; overflow:hidden; text-align:center; }
.cta-inner{ position:relative; z-index:2; display:flex; flex-direction:column;
  align-items:center; gap:var(--space-6); }
.cta-inner h2{ max-width:28ch; }
.cta-inner .t-lead{ max-width:var(--measure-lead); }
.cta-band > .media{ z-index:0; }
.cta-band .cta-inner{ z-index:1; }


/* ── METRICS STRIP ─────────────────────────────────────────────────────────
   Four tiles in one row under an eyebrow, shared by index.html, viper.html
   and nexus.html (SECTION-TYPES.md, type 8): change it here, not per page.
   Each tile: a caps label top-left, its index top-right, then the figure
   and a supporting line pushed to the bottom. Tiles take the SECTION's own
   ground (--color-bg) and ink, so they are black on a dark section and white
   on a light one with no per-page override. On hover they fill with accent
   and the ink flips to black. Tiles do not carry a .surface-* class, so the
   derived muted ink is NOT re-declared on them — hover sets it explicitly.
   Accent is only ever a fill here, never text on a light ground (rule 11).
   The hairlines between tiles are the grid's own background showing
   through a --hair gap, so the grid takes the section's --color-border.
   Two-up below 1024, four-up from there. */
.metrics-grid{ list-style:none; margin:var(--space-7) 0 0; padding:0;
  display:grid; grid-template-columns:repeat(2,minmax(0,1fr));
  gap:var(--hair); background:var(--color-border);
  border:var(--hair) solid var(--color-border); }
.metric{ display:flex; flex-direction:column;
  min-height:calc(var(--space-10) * 2);
  padding:var(--space-5) var(--space-6) var(--space-6);
  background:var(--color-bg);
  transition:background-color var(--dur-fast) var(--ease-out),
             color var(--dur-fast) var(--ease-out); }
.metric-top{ display:flex; justify-content:space-between; gap:var(--space-3); }
.metric-n{ margin-top:auto; padding-top:var(--space-8); }
.metric-cap{ margin-top:var(--space-3); font-size:var(--ui-md); line-height:var(--lh-body);
  color:var(--color-text-muted); }
@media (hover:hover){
  .metric:hover{ color:var(--black); background:var(--color-accent);
    --color-text-muted:rgba(var(--rgb-black),.60); }
}
@media (max-width:639px){
  .metric{ padding:var(--space-4); min-height:calc(var(--space-10) + var(--space-8)); }
}
@media (min-width:1024px){
  .metrics-grid{ grid-template-columns:repeat(4,minmax(0,1fr)); }
}
@media (prefers-reduced-motion:reduce){
  .metric{ transition:none; }
}
