/* ============================================================================
   SPY EYE — v3 "SOFT MESH"
   Near-black canvas, large rounded glass panels, blurred gradient mesh bleeding
   in from the corners, floating pill labels, orbital dials.
   Brand green is the light source rather than UI paint.
   ============================================================================ */

:root{
  --green:      #004f12;
  --green-mid:  #0a7a22;
  --lime:       #7cda24;
  --lime-soft:  rgba(124,218,36,.16);
  --lime-ink:   #08170a;

  --bg:         #070807;
  --bg-2:       #0b0d0b;
  --panel:      rgba(255,255,255,.028);
  --panel-2:    rgba(255,255,255,.05);
  --line:       rgba(255,255,255,.075);
  --line-2:     rgba(255,255,255,.14);

  --ink:        #f0f2ee;
  --ink-2:      #9ea69a;
  --ink-3:      #626a60;

  --f-display: "Outfit", "Segoe UI", sans-serif;
  --f-body:    "Manrope", "Segoe UI", sans-serif;

  --s--2: clamp(.66rem,.63rem + .12vw,.72rem);
  --s--1: clamp(.79rem,.76rem + .14vw,.86rem);
  --s-0:  clamp(.96rem,.93rem + .18vw,1.04rem);
  --s-1:  clamp(1.1rem,1rem + .5vw,1.35rem);
  --s-2:  clamp(1.5rem,1.2rem + 1.5vw,2.4rem);
  --s-3:  clamp(2rem,1.4rem + 2.9vw,3.6rem);
  --s-4:  clamp(2.4rem,1.5rem + 4.6vw,5rem);

  --pad:  clamp(1rem,.6rem + 2.4vw,3.4rem);
  --maxw: 1280px;
  --ease: cubic-bezier(.16,1,.3,1);
  --r:    26px;   /* the big soft radius that defines this look */
  --r-sm: 16px;

  /* The nav is fixed, so everything that has to stop underneath it needs to
     know how tall it is - and it is not one height. It grows when the links
     wrap, when the browser's font size is larger, and when the webfont swaps
     in after first paint. site.js measures the real element and overwrites
     --nav-h; the value here is only what applies before that runs.
     --stick is that height plus the nav's own top offset (.85rem) plus a
     breathing gap, and it is the single number used by both the sticky filter
     bar and by scroll-padding for every in-page anchor, so the two can never
     disagree about where "below the nav" is. */
  --nav-h: 56px;
  --stick: calc(var(--nav-h) + 1.55rem);
}

/* The whole site is dark, and the browser needs telling.
   Without this, `color-scheme` is `normal`, so Chrome draws native UI - the
   <select> dropdown, scrollbars, date pickers - on a WHITE background. The
   options inherit the select's near-white text colour, which is right for the
   closed control sitting on a dark panel and invisible in a white popup. The
   result looked like every option except the first had been disabled: they were
   white-on-white, the highlighted row was readable only because it inverts, and
   the genuinely disabled "Choose one..." was readable only because the browser
   overrides its colour with a grey dark enough to show.
   One line fixes the popup, the scrollbars and every other piece of native
   chrome at once. The explicit <option> rules further down are the fallback for
   engines that ignore it. */
:root{ color-scheme:dark }

*,*::before,*::after{ box-sizing:border-box }
/* Every in-page jump - the nav submenu, "See what we have built", the
   category tiles, a #filter deep link - used to land with its target hidden
   behind the fixed nav. scroll-padding-top on the scroll container fixes all
   of them at once, including the smooth-scrolled ones. */
html{ scroll-behavior:smooth; -webkit-text-size-adjust:100%;
      scroll-padding-top:var(--stick) }
body{
  margin:0; background:var(--bg); color:var(--ink);
  font-family:var(--f-body); font-size:var(--s-0); line-height:1.6;
  overflow-x:hidden; -webkit-font-smoothing:antialiased;
}
img,video{ display:block; max-width:100%; height:auto }
a{ color:inherit; text-decoration:none }
h1,h2,h3,h4{ font-family:var(--f-display); font-weight:300; line-height:1.08; letter-spacing:-.025em; margin:0 }
/* <em> in a heading means "accent word", never italic. Scoped rules existed
   only for .sec__head / .hero, so headings elsewhere fell back to italic. */
h1 em,h2 em,h3 em,h4 em{ font-style:normal; color:var(--lime) }
strong,b{ font-weight:500; color:var(--ink) }
.wrap{ max-width:var(--maxw); margin-inline:auto; padding-inline:var(--pad) }

/* ================================================================= mesh glow */
/* The signature: soft out-of-focus colour bleeding in from a corner.
   Sits behind everything, never sharp, never a solid fill. */
.mesh{ position:absolute; inset:0; overflow:hidden; pointer-events:none; z-index:0; border-radius:inherit }
.mesh::before,.mesh::after{ content:""; position:absolute; border-radius:50%; filter:blur(70px) }
/* Cores sit just INSIDE the panel edge — pushed further out they get clipped
   by overflow:hidden and the glow disappears entirely on a dark canvas. */
.mesh::before{
  width:52%; aspect-ratio:1; right:-10%; top:-22%;
  background:radial-gradient(circle,
    rgba(180,255,110,.22) 0%,
    rgba(124,218,36,.14) 32%,
    rgba(10,122,34,.07) 54%,
    transparent 72%);
}
.mesh::after{
  width:44%; aspect-ratio:1; left:-10%; bottom:-24%;
  background:radial-gradient(circle,rgba(0,120,28,.20),rgba(0,79,18,.07) 45%,transparent 72%);
}
.mesh--soft::before{ background:radial-gradient(circle,rgba(124,218,36,.12),transparent 70%) }
.mesh--soft::after{ background:radial-gradient(circle,rgba(0,79,18,.14),transparent 70%) }
.mesh--center::before{ right:auto; left:50%; translate:-50% 0; top:-42%; width:95% }

/* ================================================================= atoms */
.pill{
  display:inline-flex; align-items:center; gap:.5rem;
  border:1px solid var(--line); border-radius:999px;
  background:rgba(255,255,255,.04); backdrop-filter:blur(12px);
  padding:.4rem .9rem; font-size:var(--s--1); color:var(--ink-2);
  font-family:var(--f-body); font-weight:500;
}
.pill::before{ content:""; width:6px; height:6px; border-radius:50%; background:var(--lime); box-shadow:0 0 9px var(--lime); flex:none }
.pill--plain::before{ display:none }

/* --ink-3 (#626a60) measures 3.58:1 on the page background, under the 4.5:1
   floor, and this class is 11px 600-weight so it gets no large-text carve-out.
   It is the eyebrow above almost every section heading on the site, so it was
   the most repeated contrast failure here. --ink-2 measures 8.0:1.
   The token itself is unchanged: --ink-3 is still correct for what it is for -
   de-emphasised supporting text at normal size - and moving it would flatten
   the whole type hierarchy to fix one class that was using it wrongly. */
.label{ font-family:var(--f-body); font-weight:600; font-size:var(--s--2); letter-spacing:.18em; text-transform:uppercase; color:var(--ink-2) }
.lead{ color:var(--ink-2); font-size:var(--s-1); line-height:1.55 }
.muted{ color:var(--ink-2) }
.accent{ color:var(--lime) }

.btn{
  display:inline-flex; align-items:center; justify-content:center; gap:.5rem;
  padding:.78rem 1.5rem; border-radius:999px;
  font-family:var(--f-body); font-weight:600; font-size:.9rem;
  border:1px solid var(--line-2); color:var(--ink); background:rgba(255,255,255,.04);
  backdrop-filter:blur(12px); cursor:pointer; transition:all .4s var(--ease); white-space:nowrap;
}
.btn:hover{ background:rgba(255,255,255,.1); border-color:var(--line-2); transform:translateY(-2px) }
.btn--cta{ background:var(--lime); border-color:var(--lime); color:var(--lime-ink); box-shadow:0 6px 28px rgba(124,218,36,.26) }
.btn--cta:hover{ background:#8bef2e; border-color:#8bef2e; color:var(--lime-ink); box-shadow:0 10px 38px rgba(124,218,36,.4) }

.rv{ opacity:0; transform:translateY(26px); transition:opacity .95s var(--ease), transform .95s var(--ease) }
.rv.in{ opacity:1; transform:none }
.d1{transition-delay:.08s}.d2{transition-delay:.16s}.d3{transition-delay:.24s}
.d4{transition-delay:.32s}.d5{transition-delay:.4s}.d6{transition-delay:.48s}

.progress{ position:fixed; top:0; left:0; height:2px; width:0; background:var(--lime); z-index:2000; box-shadow:0 0 12px var(--lime) }

/* ================================================================= nav */
.nav{
  position:fixed; top:.85rem; left:50%; translate:-50% 0; z-index:1500;
  width:min(calc(100% - 2*var(--pad)), var(--maxw));
  display:flex; align-items:center; justify-content:space-between; gap:1.2rem;
  padding:.55rem .6rem .55rem 1.2rem;
  border:1px solid var(--line); border-radius:999px;
  background:rgba(10,12,10,.6); backdrop-filter:blur(20px);
  transition:background .45s var(--ease), border-color .45s var(--ease);
}
.nav.solid{ background:rgba(10,12,10,.88); border-color:var(--line-2) }
.nav__logo img{ height:26px; width:auto }
.nav__links{ display:flex; gap:1.4rem }
.nav__links a{ font-size:.87rem; color:var(--ink-2); transition:color .3s; position:relative; padding-block:.3rem }
.nav__links a:hover,.nav__links a[aria-current="page"]{ color:var(--ink) }
.nav__links a[aria-current="page"]::after{ content:""; position:absolute; left:50%; translate:-50% 0; bottom:-2px; width:4px; height:4px; border-radius:50%; background:var(--lime) }
.nav__cta{ display:flex; gap:.5rem; align-items:center }
/* Was 36x33 at 375px. This is the only navigation control a phone visitor
   has, and it was the smallest thing on the page. */
.nav__burger{ display:none; background:none; border:0; color:var(--ink); font-size:1.3rem;
              cursor:pointer; width:44px; height:44px; padding:0;
              align-items:center; justify-content:center }
@media(max-width:1020px){
  .nav__links{
    /* Was a hard-coded 4.4rem, which is 70.4px - the nav is 70px tall and
       starts 13.6px down, so the panel opened 8px behind the bar it drops out
       of. --stick is the measured nav bottom plus the same gap the sticky
       filter bar uses, so the two land on the same line. */
    position:fixed; inset:var(--stick) var(--pad) auto; flex-direction:column; gap:0;
    background:rgba(10,12,10,.97); backdrop-filter:blur(22px);
    border:1px solid var(--line); border-radius:var(--r-sm); padding:.6rem 1.2rem;
    opacity:0; visibility:hidden; transform:translateY(-10px); transition:all .4s var(--ease);
    /* A phone held sideways is 375px tall. The open menu measured 109 to 432:
       the last two links - Our Clients, Contact - were below the screen edge
       with no way to reach them, because a fixed panel does not scroll the
       page. Capped to the viewport and scrollable inside. */
    max-height:calc(100vh - var(--stick) - 1.1rem); overflow-y:auto;
    overscroll-behavior:contain;
  }
  .nav__links.open{ opacity:1; visibility:visible; transform:none }
  .nav__links a{ padding-block:.85rem; border-bottom:1px solid var(--line) }
  .nav__links > a:last-child{ border-bottom:0 }
  .nav__burger{ display:flex }
}
@media(max-width:620px){
  /* The menu is the one piece of interface every phone visitor touches, and
     it was six 14px links in a box - the desktop's nav row turned sideways.
     On a phone it is the closest thing the site has to a table of contents,
     so it gets the display face at a size that reads as headings, and the
     links arrive one after another rather than as a block. The current page's
     dot moves from under the link to beside it, where a stacked list wants it. */
  .nav__links{ padding:.35rem 1.35rem .55rem; inset-inline:var(--pad) }
  .nav__links a{
    font-family:var(--f-display); font-size:1.38rem; font-weight:300;
    letter-spacing:-.015em; padding-block:.9rem; padding-left:1.1rem;
    opacity:0; transform:translateX(-8px);
    transition:opacity .45s var(--ease), transform .45s var(--ease), color .3s;
  }
  .nav__links.open a{ opacity:1; transform:none }
  .nav__links.open a:nth-child(1){ transition-delay:.06s }
  .nav__links.open a:nth-child(2){ transition-delay:.11s }
  .nav__links.open a:nth-child(3){ transition-delay:.16s }
  .nav__links.open a:nth-child(4){ transition-delay:.21s }
  .nav__links.open a:nth-child(5){ transition-delay:.26s }
  .nav__links.open a:nth-child(6){ transition-delay:.31s }
  .nav__links a[aria-current="page"]{ color:var(--lime) }
  .nav__links a[aria-current="page"]::after{ left:0; translate:0 -50%; top:50%; bottom:auto;
                                             width:5px; height:5px }
}

/* ================================================================= panels */
/* Everything lives inside a big rounded panel — the reference's core move. */
.panel{
  position:relative; overflow:hidden;
  border:1px solid var(--line); border-radius:var(--r);
  background:linear-gradient(165deg,rgba(255,255,255,.075),rgba(255,255,255,.02) 45%,rgba(255,255,255,.008));
  isolation:isolate;
}
.panel > *{ position:relative; z-index:1 }
/* Must out-specify the rule above (0-2-0 vs 0-1-0) so the mesh keeps
   position:absolute. Do NOT write this as `.panel > :not(.mesh)` — that also
   scores 0-2-0 and then overrides .float's own absolute positioning. */
.panel > .mesh{ position:absolute; inset:0; z-index:0 }
.panel--flat{ background:var(--panel) }

.glass{
  border:1px solid var(--line); border-radius:var(--r-sm);
  background:rgba(255,255,255,.04); backdrop-filter:blur(16px);
  padding:1.2rem 1.4rem;
  transition:background .45s var(--ease), border-color .45s var(--ease), transform .45s var(--ease);
}
.glass:hover{ background:rgba(255,255,255,.075); border-color:var(--line-2); transform:translateY(-4px) }

/* ================================================================= hero */
.hero{ padding:clamp(6.5rem,14vh,9rem) 0 clamp(2rem,5vh,3.5rem) }
.hero__panel{ padding:clamp(2.4rem,6vw,5rem) clamp(1.4rem,4vw,3rem) clamp(2rem,5vw,3.5rem); text-align:center; min-height:clamp(420px,62vh,620px); display:flex; flex-direction:column; justify-content:center }

/* Drawn hero background: CCTV camera right, concealed lens left.
   Sits under the mesh glow and under all content. object-fit via
   background-size:cover so it crops gracefully at every ratio. */
.hero__art{
  position:absolute; inset:-2.5%; z-index:0; pointer-events:none;
  overflow:hidden; border-radius:inherit;
}
/* The inlined SVG carries preserveAspectRatio="…slice", which is the vector
   equivalent of background-size:cover. */
.hero__art svg{ width:100%; height:100%; display:block }
/* Mesh sits above the art and was washing it out; hold it back over the hero. */
.hero__panel > .mesh{ opacity:.55 }
@media(max-width:760px){
  .hero__art{ opacity:.65 }
  /* The clip as a bed behind the copy did not survive on a phone. Two
     versions were tried: cover centred on the frame (which is the darkest
     part of this clip - the subject lives 12-31% from the left), and cover
     aimed at the subject under a scrim heavy enough to keep the paragraph at
     4.5:1. The second passed every contrast number and was reported, fairly,
     as "no live background": composited through that scrim the whole hero
     peaked at luma 61 and averaged 3-17. Numerically present, visibly black.
     The constraint is that the copy covers 15-81% of the hero's height, so
     there is no strip to leave bright. So the copy comes out from over the
     clip: the video is a band at the top of the panel, full brightness, no
     scrim, and the words sit under it - the same arrangement the Spy Cameras
     hero settled on, which is the one that was approved. */
  .hero__panel{ padding-top:0; justify-content:flex-start }
  /* With no band (Data Saver, 2G) the copy would sit hard against the panel's
     top edge; give it the desktop's padding back and centre it over the
     drawn artwork as before. */
  .lowdata .hero__panel{ padding-top:clamp(2.4rem,6vw,5rem); justify-content:center }
  /* Written as .hero .hero__video, not .hero__video: the base rule that makes
     it absolute is declared later in this file at equal specificity, and at
     equal specificity the later one wins - measured as position:absolute with
     the band still behind the headline on the first build of this. */
  .hero .hero__video{ position:relative; inset:auto; z-index:0; width:auto; height:38vh;
                max-height:330px; flex:none;
                /* Negative bottom margin: the mask fades the last third of
                   the band, and with a normal margin the pill sat 150px below
                   the last visible pixel of the clip - a black gap in the
                   middle of the hero. Pulled up, the pill sits in the fade and
                   the band hands over to the copy instead of ending first. */
                margin:0 calc(-1 * clamp(1.4rem,4vw,3rem)) -2.6rem;
                -webkit-mask-image:linear-gradient(180deg,#000 66%,transparent 99%);
                        mask-image:linear-gradient(180deg,#000 66%,transparent 99%) }
  /* Square-ish band, cover: shows about 56% of the frame width. Aimed at 22%
     the window runs 0-56%, which holds the 12-31% subject with room. */
  .hero__video video{ object-fit:cover; object-position:22% center }
  /* .panel > * lifts every child to z-index 1 and position relative; the copy
     that follows needs to sit above the band's fade, which it does in DOM
     order, so nothing else to do. The drawn artwork stays absolute behind
     everything for the lowdata case. */
}
.panel > .hero__art{ position:absolute }
/* 15ch keeps the headline clear of the CCTV camera in the artwork's top-right
   corner. Widen it and the last line runs under the lens. */
.hero h1{ font-size:var(--s-4); max-width:15ch; margin-inline:auto; text-wrap:balance }
.hero h1 em{ font-style:normal; color:var(--lime) }
.hero__sub{ max-width:52ch; margin:1.3rem auto 2rem; color:var(--ink-2) }
.hero__btns{ display:flex; gap:.7rem; justify-content:center; flex-wrap:wrap }

/* floating labels scattered around the hero, as in the reference */
.float{ position:absolute; z-index:2; pointer-events:none }
.float .pill{ box-shadow:0 12px 34px rgba(0,0,0,.5) }
/* Right-hand labels sit below the CCTV camera, which now occupies the top-right
   corner of the artwork — placed any higher they collide with its lens. */
.f1{ top:16%;    left:5%;  animation:bob 7s ease-in-out infinite }
.f2{ top:60%;    right:5%; animation:bob 8s ease-in-out infinite reverse }
.f3{ bottom:20%; left:9%;  animation:bob 9s ease-in-out infinite }
.f4{ bottom:12%; right:9%; animation:bob 6.5s ease-in-out infinite reverse }
@keyframes bob{ 50%{ translate:0 -12px } }
/* Measured overlap: at 848px "4G SIM Remote" crosses the hero paragraph by
   52x36px and "Local Recording" by 89x29px. Clean at 1100 and above, hidden
   below 820 - so the broken band was 821px to about 1050px, which is every
   small laptop.
   The cause is two different sizing systems meeting: the badges are positioned
   by percentage of the panel, the paragraph is capped at 52ch. Below ~1050 the
   panel narrows faster than the text does, and the percentages walk inward on
   top of it. Hiding them earlier is the honest fix - they are decoration, and
   there is no width where a badge on top of a sentence is better than no
   badge. */
@media(max-width:1080px){ .float{ display:none } }

/* logo strip */
.strip{ display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; padding:1.1rem clamp(1.2rem,3vw,2rem); margin-top:1rem }
.strip span{ color:var(--ink-3); font-size:.88rem; font-weight:500; display:flex; align-items:center; gap:.5rem }
.strip svg{ width:16px; height:16px; fill:var(--lime); flex:none }
@media(max-width:620px){
  /* Five items of very uneven length, wrapping against space-between: the
     first three each took a row of their own and the last two - short enough
     to share one - ended up pushed to opposite edges of a fourth. That reads
     as a layout that ran out of room rather than as a list, and the two
     stranded items look demoted. A column makes it the checklist it always
     was, and every tick lines up under the one above it. */
  .strip{ flex-direction:column; align-items:flex-start; gap:.6rem;
          padding-block:1rem }
  .strip span{ width:100% }
}

/* ================================================================= sections */
.sec{ padding-block:clamp(3rem,7vh,5.5rem) }
.sec__head{ text-align:center; max-width:60ch; margin:0 auto clamp(2rem,4.5vh,3.2rem) }
.sec__head h2{ font-size:var(--s-3); text-wrap:balance }
.sec__head h2 em{ font-style:normal; color:var(--lime) }
.sec__head p{ color:var(--ink-2); margin:.9rem 0 0 }
.sec__head--left{ text-align:left; margin-inline:0 }

/* ================================================================= bento */
/* Asymmetric panel mosaic — the reference's dashboard-ish composition. */
.bento{ display:grid; gap:clamp(.7rem,1.4vw,1.1rem); grid-template-columns:repeat(6,1fr) }
.bento > *{ grid-column:span 6 }
@media(min-width:820px){
  .bento--a > :nth-child(1){ grid-column:span 4 }
  .bento--a > :nth-child(2){ grid-column:span 2 }
  .bento--a > :nth-child(3){ grid-column:span 2 }
  .bento--a > :nth-child(4){ grid-column:span 4 }
  .bento--b > *{ grid-column:span 2 }
  /* Equal halves — used where two things must read as equally important. */
  .bento--pair > *{ grid-column:span 3 }
}
.tile{ padding:clamp(1.3rem,2.6vw,2rem); min-height:210px; display:flex; flex-direction:column; gap:.7rem; justify-content:flex-end }
.tile h3{ font-size:var(--s-1); font-weight:400 }
.tile p{ color:var(--ink-2); font-size:.93rem; margin:0 }
.tile__n{ font-family:var(--f-display); font-size:clamp(2.2rem,4.5vw,3.4rem); font-weight:300; color:var(--lime); line-height:1 }
.tile__top{ display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:auto }
.tile__go{ width:30px; height:30px; border-radius:50%; border:1px solid var(--line); display:grid; place-items:center; color:var(--ink-3); font-size:.75rem; transition:all .4s var(--ease); flex:none }
.tile:hover .tile__go{ border-color:var(--lime); color:var(--lime); transform:rotate(45deg) }
.tile--hot{ background:linear-gradient(150deg,rgba(124,218,36,.16),rgba(0,79,18,.28)); border-color:rgba(124,218,36,.3) }
.tile--hot .tile__n{ color:#c6ff7a }

/* Lead tiles: taller, lifted, and identical to each other so neither of the
   two core practices reads as the junior partner. */
.tile--lead{
  min-height:290px; justify-content:flex-start; gap:.85rem;
  background:linear-gradient(155deg,rgba(124,218,36,.10),rgba(0,79,18,.18) 55%,rgba(255,255,255,.01));
  border-color:rgba(124,218,36,.22);
}
.tile--lead h3{ font-size:var(--s-2); font-weight:300 }
.tile--lead .tile__n{ color:#c6ff7a }
.tile--lead p{ font-size:.97rem }

/* Founder portrait frame. Only rendered when assets/img/founder-afnan.webp
   exists (see build.py); without it the section is text only - the "AA"
   monogram that used to stand in was removed on 2026-09-30. */
.founder{
  position:relative; aspect-ratio:1; width:min(100%,300px); margin-inline:auto;
  display:grid; place-items:center;
  border:1px solid var(--line); border-radius:50%;
  background:radial-gradient(circle at 35% 30%,rgba(124,218,36,.14),rgba(0,79,18,.10) 55%,transparent 75%);
}
.founder__ring{
  position:absolute; inset:-7%; border-radius:50%;
  border:1px dashed rgba(124,218,36,.28);
  animation:rot 40s linear infinite;
}

/* ================================================================= orbital dial */
/* The .dial widget lived here - three concentric rings, a conic-gradient arc
   and a counting number in the middle. Its only use was the home page's "15+ AI
   features" block, which is gone. In git history if a stat ever wants it. */

/* ================================================================= split */
.split{ display:grid; grid-template-columns:1.02fr 1fr; gap:clamp(1.4rem,3.5vw,3rem); align-items:center }
.split--flip .split__media{ order:-1 }
@media(max-width:940px){ .split{ grid-template-columns:1fr } .split--flip .split__media{ order:0 } }

.shot{ position:relative; border-radius:var(--r); overflow:hidden; border:1px solid var(--line); background:var(--bg-2) }
.shot img,.shot video{ width:100%; display:block }
.shot--pad{ padding:clamp(1.6rem,4vw,3rem); display:grid; place-items:center }

/* ================================================================= specs */
.specs{ list-style:none; margin:1.4rem 0 0; padding:0 }
.specs li{ display:flex; gap:1rem; align-items:baseline; padding:.8rem 0; border-top:1px solid var(--line) }
.specs li b{ font-size:var(--s--2); letter-spacing:.16em; text-transform:uppercase; color:var(--lime); flex:none; width:5.4rem; font-weight:600 }
.specs li span{ color:var(--ink-2); font-size:.94rem }

/* ================================================================= rail */
/* The home page teaser rail.
   The cards carry white product plates, and a row of them straight onto the
   page's near-black was the harshest edge on the site - full-height white
   rectangles against #070807, cut off dead at both viewport edges. Three things
   soften it, and all three are doing work:
   - .railband puts a lit surface underneath, so the eye steps page -> band ->
     card -> plate instead of jumping from black to white in one move;
   - the mask fades the row out at both ends, so cards leave rather than stop;
   - the plate is inset inside the card (below) rather than bleeding to its
     edges, which gives the white a dark frame at every side. */
.railband{ position:relative; margin-top:.4rem;
           padding:clamp(1.1rem,2.4vw,1.7rem) 0 clamp(.5rem,1.2vw,.9rem);
           border-block:1px solid var(--line);
           background:linear-gradient(180deg,rgba(124,218,36,.055),transparent 78%) }
.rail{ display:flex; gap:.9rem; overflow-x:auto; scroll-snap-type:x mandatory; padding:.2rem var(--pad) 1.2rem; scrollbar-width:none;
       -webkit-mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent);
               mask-image:linear-gradient(90deg,transparent,#000 5%,#000 95%,transparent) }
.rail::-webkit-scrollbar{ display:none }
.pcard{ flex:0 0 clamp(200px,25vw,265px); scroll-snap-align:start; border:1px solid var(--line); border-radius:var(--r-sm); overflow:hidden; background:var(--panel); transition:all .45s var(--ease) }
.pcard:hover{ border-color:rgba(124,218,36,.32); transform:translateY(-5px); background:var(--panel-2) }
/* Inset and rounded, unlike the catalogue's full-bleed plates. The catalogue is
   forty of these tiled together, where the whites read as one surface; here it
   is eight floating on an editorial page, where each one is its own bright
   rectangle. The margin is what turns it from a block into a mounted print.
   Dimmer at rest than the catalogue's .955 for the same reason. */
.pcard__img{ aspect-ratio:1; overflow:hidden; background:#fff;
             margin:.5rem; border-radius:12px; border:1px solid rgba(0,0,0,.35) }
.pcard__img img{ width:100%; height:100%; object-fit:cover; display:block; filter:brightness(.9);
                 transition:transform .6s var(--ease),filter .5s var(--ease) }
.pcard:hover .pcard__img img{ transform:scale(1.05); filter:brightness(1) }
.pcard__b{ padding:.35rem 1.1rem 1.1rem }
.pcard__b h3{ font-size:.98rem; font-weight:400 }
.pcard__b span{ font-size:.78rem; color:var(--lime) }

/* ================================================================= reviews */
/* ================================================================ reviews
   Real Google reviews, rendered from content/reviews.json at build time. The
   summary row prints the listing's own score and count (4.5 from 60 at the
   dump), not an average of the quotes below it - those are the 5-star ones
   the old plugin was set to show, and "5.0" over a hand-picked eight would be
   a lie by arithmetic. The Google mark is the four-colour G because a review
   that says where it came from is worth more than one that does not. */
.rsum{ display:flex; align-items:center; flex-wrap:wrap; gap:.55rem .9rem;
       justify-content:center; margin:-.4rem auto 1.8rem; color:var(--ink-2); font-size:.92rem }
.rsum__g svg,.review__g svg{ width:20px; height:20px; display:block }
.rsum__n{ font-family:var(--f-display); font-size:1.9rem; font-weight:300; color:var(--ink); line-height:1 }
.rsum__c{ color:var(--ink-3) }
.rsum__links{ display:flex; gap:1.1rem; flex-wrap:wrap; margin-left:.4rem }
.rsum__links a{ color:var(--lime); font-weight:600; font-size:.86rem }
.rsum__links a:hover{ text-decoration:underline; text-underline-offset:3px }

/* Stars are drawn, not typed: a glyph star cannot be half filled, and the
   listing's score is 4.5. Same mark at both sizes; the half is a masked
   second layer over an empty one. */
.stars{ display:inline-flex; gap:.18em; line-height:0 }
.st{ width:.95em; height:.95em; display:inline-block; position:relative;
     -webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2.5l2.9 6.2 6.8.8-5 4.7 1.3 6.7L12 17.6l-6 3.3 1.3-6.7-5-4.7 6.8-.8z'/%3E%3C/svg%3E") center/contain no-repeat;
             mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2.5l2.9 6.2 6.8.8-5 4.7 1.3 6.7L12 17.6l-6 3.3 1.3-6.7-5-4.7 6.8-.8z'/%3E%3C/svg%3E") center/contain no-repeat;
     background:rgba(255,255,255,.14) }
.st--f{ background:var(--lime) }
.st--h{ background:linear-gradient(90deg,var(--lime) 50%,rgba(255,255,255,.14) 50%) }
.rsum .stars{ font-size:1.05rem }

.reviews{ display:grid; gap:clamp(.8rem,1.6vw,1.1rem);
          grid-template-columns:repeat(auto-fill,minmax(min(100%,270px),1fr)) }
.review{ display:flex; flex-direction:column; gap:.8rem; padding:1.25rem 1.3rem 1.15rem }
.review__top{ display:flex; align-items:center; justify-content:space-between }
.review .stars{ font-size:.86rem }
.review__text{ color:var(--ink-2); font-size:.93rem; line-height:1.55; margin:0; flex:1 }
/* Long reviews clamp to seven lines with a button to open them, so one
   680-character review does not set the height of the whole row. */
.review--long:not(.open) .review__text{ display:-webkit-box; -webkit-line-clamp:7;
                                        -webkit-box-orient:vertical; overflow:hidden }
.review__more{ align-self:flex-start; background:none; border:0; padding:.2rem 0; cursor:pointer;
               font:inherit; font-size:.8rem; font-weight:600; color:var(--lime); margin-top:-.4rem }
.review__more:hover{ text-decoration:underline; text-underline-offset:3px }
.review.open .review__more{ display:none }
/* Google's Places policy requires the author's avatar, name and profile link
   on every review it supplies. The avatar comes from api/avatar.php, our own
   origin - reviews.php caches it server-side so that reading the home page
   costs the visitor no request to Google, the same bargain the click-to-load
   map makes. Seeded reviews predate the API and have no avatar, so they get a
   monogram instead, matching the founder slot's fallback. */
.review__who{ display:flex; align-items:center; gap:.55rem;
              padding-top:.75rem; border-top:1px solid var(--line); font-size:.84rem }
.review__av{ flex:0 0 auto; width:32px; height:32px; border-radius:50%; display:block;
             background:rgba(255,255,255,.06); object-fit:cover }
.review__av--mono{ display:grid; place-items:center; font-size:.7rem; font-weight:700;
                   letter-spacing:.04em; color:var(--lime); border:1px solid var(--line) }
.review__name{ font-weight:600; color:var(--ink); text-decoration:none; min-width:0;
               overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
.review__name:hover{ text-decoration:underline; text-underline-offset:3px }
.review__who time{ margin-left:auto; flex:0 0 auto; color:var(--ink-3); font-size:.76rem;
                   letter-spacing:.06em; text-transform:uppercase }

/* The ordering-and-filtering notice. Required by the same policy, so it is
   placed where it is actually read - under the score, above the cards - and
   not shrunk to the point of being a disclaimer in the legal sense only. */
.rnote{ margin:-1.2rem auto 1.5rem; max-width:52ch; text-align:center;
        color:var(--ink-3); font-size:.8rem; line-height:1.5 }

@media(max-width:820px){
  /* Eight paragraphs of prose one under another is the longest thing on the
     home page after the hero. A snap rail, one card at a time, the way the
     objects rail already works - and a phone visitor reads two or three and
     moves on, which is what people do with reviews. */
  .reviews{ display:flex; gap:.75rem; overflow-x:auto; scroll-snap-type:x mandatory;
            scrollbar-width:none; margin-inline:calc(-1 * var(--pad)); padding:.2rem var(--pad) 1rem;
            -webkit-mask-image:linear-gradient(90deg,transparent,#000 4%,#000 96%,transparent);
                    mask-image:linear-gradient(90deg,transparent,#000 4%,#000 96%,transparent) }
  .reviews::-webkit-scrollbar{ display:none }
  .review{ flex:0 0 min(82vw,320px); scroll-snap-align:start }
  .rsum{ justify-content:flex-start; text-align:left }
  .rnote{ text-align:left; margin-inline:0 }
  /* The two Google links measured 22px tall - the shortest standalone
     targets on the page. Rows, not inline text. */
  .rsum__links{ width:100%; margin-left:0; gap:.4rem 1.4rem }
  .rsum__links a{ display:inline-flex; align-items:center; min-height:44px }
}

/* ================================================================= form */
.form{ display:grid; gap:.9rem }
.field{ display:grid; gap:.35rem }
.field label{ font-size:var(--s--2); letter-spacing:.14em; text-transform:uppercase; color:var(--ink-3); font-weight:600 }
.field input,.field select,.field textarea{
  background:rgba(255,255,255,.04); border:1px solid var(--line); border-radius:12px;
  padding:.8rem 1rem; color:var(--ink); font-family:var(--f-body); font-size:1rem;
  transition:border-color .35s var(--ease), background .35s var(--ease);
}
.field input:focus,.field select:focus,.field textarea:focus{ outline:none; border-color:var(--lime); background:rgba(255,255,255,.07) }
.field textarea{ min-height:120px; resize:vertical }
/* Not every engine honours color-scheme for the options inside a popup, and an
   unreadable dropdown is worse than an off-palette one. These are near-black
   rather than the panel's translucent white, because a popup is drawn over the
   page rather than in it - there is nothing behind it to be translucent
   against, and a transparent option renders on whatever the OS supplies. */
.field select option{ background:#0d100c; color:var(--ink) }
.field select option:disabled{ color:var(--ink-3) }
/* Chrome will not use the inherited near-white for the closed control's own
   text once an option is chosen unless it is stated here too. */
.field select{ color:var(--ink) }
.grid--form{ display:grid; grid-template-columns:1fr 1fr; gap:.9rem }
@media(max-width:640px){ .grid--form{ grid-template-columns:1fr } }

/* ================================================================= footer */
.foot{ padding-block:clamp(2rem,5vh,3.5rem) 1.2rem; margin-top:clamp(2rem,5vh,4rem) }
.foot__panel{ padding:clamp(1.8rem,4vw,3rem) }
.foot__grid{ display:grid; grid-template-columns:1.7fr 1fr 1fr 1.2fr; gap:var(--pad) }
@media(max-width:820px){ .foot__grid{ grid-template-columns:1fr 1fr } }
.foot h5{ font-size:var(--s--2); letter-spacing:.18em; text-transform:uppercase; color:var(--lime); margin:0 0 .9rem; font-weight:600 }
.foot ul{ list-style:none; margin:0; padding:0; display:grid; gap:.45rem }
.foot a,.foot li{ color:var(--ink-2); font-size:.91rem }
.foot a:hover{ color:var(--lime) }
.foot__bot{ margin-top:2.2rem; padding-top:1.2rem; border-top:1px solid var(--line); display:flex; justify-content:space-between; gap:1rem; flex-wrap:wrap; font-size:.8rem; color:var(--ink-3) }

.wa{ position:fixed; right:clamp(1rem,3vw,2rem); bottom:clamp(1rem,3vw,2rem); z-index:1400; width:50px; height:50px; border-radius:50%; background:#25D366; display:grid; place-items:center; box-shadow:0 10px 30px rgba(37,211,102,.34); transition:transform .4s var(--ease) }
.wa:hover{ transform:scale(1.1) }
/* At 375px this sat on top of the phone-number button at the foot of the
   tactical helmet page. Pages that end in a call-to-action get bottom padding
   to park it clear rather than the button being moved, since the button is in
   the flow and the floating one is not. */
@media(max-width:520px){ body{ padding-bottom:4.5rem } }
.wa svg{ width:25px; height:25px; fill:#fff }

/* ------------------------------------------------- form result -----------
   This was a .muted paragraph with no rule of its own, so a successful
   submission printed in the same grey as the body copy with no background, no
   border and no icon, and the button did not change either. Nothing separated
   "sent" from "the click did nothing" - which is exactly how it was read in
   testing. It carries role="status" and aria-live="polite" in the markup, so
   it is announced rather than only shown. */
.formnote{ margin:.9rem 0 0; padding:.85rem 1rem; border-radius:var(--r-sm);
           font-size:.87rem; line-height:1.5; color:var(--ink);
           background:rgba(124,218,36,.1); border:1px solid rgba(124,218,36,.4);
           display:flex; gap:.6rem; align-items:flex-start }
.formnote::before{ content:"\2713"; flex:none; width:20px; height:20px; border-radius:50%;
                   background:var(--lime); color:var(--lime-ink); font-size:.72rem;
                   font-weight:700; display:grid; place-items:center; margin-top:.1rem }
.formnote[hidden]{ display:none }
/* Failure is not lime. The success styling was the only state this had, so a
   rejected submission arrived looking exactly like a successful one - the worst
   possible confusion on the one control that matters. */
.formnote--bad{ background:rgba(255,86,86,.1); border-color:rgba(255,120,120,.45) }
.formnote--bad::before{ content:"!"; background:#ff7a7a; color:#2a0808 }
.formnote--wait{ background:var(--panel-2); border-color:var(--line-2); color:var(--ink-2) }
.formnote--wait::before{ content:"\2026"; background:var(--line-2); color:var(--ink) }

/* A field the server expects to be empty. Not display:none - some bots skip
   fields that are display:none, and some browsers skip them for autofill in a
   way that is harder to reason about. Off-screen and out of the tab order is
   the version that behaves predictably in both directions. */
.hp{ position:absolute; left:-9999px; width:1px; height:1px; overflow:hidden }

.field__opt{ color:var(--ink-3); font-weight:400; text-transform:none; letter-spacing:0;
             font-size:.82em }
.field__hint{ margin:.45rem 0 0; font-size:.79rem; line-height:1.45; color:var(--ink-3) }
/* Invalid state. aria-invalid is what the JS sets, so the styling and what a
   screen reader is told cannot drift apart - there is only one flag. */
.field--bad input,.field--bad select,.field--bad textarea,
[aria-invalid="true"]{ border-color:rgba(255,120,120,.65)!important }
.field--bad > label{ color:#ff9b9b }

/* ================================================================= a11y */
:where(a,button,input,select,textarea,[tabindex]):focus-visible{ outline:2px solid var(--lime); outline-offset:3px; border-radius:6px }
.skip{ position:absolute; left:-9999px; top:0; background:var(--lime); color:var(--lime-ink); padding:.7rem 1.1rem; z-index:3000 }
.skip:focus{ left:0 }

@media (prefers-reduced-motion: reduce){
  *,*::before,*::after{ animation-duration:.001ms !important; animation-iteration-count:1 !important; transition-duration:.001ms !important; scroll-behavior:auto !important }
  .rv{ opacity:1 !important; transform:none !important }
}

/* ============================================================ NAV DROPDOWN
   The original site's main menu has one submenu ("More Products"). Kept as a
   real <details>-free hover/focus menu so it works with a keyboard: the parent
   is a button, and the panel stays open while anything inside it has focus. */
.nav__item{ position:relative; display:flex; align-items:center }
.nav__item > button{
  background:none; border:0; cursor:pointer; font:inherit; font-size:.87rem;
  color:var(--ink-2); padding-block:.3rem; display:flex; align-items:center; gap:.32rem;
  transition:color .3s;
}
.nav__item > button::after{ content:"▾"; font-size:.7em; opacity:.6; transition:transform .3s }
.nav__item:hover > button,.nav__item:focus-within > button{ color:var(--ink) }
.nav__item:hover > button::after,.nav__item:focus-within > button::after{ transform:rotate(180deg) }
.nav__sub{
  position:absolute; top:calc(100% + .9rem); left:50%; translate:-50% 0; min-width:230px;
  background:rgba(12,15,12,.96); backdrop-filter:blur(20px);
  border:1px solid var(--line-2); border-radius:var(--r-sm); padding:.5rem;
  display:grid; gap:.1rem; opacity:0; visibility:hidden; transform:translateY(-8px);
  transition:all .3s var(--ease); box-shadow:0 24px 60px rgba(0,0,0,.6);
}
.nav__item:hover .nav__sub,.nav__item:focus-within .nav__sub{ opacity:1; visibility:visible; transform:none }
.nav__sub a{ padding:.55rem .8rem; border-radius:10px; font-size:.87rem; color:var(--ink-2) }
.nav__sub a:hover{ background:var(--panel-2); color:var(--lime) }

/* Must come after the popover rules above, not up in the earlier mobile block:
   at equal specificity the later declaration wins, and the popover rules are
   declared later in this file than the drawer's. */
@media(max-width:1020px){
  /* In the drawer the submenu is not a hover popover - it is an inline group,
     always open, indented under its label. There is no hover on touch, and a
     popover positioned off a full-width row has nowhere sensible to go. */
  .nav__item{ display:block; border-bottom:1px solid var(--line) }
  .nav__item > button{ width:100%; justify-content:space-between; padding-block:.85rem }
  .nav__item > button::after{ content:"" }
  .nav__sub{
    position:static; translate:none; transform:none; opacity:1; visibility:visible;
    background:none; border:0; box-shadow:none; padding:0 0 .5rem .9rem; min-width:0;
  }
  /* --ink-3 on the drawer's near-black measures 3.5:1, under the 4.5:1 floor,
     and these are navigation links - the indent already subordinates them to
     the top-level items without also making them hard to read. */
  .nav__sub a{ padding:.5rem 0; border-bottom:0; font-size:.85rem; color:var(--ink-2) }
}

/* ============================================================== CATALOGUE
   Product photography from the shop is white-background studio work. Rather
   than key it out (several products are themselves white), each card gets a
   light "lightbox" plate; the dark card body carries the text. */
.filters{ display:flex; flex-wrap:wrap; gap:.5rem; justify-content:center; margin-bottom:clamp(1.6rem,3.5vh,2.6rem) }
.filters button{
  font:inherit; font-size:.83rem; font-weight:600; cursor:pointer;
  padding:.5rem 1.05rem; border-radius:999px; border:1px solid var(--line);
  background:var(--panel); color:var(--ink-2); transition:all .35s var(--ease);
}
.filters button:hover{ border-color:var(--line-2); color:var(--ink) }
.filters button[aria-pressed="true"]{ background:var(--lime); border-color:var(--lime); color:var(--lime-ink) }
.filters .count{ opacity:.55; font-weight:500; margin-left:.35rem }

.cat{ display:grid; gap:clamp(.8rem,1.6vw,1.2rem);
      grid-template-columns:repeat(auto-fill,minmax(min(100%,235px),1fr));
      /* dense lets ordinary cards backfill the holes a double-width feature
         card leaves when it will not fit in the remaining columns */
      grid-auto-flow:dense }
.prod{ display:flex; flex-direction:column; border:1px solid var(--line); border-radius:var(--r-sm);
       overflow:hidden; background:var(--panel); transition:transform .45s var(--ease),border-color .45s,background .45s }
.prod:hover{ transform:translateY(-6px); border-color:rgba(124,218,36,.34); background:var(--panel-2) }
/* A white plate, filled edge to edge by the photograph.
   The catalogue used to run cutouts on a dark gradient, with a painted ellipse
   under each object standing in for a shadow. Three passes at the cutout (see
   adopt-plain-images.py) never got the bases to look real, because the shadow
   these images were generated with cannot be separated from the white it was
   cast on. So the white comes too, and the plate becomes part of the design.
   Two things follow from that and both are load-bearing:
   - no padding. The photographs already carry their own margin, and adding
     more shrinks the object inside a frame that is already mostly white.
   - no drop-shadow on the img. The image is now an opaque rectangle, so a
     drop-shadow outlines the rectangle instead of the object. The plate is
     shadowed by the card, and the object by the photograph itself.
   Held a shade off pure white at rest so a grid of forty does not glare on a
   near-black page; hover takes it to full white, which reads as the card
   lighting up. */
.prod__img{ aspect-ratio:1; display:block; position:relative; overflow:hidden;
            background:#fff; border-bottom:1px solid var(--line) }
.prod__img img{ width:100%; height:100%; object-fit:cover; display:block;
                filter:brightness(.955);
                transition:transform .6s var(--ease),filter .5s var(--ease) }
.prod:hover .prod__img img{ transform:scale(1.05); filter:brightness(1) }
.prod__img--none{ background:linear-gradient(180deg,#11170f,#080b08); display:grid; place-items:center;
                  color:var(--ink-3); font-size:var(--s--2); letter-spacing:.16em; text-transform:uppercase }
/* z-index is load-bearing. On hover the image gets transform:scale(), which
   promotes it into the positioned-painting layer; with both at z-index auto the
   image wins on DOM order and paints over the badge, which then disappears
   under the photo. pointer-events:none keeps the card's click overlay reachable
   through it. */
.prod__tag{ position:absolute; top:.6rem; left:.6rem; background:rgba(8,23,10,.86); color:var(--lime);
            font-size:.66rem; letter-spacing:.14em; text-transform:uppercase; font-weight:700;
            padding:.28rem .55rem; border-radius:6px; z-index:2; pointer-events:none }
.prod__b{ padding:.95rem 1.05rem 1.1rem; display:flex; flex-direction:column; gap:.45rem; flex:1 }
.prod__b h3{ font-size:1rem; font-weight:400; line-height:1.25 }
.prod__b p{ margin:0; font-size:.8rem; color:var(--ink-3); line-height:1.45; flex:1 }
.prod__foot{ display:flex; align-items:baseline; justify-content:space-between; gap:.6rem;
             margin-top:.3rem; padding-top:.7rem; border-top:1px solid var(--line) }
/* The card foot used to hold a price. The site does not publish prices - every
   unit is quoted against the brief - so it carries a build note instead. */
.prod__note{ font-size:.72rem; letter-spacing:.14em; text-transform:uppercase; color:var(--ink-3); font-weight:600 }
.prod__cta{ font-size:.78rem; font-weight:600; color:var(--ink-2); transition:color .3s }
.prod:hover .prod__cta{ color:var(--lime) }
.prod.hide{ display:none }

/* ================================================================= PROSE
   Long-form pages (policies, training, product write-ups) render the
   database's own HTML, so this styles bare tags rather than classes. */
.prose{ max-width:74ch; color:var(--ink-2); font-size:1rem; line-height:1.72 }
.prose h2{ font-size:var(--s-2); color:var(--ink); margin:2.4rem 0 .9rem }
.prose h3{ font-size:var(--s-1); color:var(--ink); margin:2rem 0 .7rem }
.prose h2:first-child,.prose h3:first-child{ margin-top:0 }
.prose p{ margin:0 0 1.1rem }
.prose strong{ color:var(--ink); font-weight:600 }
.prose ul,.prose ol{ margin:0 0 1.3rem; padding-left:1.2rem; display:grid; gap:.45rem }
.prose li::marker{ color:var(--lime) }
.prose a{ color:var(--lime); text-decoration:underline; text-underline-offset:3px }

/* Client roster — a plain list would waste the width, so it wraps as chips. */
/* Client logo wall. White plates, for the same reason the catalogue cards are
   white plates: these are full-colour marks - a navy crest, a red-and-blue
   shield, a gold wreath - and none of them was drawn to sit on near-black.
   Recolouring them is not an option either; a police insignia is not a brand
   asset anyone gets to restyle.
   Held a shade off pure white at rest so seven of them do not glare, full white
   on hover, matching .prod__img. */
/* Flex, not grid. Seven is an awkward count: every fixed column number leaves
   an orphan on the last row (7 = 4+3, 3+3+1, 2+2+2+1), and a grid cannot centre
   a partial row. Flex with grow does - a short last row spreads and stays
   centred at every width. */
.clogos{ list-style:none; margin:0; padding:0; display:flex; flex-wrap:wrap;
         justify-content:center; gap:clamp(.6rem,1.4vw,1rem) }
.clogo{ flex:1 1 130px; max-width:210px;
        background:#fff; border:1px solid var(--line); border-radius:var(--r-sm);
        display:grid; place-items:center;
        min-height:calc(var(--clogo-h) + 2.6rem); padding:1.3rem 1.1rem;
        transition:transform .4s var(--ease),box-shadow .4s var(--ease) }
.clogo:hover{ transform:translateY(-4px); box-shadow:0 16px 34px rgba(0,0,0,.5) }
/* Set by build.py on any mark wider than 4:1. Without it the widest lockup on
   the wall renders its type about 16px tall in a tile sized for square crests. */
.clogo--wide{ flex:1.9 1 240px; max-width:400px }
/* The height cap is a length, not a percentage. Against a percentage the square
   crests won: max-height:100% resolves against a height that aspect-ratio only
   suggests, so a 190x194 crest rendered at full width and stretched its tile
   half again as tall as the wordmark tiles beside it. A fixed cap plus
   max-width:100% is what makes a crest and a 480x82 wordmark carry the same
   weight, and it keeps every tile the same height. */
:root{ --clogo-h:clamp(46px,5.4vw,72px) }
.clogo img{ max-width:100%; max-height:var(--clogo-h); width:auto; height:auto;
            object-fit:contain; filter:brightness(.97);
            transition:filter .4s var(--ease) }
.clogo:hover img{ filter:brightness(1) }

.clients{ display:grid; gap:.6rem; grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr));
          list-style:none; margin:0; padding:0 }
.clients li{ display:flex; align-items:center; gap:.7rem; padding:.85rem 1.1rem;
             border:1px solid var(--line); border-radius:var(--r-sm); background:var(--panel);
             font-size:.93rem; color:var(--ink); transition:all .4s var(--ease) }
.clients li:hover{ border-color:rgba(124,218,36,.3); background:var(--panel-2); transform:translateY(-3px) }
.clients li::before{ content:""; width:7px; height:7px; border-radius:50%; background:var(--lime);
                     box-shadow:0 0 10px var(--lime); flex:none }

/* ========================================================== DETAIL PANEL
   Clicking a catalogue card opens its detail in a slide-over rather than a
   separate page, so the grid and its filter survive browsing several builds. */

/* The button lives in the card foot but is stretched over the whole card, so
   the entire tile is one click target without nesting flow content in a
   <button>. Anything that must stay clickable inside a card needs a z-index
   above this. */
.prod{ position:relative }
.prod__open{ font:inherit; background:none; border:0; cursor:pointer; padding:0 }
.prod__open::after{ content:""; position:absolute; inset:0; border-radius:inherit; z-index:1 }
.prod:focus-within{ border-color:rgba(124,218,36,.34) }
.prod__open:focus-visible::after{ outline:2px solid var(--lime); outline-offset:3px }

/* Screen-reader-only text: "Details for Book Spy Camera" reads usefully out of
   context, while the visible label stays a tidy "Details". */
.vh{ position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap }

.drawer{ position:fixed; inset:0; z-index:2500; display:flex; justify-content:flex-end }
.drawer[hidden]{ display:none }
.drawer__scrim{ position:absolute; inset:0; background:rgba(4,6,4,.72); backdrop-filter:blur(6px);
                opacity:0; transition:opacity .4s var(--ease) }
.drawer.open .drawer__scrim{ opacity:1 }
.drawer__panel{
  position:relative; width:min(560px,100%); height:100%; overflow-y:auto;
  background:var(--bg-2); border-left:1px solid var(--line-2);
  padding:clamp(1.4rem,4vw,2.4rem); transform:translateX(100%);
  transition:transform .5s var(--ease);
}
.drawer.open .drawer__panel{ transform:none }
.drawer__close{
  position:sticky; top:0; float:right; margin:-.4rem -.4rem 0 0; z-index:2;
  width:38px; height:38px; border-radius:50%; cursor:pointer;
  background:var(--panel-2); border:1px solid var(--line-2); color:var(--ink);
  font-size:.9rem; transition:all .3s var(--ease);
}
.drawer__close:hover{ background:var(--lime); border-color:var(--lime); color:var(--lime-ink) }
@media(max-width:620px){
  .drawer__panel{ width:100%; border-left:0; border-top:1px solid var(--line-2);
                  height:92%; margin-top:auto; border-radius:var(--r) var(--r) 0 0;
                  transform:translateY(100%) }
  .drawer{ align-items:flex-end }
}

/* Same white plate as the cards. 1:1 rather than 4:3, because the photographs
   are square - at 4:3 the object was letterboxed by its own white margin, which
   against a dark drawer looked like a rendering fault. */
.pd__media{ aspect-ratio:1; border-radius:var(--r-sm); display:block; position:relative;
            margin-bottom:1.4rem; overflow:hidden; background:#fff;
            box-shadow:0 20px 50px rgba(0,0,0,.5) }
.pd__media img{ width:100%; height:100%; object-fit:cover; display:block }
.pd__media:has(.pd__noimg){ display:grid; place-items:center; aspect-ratio:4/3;
                            background:linear-gradient(180deg,#12180f,#070a07) }
.pd__noimg{ color:#6d766a; font-size:var(--s--2); letter-spacing:.16em; text-transform:uppercase }
.pd__title{ font-size:var(--s-2); margin:.35rem 0 1rem }
.pd p{ color:var(--ink-2); font-size:.95rem; line-height:1.62; margin:0 0 .9rem }
.pd__h{ font-size:var(--s--2); letter-spacing:.16em; text-transform:uppercase; color:var(--lime);
        font-family:var(--f-body); font-weight:600; margin:1.8rem 0 .8rem }
.tiers{ list-style:none; margin:0; padding:0; display:grid; gap:.5rem }
.tiers li{ padding:.85rem 1rem; border:1px solid var(--line); border-radius:var(--r-sm);
           background:var(--panel); display:grid; gap:.2rem }
.tiers b{ font-size:.94rem; font-weight:600; color:var(--ink) }
.tiers span{ font-size:.85rem; color:var(--ink-3); line-height:1.5 }
.pd__notes{ list-style:none; margin:1.5rem 0 0; padding:0; display:grid; gap:.5rem }
.pd__notes li{ font-size:.82rem; color:var(--ink-3); line-height:1.5; padding-left:1.1rem; position:relative }
.pd__notes li::before{ content:"—"; position:absolute; left:0; color:var(--ink-3) }
.pd__cta{ display:grid; gap:.5rem; margin-top:1.7rem }
.pd__cta .btn{ justify-content:center }
.pd__alt{ margin-top:1.4rem; padding-top:1.2rem; border-top:1px solid var(--line);
          font-size:.88rem; color:var(--ink-2) }
.pd__alt a{ color:var(--lime); text-decoration:underline; text-underline-offset:3px }

/* ---- the "not on this list" tile, sitting inside the grid ---------------- */
.prod--custom{ border-style:dashed; border-color:rgba(124,218,36,.4);
               background:linear-gradient(160deg,rgba(124,218,36,.07),rgba(0,79,18,.1)) }
.prod--custom:hover{ border-color:var(--lime); background:linear-gradient(160deg,rgba(124,218,36,.13),rgba(0,79,18,.16)) }
.prod__img--custom{ background:none; display:grid; place-items:center; gap:.7rem; align-content:center }
.prod__img--custom svg{ width:38px; height:38px; fill:none; stroke:var(--lime); stroke-width:1.4;
                        stroke-linecap:round; transition:transform .5s var(--ease) }
.prod--custom:hover .prod__img--custom svg{ transform:rotate(90deg) }
.prod__img--custom span{ font-size:var(--s--2); letter-spacing:.16em; text-transform:uppercase;
                         color:var(--lime); font-weight:600 }
.prod--custom .prod__b p{ color:var(--ink-2) }
.prod--custom .prod__note{ color:var(--lime) }

/* ======================================================== OBJECT STRIP
   A slow drift of the actual host objects, directly under the page hero. The
   proposition - these are ordinary things, each with a camera inside - lands
   faster from a moving row of objects than from any sentence. */
.ostrip{ position:relative; overflow:hidden; padding:1.6rem 0 2rem;
         border-block:1px solid var(--line);
         background:linear-gradient(180deg,rgba(124,218,36,.04),transparent);
         /* fade both ends so items enter and leave rather than popping */
         -webkit-mask-image:linear-gradient(90deg,transparent,#000 9%,#000 91%,transparent);
                 mask-image:linear-gradient(90deg,transparent,#000 9%,#000 91%,transparent) }
.ostrip__track{ display:flex; gap:clamp(1.6rem,4vw,3.2rem); width:max-content;
                animation:drift 64s linear infinite }
.ostrip:hover .ostrip__track{ animation-play-state:paused }
@keyframes drift{ to{ transform:translateX(-50%) } }
.ostrip__i{ display:grid; justify-items:center; gap:.55rem; width:clamp(88px,11vw,124px) }
/* The photographs are opaque white now, so the strip carries small white tiles
   rather than floating objects. drop-shadow would trace the tile instead of the
   thing in it; box-shadow does the same job correctly on a rectangle. */
.ostrip__i img{ width:100%; aspect-ratio:1; object-fit:cover; display:block;
                background:#fff; border-radius:12px; filter:brightness(.9);
                box-shadow:0 10px 22px rgba(0,0,0,.55);
                transition:filter .4s var(--ease),transform .4s var(--ease) }
.ostrip__i b{ font-size:.68rem; letter-spacing:.13em; text-transform:uppercase;
              color:var(--ink-3); font-weight:600; text-align:center; line-height:1.3 }
.ostrip__i:hover img{ filter:brightness(1); transform:translateY(-5px) scale(1.06) }
@media(prefers-reduced-motion:reduce){
  /* No drift. The row becomes a normal horizontal scroller the reader drives. */
  .ostrip__track{ animation:none }
  .ostrip{ overflow-x:auto; scrollbar-width:none }
  .ostrip::-webkit-scrollbar{ display:none }
}

/* Forty-plus cards is a long scroll; keeping the filters reachable means the
   reader can narrow at the point they realise they want to, not by scrolling
   back up.
   The offset was a hard-coded 76px, measured against the desktop nav and wrong
   everywhere else - the nav is 70px tall and starts 13.6px down, so the bar
   came to rest 7px *behind* it and read as a second navbar welded to the first.
   --stick is derived from the nav's measured height instead (see site.js).

   The bar itself was also full-bleed, 88% opaque and stadium-radiused: a
   1300px slab with seven chips centred in it, so most of its width was empty
   translucent panel, and card copy scrolling underneath was legible straight
   through both ends. It now hugs its own content and is opaque, which fixes
   both at once - it reads as a floating control group rather than as a smear
   across the page, and being much narrower than the nav it no longer doubles
   it. */
.filters{ position:sticky; top:var(--stick); z-index:40;
          width:max-content; max-width:100%; margin-inline:auto;
          padding:.45rem .5rem;
          background:rgba(11,14,11,.97); backdrop-filter:blur(18px);
          border:1px solid var(--line-2); border-radius:999px;
          box-shadow:0 16px 38px rgba(0,0,0,.55), 0 2px 0 rgba(255,255,255,.02) inset }

@media(max-width:820px){
  /* Seven chips will not fit on one phone line, and wrapping them turns the
     sticky bar into a three-row block that owns a third of the viewport for
     the rest of the scroll. One line that scrolls sideways is the usual answer
     and costs no height. The radius drops because a stadium end on something
     that scrolls horizontally reads as a cut-off pill. */
  .filters{ width:auto; max-width:none; flex-wrap:nowrap; justify-content:flex-start;
            overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:none;
            border-radius:var(--r-sm); padding:.45rem .55rem;
            padding-right:.2rem }
  .filters::-webkit-scrollbar{ display:none }
  /* There was a mask fading the right edge here, as the "there is more"
     affordance. Two things were wrong with it. It was too quiet - a visitor
     testing this at 375px saw three chips and reported the other four as simply
     absent. And a mask applies to the element's own ::after, so it faded out
     the arrow meant to fix it, along with the thing the arrow was pointing at.
     A sticky arrow on the bar's own background is louder, and it occludes the
     cut-off chip cleanly rather than dissolving it. */
  .filters::after{ content:"\203A"; position:sticky; right:-.55rem; flex:none;
                   align-self:stretch; display:grid; place-items:center;
                   width:2rem; margin-left:-.6rem;
                   font-size:1.3rem; line-height:1; color:var(--lime);
                   background:linear-gradient(90deg,transparent,rgba(11,14,11,.97) 42%) }
  .filters button{ flex:none }
}
@media(min-width:821px){
  /* A notch smaller than the nav's own links. Same size, stacked 12px apart
     and both centred, the two pills read as one doubled navbar; dropping this
     one below nav scale makes it read as a control strip belonging to the grid.
     Only above 820px - on a phone these are the touch targets. */
  .filters button{ padding:.42rem .9rem; font-size:.79rem }
}
@media(max-width:820px){
  /* Measured 39.25px tall, under the 44px floor, on the one control a phone
     visitor uses to get through forty cards. */
  .filters button{ min-height:44px; padding-block:0 }
}

/* Host objects behind the concealment-family panels. Naming "Bodywear" is
   abstract; the blazer and the helmet sitting behind the word are not. Low
   opacity and masked at the top so the heading stays legible over them. */
.tile--art{ position:relative; overflow:hidden }
/* Decorative corner art, now the neon wireframes rather than the photographs.
   Photographs stopped working here the moment they became opaque white frames:
   at 22% opacity on a dark panel a white rectangle is just a grey rectangle.
   The wireframes are transparent, already lime, and they tie the tiles to the
   hero clip - which draws these same objects. */
.tile__art{ position:absolute; inset:auto -6% -8% auto; width:74%; height:78%; z-index:0;
            display:flex; align-items:flex-end; justify-content:flex-end; gap:.4rem;
            opacity:.4; transition:opacity .55s var(--ease),transform .55s var(--ease);
            -webkit-mask-image:linear-gradient(200deg,transparent,#000 55%);
                    mask-image:linear-gradient(200deg,transparent,#000 55%) }
.tile__art img{ width:52%; object-fit:contain; object-position:bottom }
.tile--art:hover .tile__art{ opacity:.62; transform:translateY(-5px) scale(1.04) }
/* .panel > * already lifts children to z-index 1; the art must stay under them */
.tile--art > .tile__art{ z-index:0 }
a.tile{ text-decoration:none; color:inherit }


/* ===================================================== HERO VIDEO + DEPTH
   A video bed under the drawn artwork, with the layers above it moving at
   different rates against the pointer. The parallax is what stops footage
   from reading as wallpaper: the pills sit visibly in front of the picture
   rather than on it. */
/* Overscanned. Parallax translates this layer by up to ~5px, and at inset:0
   that would drag the panel background into view at whichever edge it moves
   away from. The art layer below gets the same treatment for the same reason,
   at a larger margin because it moves further. */
.hero__video{ position:absolute; inset:-1.5%; z-index:0; overflow:hidden;
              border-radius:inherit; pointer-events:none }
.hero__video video{ width:100%; height:100%; object-fit:cover; display:block }
/* No brightness filter here. The clip is graded to an average luma of ~16 by
   scripts/prepare-hero-video.sh, which is where that correction belongs -
   doing it again in CSS took it to ~10 and threw the artwork away. */

/* Contrast floor for the headline. Without it the text loses legibility every
   time the clip brightens, which is exactly when nobody is watching for it. */
.hero__scrim{ position:absolute; inset:0; z-index:1; pointer-events:none;
              border-radius:inherit;
              background:radial-gradient(64% 56% at 50% 52%, rgba(4,6,4,.86), rgba(4,6,4,.35) 72%, transparent 88%),
                         linear-gradient(180deg, rgba(4,6,4,.55), transparent 38%, rgba(4,6,4,.66)) }

/* The video was generated *from* this artwork, so with the clip playing the
   two draw the same camera twice - aligned at the first frame and drifting
   apart as it animates, which reads as a ghost. The SVG is hidden while the
   video plays and comes back wherever the video does not (below). */
.hero__video ~ .hero__art{ display:none }

/* Parallax layers. translate3d keeps it on the compositor; the transition
   smooths the gap between pointer samples so it glides instead of stepping. */
[data-depth]{ will-change:transform; transition:transform .45s cubic-bezier(.22,1,.36,1) }
.hero.is-tilting [data-depth]{ transition:none }

@media(prefers-reduced-motion:reduce){
  /* No drift, no video - the drawn artwork carries the hero instead. */
  [data-depth]{ transform:none!important; transition:none }
  .hero__video{ display:none }
  .hero__video ~ .hero__art{ display:block }
}
/* This was `@media(max-width:820px)`, which is the wrong question. A phone on
   a good connection can afford 562KB and is exactly the visitor who has seen
   nothing else of the design; a laptop tethered to a 2G hotspot cannot. The
   flag is set in the document head from navigator.connection, so the fetch
   never starts rather than being started and hidden. The poster is doing the
   work in both directions, so the fallback costs nothing extra. */
.lowdata .hero__video{ display:none }
.lowdata .hero__video ~ .hero__art{ display:block }
.lowdata .vhero__vid{ display:none }

/* ==================================================== SPY CAMERAS VIDEO HERO
   The wireframe clip is the whole first screen, and the copy sits on it.

   This works here and would not work with most footage, because of what the
   clip is: one glowing object on true black, and the black is the same colour
   as the page. There is no edge to the picture, so the video does not have to
   fill the viewport to look full-bleed - it only has to have nothing between it
   and the viewport edge on the sides where it shows.

   Which is the whole trick, because the subject moves. Sampling the graded
   clip four times a second and taking the bounding box of everything green,
   the artwork's left edge ranges over 0.224-0.453 of the source width and its
   right edge over 0.542-0.766. Laid edge to edge, that leftmost reach lands
   underneath the headline, and no amount of shifting fixes it: push far enough
   right to clear the text and the rightmost reach walks off the screen.

   So the bed starts at --vleft instead of at 0, and object-fit is `contain`
   rather than `cover`. The entire 16:9 frame lives in the right-hand share of
   the hero - both subject positions land at roughly 53% and 83% of the
   viewport, clear of the copy at every point in the loop - and the letterbox
   the contain leaves above and below is black on black, so it is not there.
   The scrim dissolves the bed's left edge into the page. Nothing reads as a
   boxed clip; it reads as a hero shot with room on the left for words.

   --vleft and --vzoom are the two numbers that hold that apart, and they are
   tuned against the measurements above, not by eye. At 1440px the artwork's
   leftmost reach lands at x=650 and the copy ends at x=563; at 1000px, 522
   against 374. Change either variable and check that gap is still positive at
   both ends of each breakpoint. */
.vhero{ position:relative; overflow:hidden; isolation:isolate;
        min-height:clamp(580px,92vh,940px); display:flex; align-items:center;
        padding-block:clamp(7rem,15vh,9.5rem) clamp(5rem,10vh,7rem);
        --vleft:34%; --vzoom:1.2 }

/* The bed is also a parallax layer, so it is overscanned on the three sides it
   reaches - the fourth is feathered by the scrim and needs no margin. */
.vhero__bed{ position:absolute; inset:-2% -2% -2% var(--vleft); z-index:0;
             overflow:hidden; pointer-events:none }
/* contain, not cover: cover crops the sides at tall viewports, and the sides
   are where the object spends half the clip. --vzoom then scales the contained
   frame back up, because contain on its own leaves the subject small - it is
   only about a third of the source width to begin with. The ceiling is set by
   the near subject position: past ~1.25 the object's left edge reaches the end
   of the longest headline line. */
.vhero__vid{ width:100%; height:100%; object-fit:contain; display:block;
             transform:scale(var(--vzoom)) }
/* The poster is the picture wherever the video is suppressed - phones, reduced
   motion - and covers the decode gap everywhere else. On the bed rather than on
   the video so it survives the video being display:none. */
.vhero__bed{ background:center/contain no-repeat url(../video/spy-hero-poster.jpg) }

/* Faint measurement grid, as on an engineering drawing. Very low contrast on
   purpose: anything stronger fights the wireframe in front of it. */
.vhero__lines{ position:absolute; inset:0; z-index:1; pointer-events:none;
  background-image:linear-gradient(rgba(124,218,36,.05) 1px, transparent 1px),
                   linear-gradient(90deg, rgba(124,218,36,.05) 1px, transparent 1px);
  background-size:58px 58px }

/* One soft band travelling down the frame every eight seconds. Faster reads as
   a fault rather than as a scan. */
.vhero__scan{ position:absolute; left:0; right:0; height:22%; z-index:2; pointer-events:none;
              background:linear-gradient(180deg, transparent, rgba(124,218,36,.1) 55%, transparent);
              animation:vscan 8s linear infinite }
@keyframes vscan{ 0%{ transform:translateY(-140%) } 100%{ transform:translateY(560%) } }

/* Four gradients, each with one job: the horizontal one dissolves the bed's
   left edge and gives the copy its ground, the top one keeps the floating nav
   off the artwork, and the bottom one carries the status bar and hands the hero
   over to the section below. */
.vhero__scrim{ position:absolute; inset:0; z-index:3; pointer-events:none;
  background:linear-gradient(90deg, #060806 0%, rgba(6,8,6,.97) 30%, rgba(6,8,6,.7) 42%,
                                    rgba(6,8,6,.2) 52%, transparent 62%),
             linear-gradient(180deg, rgba(6,8,6,.8), transparent 20%),
             linear-gradient(0deg,  rgba(6,8,6,.9), transparent 24%) }

/* Viewfinder brackets on the hero itself, not on a panel inside it. */
.vhero__c{ position:absolute; width:30px; height:30px; z-index:5; pointer-events:none;
           border:2px solid var(--lime); opacity:.5 }
.vhero__c--tl{ top:22px; left:22px;  border-right:0; border-bottom:0 }
.vhero__c--tr{ top:22px; right:22px; border-left:0;  border-bottom:0 }
.vhero__c--bl{ bottom:22px; left:22px;  border-right:0; border-top:0 }
.vhero__c--br{ bottom:22px; right:22px; border-left:0;  border-top:0 }

.vhero__inner{ position:relative; z-index:6; width:100% }
.vhero__crumb{ margin:0 0 1.3rem }
.vhero__crumb a{ color:var(--ink-2) }
.vhero__crumb a:hover{ color:var(--lime) }
/* Capped short of --vleft. The copy has to end before the artwork starts, not
   before the scrim does. */
.vhero__copy{ max-width:min(44ch,44%) }
.vhero__copy h1{ font-size:var(--s-3); max-width:15ch; margin-inline:0; text-wrap:balance }
.vhero__copy .lead{ margin:1.1rem 0 0 }
.vhero__copy .hero__btns{ justify-content:flex-start; margin-top:1.9rem }

/* Three fixed columns, not a wrapping row - wrapping put "40+" alone on the
   first line with the other two beneath it, which reads as a broken list. */
.vhero__facts{ list-style:none; display:grid; grid-template-columns:repeat(3,minmax(0,1fr));
               gap:clamp(.9rem,2vw,1.6rem); align-items:start;
               margin:2.1rem 0 0; padding:1.4rem 0 0; border-top:1px solid var(--line) }
.vhero__facts li{ display:grid; gap:.25rem }
.vhero__facts b{ font-family:var(--f-head); font-size:1.5rem; font-weight:400; color:var(--lime); line-height:1 }
.vhero__facts span{ font-size:.72rem; letter-spacing:.09em; text-transform:uppercase; color:var(--ink-3);
                    line-height:1.35 }

/* Status bar along the foot of the hero. It labels what is on screen, which a
   moving wireframe cannot do for itself. The rule spans the full width; the
   text inside is a .wrap, so it lines up with the headline above it. */
.vhero__hud{ position:absolute; left:0; right:0; bottom:0; z-index:6;
             padding-block:.95rem 1.1rem; border-top:1px solid var(--line);
             background:linear-gradient(0deg, rgba(6,8,6,.75), transparent) }
.vhero__hudrow{ display:flex; align-items:center; gap:.6rem; flex-wrap:wrap;
                font-size:.72rem; letter-spacing:.09em; text-transform:uppercase; color:var(--ink-3) }
.vhero__hudrow b{ color:var(--lime); font-weight:700; letter-spacing:.14em }
.vhero__hudrow > span:last-child{ flex:1 1 18rem; min-width:0 }
.vhero__dot{ width:7px; height:7px; border-radius:50%; background:var(--lime);
             box-shadow:0 0 10px var(--lime); animation:vpulse 2.4s ease-in-out infinite; flex:none }
@keyframes vpulse{ 50%{ opacity:.25 } }
.vhero__hudsep{ width:1px; height:12px; background:var(--line-2); flex:none }

@media(max-width:1180px){
  /* Narrower viewport, same argument: the artwork has to start further across,
     and be zoomed less, or the near subject position reaches the end of a
     headline line. */
  .vhero{ --vleft:40%; --vzoom:1.1 }
  .vhero__copy{ max-width:min(36ch,48%) }
}
@media(max-width:820px){
  /* The phone layout for this hero went through three versions; the reasoning
     is worth keeping because the constraint is not obvious.

     A portrait hero and a 16:9 clip cannot both be satisfied. `contain` gives
     a 211px band that has to sit *somewhere*, and on a hero this tall there is
     no free strip - the copy runs 15% to 81% of the height, so the band lands
     on the headline whatever object-position it is given. `cover` over the
     whole hero scales the frame to 1596px wide, so a phone sees 23% of it and
     the object is enormous and unreadable.

     What works is a band whose aspect matches the subject's travel. Sampling
     the clip, the artwork's left edge ranges over 0.224-0.453 of the source
     width and its right edge over 0.542-0.766 - so the crop has to keep
     roughly 0.22 to 0.78 in frame at all times. A square band does exactly
     that. At 40vh the band is 375x325 and cover shows 65% of the width,
     centred - 0.176 to 0.824 - so the object stays whole through the entire
     loop and is still large enough to read. 46vh framed it more tightly but
     pushed the buttons a clear 20px below the fold; this keeps them at it.

     The border went too. That was what made the first version look like a
     broken image - a hard rule around an unidentified outline reads as a
     placeholder. It is a mask now, so the artwork dissolves into the page and
     hands over to the copy instead of stopping dead. */
  .vhero{ display:block; position:relative; min-height:0; overflow:hidden;
          --vleft:0%; --vzoom:1;
          padding-block:calc(clamp(5rem,11vh,6rem) + 40vh - .4rem)
                        clamp(3rem,7vh,4.5rem) }
  .vhero__bed{ position:absolute; left:0; right:0; top:clamp(5rem,11vh,6rem);
               bottom:auto; height:40vh; width:auto; aspect-ratio:auto;
               max-height:none; margin:0; border:0;
               background-size:cover; background-position:center;
               -webkit-mask-image:linear-gradient(180deg,#000 70%,transparent 99%);
                       mask-image:linear-gradient(180deg,#000 70%,transparent 99%) }
  .vhero__vid{ object-fit:cover; object-position:center; transform:none }
  /* The measurement grid goes: 58px squares at this width read as noise
     rather than as an engineering drawing. */
  .vhero__lines{ display:none }
  /* The copy sits below the band rather than on it, so the scrim has only one
     job left: keeping the floating nav legible over the top of the artwork. */
  .vhero__scrim{ background:linear-gradient(180deg, rgba(6,8,6,.82) 0%,
                   rgba(6,8,6,.35) 9%, transparent 22%) }
  .vhero__c{ display:none }
  .vhero__copy{ max-width:none }
  .vhero__copy h1{ max-width:18ch }
  .vhero__hud{ position:static; margin-top:1.9rem; padding-block:1rem 0; background:none }
}
@media(max-width:520px){
  .vhero__facts{ grid-template-columns:1fr 1fr }
  .vhero__facts li:last-child{ grid-column:1 / -1 }
  .vhero__facts b{ font-size:1.25rem }
}
@media(prefers-reduced-motion:reduce){
  /* The poster carries the hero. Nothing else in here moves either, or
     "reduced motion" would still leave a scan band and a pulse running. */
  .vhero__vid{ display:none }
  .vhero__scan{ display:none }
  .vhero__dot{ animation:none }
}

/* ============================================== CCTV: PREMISES + CUSTOM WORK
   Two blocks that exist only on cctv-smart.html.

   .prems is the "where these go" row. The artwork is neon line drawing on the
   page's own background rather than photographs, for the same reason the
   category tiles use the wireframes: a stock photo of a warehouse says nothing
   a drawing of a warehouse does not, and it drags a second, unrelated look onto
   a page that already has one. */
.prems{ list-style:none; margin:0; padding:0; display:grid;
        gap:clamp(.7rem,1.4vw,1.1rem);
        grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr)) }
.prem{ border:1px solid var(--line); border-radius:var(--r-sm); background:var(--panel);
       padding:1.3rem 1.3rem 1.4rem; display:flex; flex-direction:column; gap:.5rem;
       transition:transform .45s var(--ease),border-color .45s,background .45s }
.prem:hover{ transform:translateY(-5px); border-color:rgba(124,218,36,.32); background:var(--panel-2) }
/* The band height is a token because the image has to be capped against the
   same number, and a percentage cannot do it. Neither max-height:100% nor
   height:100% resolves here: the grid row is `auto`, so the grid area counts
   as indefinite for percentage resolution even though .prem__art has a fixed
   height, and the image falls back to its intrinsic size. That rendered the
   drawings 124px tall in a 78px band, overlapping the heading beneath - which
   only became visible the day real artwork existed to put in the slot. */
.prem__art{ --band:78px; height:var(--band); display:grid; place-items:center;
            margin-bottom:.3rem }
.prem__art img{ max-height:var(--band); max-width:70%; width:auto; height:auto;
                object-fit:contain;
                opacity:.85; transition:opacity .45s var(--ease),transform .45s var(--ease) }
.prem:hover .prem__art img{ opacity:1; transform:translateY(-3px) }
/* Empty until the artwork exists. A blank 78px band keeps the cards the same
   height as they will be once it lands, so the layout does not jump. */
.prem__art:empty,.prem__art:not(:has(img)){ opacity:.25 }
.prem h3{ font-size:1.02rem; font-weight:400; line-height:1.25; margin:0 }
.prem p{ margin:0; font-size:.84rem; color:var(--ink-3); line-height:1.5 }

/* .csols is the custom-solutions domain: four reasons a catalogue system fails.
   Photographs here, not drawings - these are specific pieces of hardware and
   the point is that they exist, which a line drawing cannot assert. White
   plates, same as the catalogue cards. */
.csols{ display:grid; gap:clamp(.8rem,1.6vw,1.2rem);
        grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr)) }
.csol{ display:flex; flex-direction:column; overflow:hidden;
       border:1px solid var(--line); border-radius:var(--r-sm); background:var(--panel);
       transition:transform .45s var(--ease),border-color .45s,background .45s }
.csol:hover{ transform:translateY(-6px); border-color:rgba(124,218,36,.34); background:var(--panel-2) }
.csol__img{ aspect-ratio:4/3; overflow:hidden; background:#fff;
            border-bottom:1px solid var(--line) }
.csol__img img{ width:100%; height:100%; object-fit:cover; display:block;
                filter:brightness(.955); transition:transform .6s var(--ease),filter .5s var(--ease) }
.csol:hover .csol__img img{ transform:scale(1.05); filter:brightness(1) }
/* A card with no photograph yet is text only (see cctv_custom() in build.py).
   The lime rule on top stands in for the image edge so the row still reads as
   four matching cards. */
.csols--text .csol{ border-top:2px solid rgba(124,218,36,.45) }
.csols--text .csol__b{ padding-top:1.25rem }
.csol__b{ padding:1.05rem 1.15rem 1.25rem; display:flex; flex-direction:column; gap:.45rem }
.csol__b h3{ font-size:1.02rem; font-weight:400; line-height:1.3; margin:0 }
.csol__b h3 em{ font-style:normal; color:var(--lime) }
.csol__b p{ margin:0; font-size:.84rem; color:var(--ink-3); line-height:1.55 }

/* ------------------------------------------- lawful-use notice -------------
   Sits directly above the Spy Cameras catalogue. Not styled as a warning: a red
   or yellow banner would make an ordinary customer feel accused, which is the
   opposite of the job. It reads as a statement of how the business works,
   which is what it is. */
.lawful{ display:flex; gap:.85rem; align-items:flex-start; margin:0;
         padding:clamp(1rem,2.2vw,1.35rem) clamp(1.1rem,2.4vw,1.5rem);
         border:1px solid var(--line); border-left:2px solid rgba(124,218,36,.55);
         border-radius:var(--r-sm); background:var(--panel);
         font-size:.92rem; line-height:1.6; color:var(--ink-2); max-width:88ch }
.lawful__i{ flex:none; color:var(--lime); font-size:1.05rem; line-height:1.45 }
.lawful a{ color:var(--lime); text-decoration:underline; text-underline-offset:3px;
           white-space:nowrap }

/* ------------------------------------------- inline glossary ---------------
   PTZ, NVR, AMC and "retention" were the four terms that actually stopped a
   non-technical visitor - she skimmed past PoE and UPS because the surrounding
   sentence glossed them. A dotted underline plus a title attribute is the whole
   mechanism: it costs nothing, it degrades to plain text, and on a phone, where
   there is no hover, the sentence still has to make sense without it. So the
   expansions are also written into the prose rather than living only in here. */
abbr[title]{ text-decoration:underline dotted rgba(124,218,36,.6);
             text-underline-offset:4px; cursor:help; text-decoration-thickness:1px }

/* ================================================== TACTICAL HELMET (PoC)
   The prototype photograph. Its own background is the same near-black as the
   page, so it is framed rather than plated - a border and a radius, and the
   picture bleeds into the page at its edges instead of sitting in a box. */
.poc{ margin:0; position:relative; border:1px solid var(--line);
      border-radius:var(--r); overflow:hidden; background:#050705 }
.poc img{ width:100%; height:auto; display:block }
/* The caption earns its place by naming what is in the picture. A helmet with
   two things strapped to it is hard to parse at a glance, and "camera on one
   rail, control box and antenna on the other" turns it from a dark object into
   a diagram. */
.poc figcaption{ margin:0; padding:.85rem clamp(1rem,2.4vw,1.4rem);
                 border-top:1px solid var(--line); background:rgba(255,255,255,.02);
                 font-size:.79rem; line-height:1.5; color:var(--ink-3) }

/* The proof-of-concept badge. Amber rather than lime: every other pill on the
   site is lime and means "this is what we do", and this one means something
   different enough that it should not look the same. */
.pill--poc{ border-color:rgba(255,186,72,.45); color:#ffcf8a;
            background:rgba(255,186,72,.09) }
.pill--poc::before{ background:#ffba48; box-shadow:0 0 9px #ffba48 }

/* Ten capabilities. auto-fit rather than a fixed five, so the row count follows
   the width instead of the design assuming a desktop - ten items wrap to 5+5,
   4+3+3, 3s or 2s without a breakpoint each. */
.caps{ list-style:none; margin:0; padding:0; display:grid;
       gap:clamp(.7rem,1.4vw,1rem);
       grid-template-columns:repeat(auto-fit,minmax(min(100%,190px),1fr)) }
.cap{ border:1px solid var(--line); border-radius:var(--r-sm); background:var(--panel);
      padding:1.15rem 1.15rem 1.3rem; display:flex; flex-direction:column; gap:.5rem;
      transition:transform .45s var(--ease),border-color .45s,background .45s }
.cap:hover{ transform:translateY(-4px); border-color:rgba(124,218,36,.3); background:var(--panel-2) }
.cap__i{ width:34px; height:34px; border-radius:50%; display:grid; place-items:center;
         background:rgba(124,218,36,.1); border:1px solid rgba(124,218,36,.28);
         margin-bottom:.35rem; flex:none }
.cap__i svg{ width:17px; height:17px; fill:none; stroke:var(--lime); stroke-width:1.6;
             stroke-linecap:round; stroke-linejoin:round }
.cap h3{ margin:0; font-size:.96rem; font-weight:400; line-height:1.3 }
.cap p{ margin:0; font-size:.82rem; line-height:1.5; color:var(--ink-3) }

/* ==========================================================================
   MOBILE PASS
   Everything above is written desktop-first with breakpoints where a component
   needed one. This block is the sweep afterwards, on a real 375px viewport,
   fixing what only shows up when the whole page is that width. Kept together
   rather than scattered so it can be read as one argument.
   ========================================================================== */

@media(max-width:620px){

  /* 15.5px body text is fine on a monitor a foot and a half away and thin on a
     phone at arm's length. 16px is the floor most platforms design to. */
  body{ font-size:16px }

  /* ~57px of padding above and below every section is a desktop rhythm. On a
     phone it is most of a screen of nothing between one idea and the next, and
     it makes an already long page longer. */
  .sec{ padding-block:clamp(2.4rem,7vh,3.4rem) }
  .sec--tight{ padding-block:clamp(1.8rem,5vh,2.6rem) }

  /* ---- footer ----------------------------------------------------------
     Two columns at 375px gave the brand paragraph about 150px to work in, so
     it broke to one word per line - "India's / only / manufacturer / of
     custom-" - and the email address ran out past its column. One column
     costs nothing here; the footer is the last thing on the page and nobody is
     scanning it in a hurry. */
  .foot__grid{ grid-template-columns:1fr; gap:2rem }
  .foot__grid > div:first-child{ max-width:46ch }

  /* Every footer link measured 20px tall. Fourteen of them, stacked 7px apart,
     is a column of targets a thumb cannot hit cleanly. Making each one a 44px
     row turns the gap between them into part of the target instead of a miss. */
  .foot ul{ gap:0 }
  .foot a{ display:flex; align-items:center; min-height:44px }
  /* The address block is not a link and should not be spaced like one. */
  .foot li:not(:has(a)){ padding-block:.35rem }
  /* Long enough to overflow its column at any width a phone has. */
  .foot a[href^="mailto:"]{ overflow-wrap:anywhere }

  /* ---- helmet capabilities ---------------------------------------------
     Ten cards at one per row filled three and a half phone screens for what is
     a scannable list. As rows - icon left, text right - the same ten fit in
     roughly one, and the icon column gives the eye something to run down. */
  .caps{ gap:.5rem }
  .cap{ display:grid; grid-template-columns:auto 1fr; align-items:start;
        column-gap:.9rem; row-gap:.2rem; padding:.9rem 1rem }
  .cap__i{ grid-row:1 / span 2; margin-bottom:0; width:30px; height:30px }
  .cap__i svg{ width:15px; height:15px }
  .cap h3{ font-size:.94rem }
  .cap p{ font-size:.83rem }

  /* There was a stray closing brace here. It ended the media query early, and
     everything below it - full-width hero buttons, centred button labels, the
     44px logo row - applied at every width. Measured on a 1280px desktop: the
     hero's "Request a quote" button was 1086px wide. Found while auditing the
     phone layout; the phone was fine, the desktop was the one that broke. */

  /* Buttons hug their label on desktop, which is right there and wrong here:
     two stacked pills of different widths in the middle of a phone screen read
     as an afterthought, and the narrower one is a smaller target for no reason.
     Full width, and the primary action keeps its order. */
  .hero__btns,.hero__btns .btn{ width:100% }
  .hero__btns{ gap:.6rem }
  .btn{ justify-content:center }

  /* The logo is a link home and measured 26px tall - the shortest standalone
     control on the page. Inline links inside a sentence are left alone on
     purpose: WCAG's target-size rule exempts them, and padding a link in the
     middle of a paragraph breaks the line rhythm to fix something that is not
     broken. The product cards look small too but are not - .prod__open::after
     is inset:0, so the whole 500px card is the tap target. */
  .nav__logo{ display:flex; align-items:center; min-height:44px }

  /* ==================================================================
     SECOND PASS - the phone as a first-class layout, not a collapsed one.
     Measured on 375x812 with every reveal forced in. The recurring finding:
     every grid on the site collapses to one column below its minmax floor,
     and one column of the desktop's cards is a long stack of near-identical
     rectangles. The desktop's density is part of what makes it feel designed;
     the phone was getting the content and none of the composition.
     ================================================================== */

  /* ---- catalogue: two columns ----------------------------------------
     Forty-two cards at 338x516, one per row, measured 27.3 phone screens -
     the whole page was 35. Two across, with the blurb dropped (the drawer
     carries the full text, and on the card it was a truncated sentence with
     an ellipsis anyway), is about 7. The white plates in a 2-up grid also
     read as a catalogue, which one enormous card after another did not. */
  .cat{ grid-template-columns:1fr 1fr; gap:.6rem }
  .prod__b{ padding:.7rem .75rem .8rem; gap:.3rem }
  .prod__b h3{ font-size:.9rem; line-height:1.2 }
  .prod__b p{ display:none }
  .prod__foot{ margin-top:.15rem; padding-top:.5rem; flex-wrap:wrap; row-gap:.2rem }
  .prod__note{ font-size:.68rem; letter-spacing:.1em }
  .prod__cta{ font-size:.74rem }
  .prod__tag{ font-size:.56rem; top:.45rem; left:.45rem; padding:.2rem .4rem; letter-spacing:.1em }
  /* The dashed "not on the list" tile is a different kind of thing from the
     forty around it and keeps the full width to say so. */
  .prod--custom{ grid-column:1 / -1 }
  .prod--custom .prod__b p{ display:block }

  /* ---- six-tile bentos: two columns ----------------------------------
     .bento--b is the 3x2 mosaic - six families, the six parts of a CCTV
     job, six AI capabilities. Below 820px every tile went full width at a
     210px minimum, so each mosaic measured 2.1-2.3 phone screens of stacked
     panels. Two columns keeps the mosaic. The tile's number, title and one
     sentence fit in 163px; the min-height goes because it was sized for the
     desktop row and on a phone it is just air under the text.
     A three-tile bento (the home page statistics) would leave one tile alone
     on the second row; :last-child:nth-child(odd) gives it the full width,
     same rule as the hero facts. */
  .bento--b{ grid-template-columns:1fr 1fr; gap:.6rem }
  .bento--b > *{ grid-column:span 1 }
  .bento--b > :last-child:nth-child(odd){ grid-column:1 / -1 }
  .bento--b .tile{ min-height:0; padding:.95rem .95rem 1.05rem; gap:.5rem }
  .bento--b .tile h3{ font-size:.98rem }
  .bento--b .tile p{ font-size:.82rem; line-height:1.45 }
  .bento--b .tile__n{ font-size:1.9rem }
  .bento--b .tile__go{ width:26px; height:26px }
  /* The concealment-family tiles carry a wireframe drawing in the corner.
     Half the width leaves it room, but not at desktop scale. */
  .bento--b .tile__art{ width:66%; height:60%; inset:auto -8% -10% auto }

  /* ---- premises and custom solutions: two columns ---------------------
     Same reasoning. Six premises with a drawing each and four custom-work
     cards with a photograph each are exactly the things a 2-up grid was
     invented for - the pictures do the talking and a phone visitor sees six
     drawings on one screen instead of scrolling through them one at a time. */
  .prems{ grid-template-columns:1fr 1fr; gap:.6rem }
  .prem{ padding:.95rem .9rem 1.05rem; gap:.4rem }
  .prem__art{ --band:62px }
  .prem__art img{ max-width:78% }
  .prem h3{ font-size:.95rem }
  .prem p{ font-size:.8rem; line-height:1.45 }
  .csols{ grid-template-columns:1fr 1fr; gap:.6rem }
  .csols--text{ grid-template-columns:1fr }
  .csol h3{ font-size:.95rem }
  .csol p{ font-size:.8rem; line-height:1.45 }

  /* ---- label floor -------------------------------------------------
     The breadcrumb measured 10.56px and the hero fact labels 11.52px. Those
     are letterspaced uppercase, which helps, but under 12px on a phone is a
     squint. 12px is the floor, and the tracking comes in a little so the
     longer labels still fit on one line. */
  .label,.vhero__facts span,.vhero__hudrow{ font-size:.75rem }
  .label{ letter-spacing:.14em }
  .vhero__crumb{ font-size:.75rem }
  /* Second sweep (2026-09-30), every page at 360px: the same 10.6px
     uppercase survived on the spec-list keys, the form labels, the footer
     headings and the product-strip captions, and the "optional" beside a
     label came out at 8.7px. Same floor for all of them. */
  .specs li b,.field label,.foot h5,.ostrip__i b,.prod__note{ font-size:.75rem }
  .specs li b,.field label{ letter-spacing:.1em }
  .field__opt{ font-size:.75rem }

  /* ---- the prototype photograph ---------------------------------------
     The one real photograph on the site, and the whole evidence for the page's
     claim, measured 339x181 on a phone - a letterbox strip of a 1.87:1 frame
     inside the page padding. The helmet sits in the centre of that frame with
     empty background either side, so a 4:3 crop loses nothing that matters
     and gives it 281px of height. The frame also runs edge to edge: the
     border and radius that make it read as framed on the desktop are the
     things that make it read as small here. */
  .poc{ margin-inline:calc(-1 * var(--pad)); border-inline:0; border-radius:0 }
  .poc img{ aspect-ratio:4/3; object-fit:cover; height:auto }
  .poc figcaption{ padding-inline:var(--pad) }
}

@media(max-width:400px){
  /* The narrowest phones still in use. Stops the headline from breaking a word
     across lines, which is the point at which a big display face stops looking
     confident and starts looking broken. */
  h1,h2{ hyphens:none; overflow-wrap:break-word }
  /* Set as the variable rather than as a bare padding so anything that
     bleeds out of the wrap by -var(--pad) (the prototype photograph) stays
     flush at this width instead of overhanging by the difference. */
  .wrap{ --pad:1.05rem; padding-inline:var(--pad) }
}

/* ------------------------------------------------------------------- map --
   The contact map is click-to-load (see site.js). Until someone asks for it
   the panel holds the address, so the section is useful with nothing fetched
   from Google at all. */
.map__ask{ display:flex; flex-direction:column; align-items:center; justify-content:center;
           gap:.85rem; height:100%; padding:1.6rem; text-align:center }
.map__addr{ margin:0; color:var(--ink-2); line-height:1.5 }
.map__note{ margin:0; font-size:.82rem; color:var(--ink-3); max-width:34ch }
.map__dir{ color:var(--lime); font-size:.9rem }
.map__go{ min-height:44px }

/* The founder slot holds a photograph now; the monogram rules above stay as
   the fallback for a missing file. The image is already circular-safe (cropped
   and graded square, subject centred), so it only has to fill the circle. */
.founder--photo{ background:none; overflow:hidden }
.founder--photo img{ width:100%; height:100%; object-fit:cover; border-radius:50%;
                     display:block }

/* ------------------------------------------------------------ touch -----
   On a touchscreen, whatever its width - tablets included - the footer links
   and the reviewer names measured 20-22px tall: under WCAG 2.2's 24px floor
   for a standalone target. Padding rather than line-height, so the text does
   not move and the gap between rows is what grows into the target. */
@media (pointer:coarse){
  .foot ul a{ display:inline-block; padding-block:.3rem }
  .foot ul{ gap:.1rem }
  .review__name{ display:inline-block; padding-block:.2rem }
  .map__dir{ display:inline-block; padding-block:.35rem }
}
