/* nface-web - the whole visual system, in one file.
 *
 * No framework, no build step, no CDN. A camera on a closed LAN has no internet, and a
 * page that needs to download something is a page that does not work where the product
 * actually runs. The web font below degrades to a system stack when it cannot load.
 *
 * There is not one threshold in this file and there must never be one. Colour encodes
 * STATUS only - never decoration - and the status always came from the camera.
 *
 * SIX spacings, FIVE text sizes, TWO figure sizes, TWO button heights, ONE left edge.
 * Every gap, padding and margin below is one of --s1..--s6, and no template sets any of
 * them inline: an eye that lands in the same place on all eighteen screens is the whole
 * point of the scale.
 */

/* ---------------------------------------------------------------- tokens */
:root {
  --bg:#0c0f13; --surface:#141920; --surface-2:#181e26; --flat:#11161c;
  --line:#232932; --ink:#eef1f5; --ink-2:#a4aebc; --ink-3:#6e7887;
  --accent:#4d9fff; --accent-solid:#2c6fd6;
  --strong:#3ddc97; --maybe:#f5b83d; --weak:#b892f2; --bad:#ff6b6b;
  --radius:16px; --radius-s:12px; --radius-xs:10px;

  /* the spacing scale - nothing between these steps */
  --s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:24px; --s6:32px;
  /* one left edge for the banner, the flash, the crumb, the h1 and every panel */
  --pad-x:32px; --measure:64ch;
  /* five text sizes */
  --t-h1:22px; --t-h2:15px; --t-body:14.5px; --t-sm:13px; --t-xs:11.5px;
  /* two figure sizes */
  --fig:28px; --fig-s:20px;

  /* The camera wall on the sign-in page, and nothing else, ever. It has five colours of
     its own because it is DARK IN EVERY PALETTE on purpose - a monitor wall is dark, and
     a light-mode CCTV feed reads as a diagram of one. A second palette redefines
     everything above and leaves these five alone. */
  --wall:#0a0e14; --wall-tile:#0f1723; --wall-figure:#05090f; --wall-ink:#dce7f4;
  /* the floor, twice: lighter where it meets the far wall, darker underfoot */
  --wall-floor:#22344a; --wall-deck:#16233a;
}

*,*::before,*::after { box-sizing: border-box; }
html,body { height:100%; }
body {
  margin:0; background:var(--bg); color:var(--ink);
  font-family:"IBM Plex Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
  font-size:var(--t-body); line-height:1.55; -webkit-font-smoothing:antialiased;
}
a { color:var(--accent); text-decoration:none; }
a:hover { color:#84bcff; }
h1,h2,h3,p { margin:0; }
.mono { font-family:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
        font-variant-numeric:tabular-nums; }
.muted { color:var(--ink-2); }
.faint { color:var(--ink-3); }
.sm { font-size:var(--t-sm); }
.xs { font-size:var(--t-xs); }
.b6 { font-weight:600; }
.row { display:flex; align-items:center; gap:var(--s3); }
.col { display:flex; flex-direction:column; }
.wrap { flex-wrap:wrap; }
.push { margin-left:auto; }
.hide { display:none !important; }
/* hidden means hidden: the browser's own [hidden] rule loses to any author display, so
   `.field { display:flex }` showed the search form's date fields while they were hidden */
[hidden] { display:none !important; }

/* the utilities that replace every inline style= the templates used to carry */
.stack { display:flex; flex-direction:column; gap:var(--s3); }
.stack-lg { gap:var(--s5); }
.tightgap { gap:1px; }
.hgap { display:flex; align-items:center; gap:var(--s3); flex-wrap:wrap; }
.stats { display:flex; flex-wrap:wrap; gap:var(--s6); }
.stat { display:flex; flex-direction:column; gap:var(--s1); }
.stat.atend { align-items:flex-end; text-align:right; }
.formrow { display:flex; flex-wrap:wrap; gap:var(--s4); align-items:flex-end; }
.formrow .field { flex:1; min-width:200px; }
.cardcol { display:flex; flex-direction:column; gap:var(--s4); height:100%; }
.ellipsis { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.grow { flex:1; }
.end { margin-top:auto; }
.measure { max-width:var(--measure); }
.ink { color:var(--ink); }
a.ink:hover { color:var(--accent); }
.centre { align-items:center; text-align:center; }
.idmono { font-size:12px; word-break:break-all; line-height:1.6; }
.brk { word-break:break-all; }

/* ---------------------------------------------------------------- shell */
.app { display:grid; grid-template-columns:250px 1fr; min-height:100vh; }
.rail { background:#101419; padding:var(--s5) var(--s4) var(--s5); display:flex;
        flex-direction:column; gap:var(--s6); }
.brand { display:flex; align-items:center; gap:var(--s3); padding:0 var(--s2); }
.mark { width:34px; height:34px; border-radius:11px; background:#17304d; flex:none;
        display:flex; align-items:center; justify-content:center; color:var(--accent); }
/* The mark is inlined (templates/_mark.html) and filled with currentColor, so this one
   line - and only this line - decides what colour the brand is. The file's own #3266a4
   never reaches a page: it would be a third blue on a console that has two. */
.mark svg { width:20px; height:20px; display:block; }
.brandname { font-size:17px; font-weight:600; letter-spacing:.02em; color:var(--ink); }
.navgroup { display:flex; flex-direction:column; gap:var(--s1); }
.nav { display:flex; align-items:center; gap:var(--s3); padding:var(--s3);
       border-radius:11px; color:var(--ink-2); font-weight:500; }
.nav svg { width:20px; height:20px; flex:none; color:#727c8b; }
.nav:hover { background:#161c24; color:var(--ink); }
.nav.on { background:#1b2634; color:var(--ink); }
.nav.on svg { color:var(--accent); }
.railfoot { margin-top:auto; display:flex; flex-direction:column; gap:var(--s2); }
.who { display:flex; align-items:center; gap:var(--s3); padding:var(--s3);
       border-radius:12px; background:#151a21; }
.avatar { width:32px; height:32px; border-radius:50%; background:#1f3350; flex:none;
          display:flex; align-items:center; justify-content:center; color:#8ac8ff;
          font-size:12px; font-weight:600; }
.who .col { min-width:0; flex:1; }
.signout { margin-left:auto; color:var(--ink-3); display:flex; }
.main { display:flex; flex-direction:column; min-width:0; }
.topbar { display:flex; align-items:center; gap:var(--s4);
          padding:var(--s5) var(--pad-x) var(--s1); flex-wrap:wrap; }
.h1 { font-size:var(--t-h1); font-weight:600; letter-spacing:-.015em; }
.subtitle { font-size:var(--t-sm); color:var(--ink-2); margin-top:var(--s1); }
.actions { margin-left:auto; display:flex; align-items:center; gap:var(--s3);
           flex-wrap:wrap; }
.content { padding:var(--s5) var(--pad-x) var(--s6); display:flex;
           flex-direction:column; gap:var(--s5); }
.narrow { max-width:940px; }

/* ---------------------------------------------------------------- surfaces */
.panel { background:var(--surface); border-radius:var(--radius); padding:var(--s5); }
.panel.flat { background:var(--flat); }
.panel.tight { padding:var(--s2); }
.head { display:flex; align-items:center; gap:var(--s3); margin-bottom:var(--s5);
        flex-wrap:wrap; }
.head.flush { margin-bottom:var(--s2); padding:var(--s3) var(--s3) 0; }
.head h2 { font-size:var(--t-h2); font-weight:600; }
.label { font-size:11px; letter-spacing:.1em; text-transform:uppercase;
         color:var(--ink-3); font-weight:600; }
.fig { font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace;
       font-variant-numeric:tabular-nums; font-size:var(--fig); font-weight:500;
       letter-spacing:-.02em; line-height:1.1; }
.fig.s { font-size:var(--fig-s); }
.fig.ok { color:var(--strong); } .fig.bad { color:var(--bad); }
/* .fig.accent went with the camera page's retention bar, 2026-09-14 (0.2.0 U1): it
   coloured "N days with video" and nothing else used it. */
.fig.weak { color:var(--weak); }
.tile { background:var(--surface); border-radius:var(--radius); padding:var(--s5);
        display:flex; flex-direction:column; gap:var(--s2); }
.g2 { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:var(--s5); }
.g3 { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:var(--s5); }
.g2.top,.g3.top { align-items:start; }
.g2.side { grid-template-columns:1fr 320px; }

/* ---------------------------------------------------------------- controls */
.btn { display:inline-flex; align-items:center; justify-content:center; gap:var(--s2);
       height:40px; padding:0 var(--s5); border-radius:var(--radius-s); font:inherit;
       font-size:var(--t-body); font-weight:500; cursor:pointer; border:0;
       background:var(--accent-solid); color:#f4f8ff; text-decoration:none; }
.btn:hover { background:#3d80e4; color:#f4f8ff; }
.btn svg { width:18px; height:18px; }
.btn.ghost { background:#1c222b; color:#d5dde7; }
.btn.ghost:hover { background:#232b36; color:var(--ink); }
.btn.quiet { background:transparent; color:var(--ink-2); }
.btn.quiet:hover { background:#181e26; color:var(--ink); }
.btn.danger { background:transparent; color:var(--bad); }
.btn.danger:hover { background:#241416; color:var(--bad); }
.btn.s { height:32px; padding:0 var(--s3); font-size:var(--t-sm);
         border-radius:var(--radius-xs); }
.btn.wide { width:100%; }
.btn[disabled],.btn.off { opacity:.4; pointer-events:none; }
.seg { display:inline-flex; background:#161b22; border-radius:var(--radius-s);
       padding:var(--s1); gap:3px; }
.seg a { padding:var(--s2) var(--s3); border-radius:9px; font-size:var(--t-sm);
         font-weight:500; color:var(--ink-2); }
.seg a.on { background:#26303c; color:var(--ink); }
.seg.start { align-self:flex-start; }
/* The results-page order toggle (0.2.6): Newest first / Best match. A segmented control,
   styled to match .seg, with the active choice shown via aria-pressed - which is also what
   static/common.js toggles when it reorders the cards in place. Without this block the
   buttons rendered bare, with no active state, and read as "the buttons do nothing". */
.order { display:inline-flex; background:#161b22; border-radius:var(--radius-s);
         padding:var(--s1); gap:3px; align-self:flex-start; margin-bottom:var(--s3); }
.order .btn-quiet { padding:var(--s2) var(--s3); border-radius:9px; font-size:var(--t-sm);
         font-weight:500; color:var(--ink-2); background:transparent; border:0; cursor:pointer;
         font-family:inherit; }
.order .btn-quiet:hover { color:var(--ink); }
.order .btn-quiet[aria-pressed="true"] { background:#26303c; color:var(--ink); }
input[type=text],input[type=email],input[type=number],input[type=datetime-local],
input[type=file],select,textarea {
  height:40px; padding:0 var(--s3); border-radius:var(--radius-s); background:var(--flat);
  color:var(--ink); border:1px solid #232a34; font:inherit; font-size:var(--t-body);
  width:100%; max-width:420px;
}
input[type=file] { padding:var(--s2) var(--s3); height:auto; }
input:focus,select:focus { outline:2px solid #2b5f9e; outline-offset:1px; }
.field { display:flex; flex-direction:column; gap:var(--s2); }
.field.wide { flex:1; min-width:220px; max-width:420px; }
.offscreen { position:absolute; left:-9999px; }

/* ---------------------------------------------------------- status language */
.pill { display:inline-flex; align-items:center; gap:7px; height:28px; padding:0 var(--s3);
        border-radius:999px; font-size:var(--t-sm); font-weight:600; }
.pill.strong { background:#123026; color:var(--strong); }
.pill.maybe  { background:#2e2612; color:var(--maybe); }
.pill.weak   { background:#262030; color:var(--weak); }
.pill.bad    { background:#2e1719; color:var(--bad); }
.pill.calm   { background:#1c222b; color:var(--ink-2); }
.dot { width:9px; height:9px; border-radius:50%; flex:none; }
.dot.strong{background:var(--strong)} .dot.maybe{background:var(--maybe)}
.dot.weak{background:var(--weak)} .dot.bad{background:var(--bad)}
.dot.calm{background:var(--ink-3)}

/* The confidence ring. Readable before a word is read; it is the raw score rescaled,
   and the colour is the camera's own verdict, never a bar applied here. */
.ring { position:relative; width:52px; height:52px; border-radius:50%; flex:none;
        display:grid; place-items:center;
        background:conic-gradient(var(--c,var(--strong)) calc(var(--p,0)*1%), #262d38 0); }
.ring > i { width:41px; height:41px; border-radius:50%; background:var(--surface);
            display:grid; place-items:center; font-style:normal; font-size:var(--t-sm);
            font-weight:600;
            font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace; }
.ring.big { width:76px; height:76px; }
.ring.big > i { width:61px; height:61px; font-size:var(--fig-s); }
.ring.strong { --c:var(--strong); } .ring.maybe { --c:var(--maybe); }
.ring.weak { --c:#7b6a95; }

/* ---------------------------------------------------------- sighting cards */
/* SIX IN A ROW on a normal screen, not three. A result page is a wall to scan, and 280px
   cards made the eye travel for a picture that is 160px of face. The arithmetic: at 1400px
   the content column is about 1,136px (viewport - the 200px rail - 2 x --pad-x), so six
   cards and five 16px gaps leave 176px each. auto-fill keeps it honest at other widths:
   more columns on a wide monitor, fewer on a laptop, and it never stretches one lonely card
   across the page. */
.shots { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr));
         gap:var(--s4); }
.shot { background:var(--surface); border-radius:var(--radius); overflow:hidden;
        display:flex; flex-direction:column; }
.shot .pic { position:relative; aspect-ratio:1; background:#1a212a; display:block; }
.shot .pic img { width:100%; height:100%; object-fit:cover; display:block; }
.shot .ring { position:absolute; right:var(--s3); top:var(--s3);
              background:conic-gradient(var(--c,var(--strong)) calc(var(--p,0)*1%),
                                        rgba(12,15,19,.75) 0); }
.shot .ring > i { background:#101519; }
.shot .body { padding:var(--s4); display:flex; flex-direction:column; gap:var(--s2);
              align-items:flex-start; }
.shot .body .when { font-size:var(--t-body); font-weight:600; }
.shot.dim .pic { filter:grayscale(1); opacity:.42; }
.shot.dim .body { opacity:.72; }
/* "Is it one of these?" (2026-09-26): one face per person a search found and could not
   vouch for, under its results - the visit card's look, without its ring and its strip. */
.offers { display:flex; flex-direction:column; gap:var(--s3); }
.offer { background:var(--surface); border-radius:var(--radius); overflow:hidden;
         display:flex; flex-direction:column; }
.offer .pic { position:relative; aspect-ratio:1; background:#1a212a; display:block; }
.offer .pic img { width:100%; height:100%; object-fit:cover; display:block; }
.offer .body { padding:var(--s4); display:flex; flex-direction:column; gap:var(--s2);
               align-items:flex-start; }
.offer .body .when { font-size:var(--t-body); font-weight:600; }

/* A face crop the camera has already deleted must not leave a broken image icon. */
.crop { border-radius:var(--radius-xs); background:#1a212a; display:block;
        object-fit:cover; }
.crop.square { width:100%; aspect-ratio:1; }
.crop.big { width:160px; height:160px; }
.crop-gone { display:grid; place-items:center; background:#1a212a; color:var(--ink-3);
             font-size:11px; border-radius:var(--radius-xs); }

/* ---------------------------------------------------------------- notes */
.note { display:flex; gap:var(--s3); padding:var(--s3) var(--s4); border-radius:13px;
        font-size:var(--t-sm); line-height:1.5; align-items:flex-start; }
.note svg { width:18px; height:18px; flex:none; margin-top:1px; }
.note.warn { background:#231c0e; color:#f0d49c; }
.note.bad  { background:#251416; color:#f3b5b5; }
.note.info { background:#111e2c; color:#b8d6f2; }
.note.ok   { background:#0f2219; color:#a8e5cb; }
.inset { margin-top:var(--s3); }
.spaced { margin-bottom:var(--s5); }
.spaced-top { margin-top:var(--s5); }
.under { margin-bottom:var(--s4); }

/* ---------------------------------------------------------------- figures */
.icon { width:52px; height:52px; border-radius:15px; background:#182231;
        color:var(--accent); display:grid; place-items:center; flex:none; }
.icon svg { width:24px; height:24px; }
.icon.ok { background:#123026; color:var(--strong); }
.icon.warn { background:#2e2612; color:var(--maybe); }
.icon.bad { background:#2e1719; color:var(--bad); }
.icon.s { width:38px; height:38px; border-radius:11px; }
.chev { color:#39424f; display:flex; align-items:center; }
.chev svg { width:22px; height:22px; }
.dash { border:1.5px dashed #2a323d; background:transparent; }
.kv { display:flex; justify-content:space-between; gap:var(--s4); padding:var(--s3) 0; }
.kv + .kv { border-top:1px solid #1c222a; }
.kvk { color:#8b95a3; font-size:var(--t-sm); }
.kvv { font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace; font-size:var(--t-sm);
       font-variant-numeric:tabular-nums; text-align:right; }
.kvv.ok { color:var(--strong); } .kvv.maybe { color:var(--maybe); }
.kvv.off { color:var(--ink-2); }
.bar { position:relative; height:12px; border-radius:6px; background:#232a34;
       overflow:hidden; }
.bar > span { position:absolute; left:0; top:0; bottom:0; border-radius:6px;
              background:var(--accent-solid); }

.tech-foot { margin-top:var(--s3); }

/* ---------------------------------------------------------------- login */
.split { display:grid; grid-template-columns:1.1fr .9fr; min-height:100vh; }
.split .left { padding:var(--s6) var(--s6); display:flex; flex-direction:column;
               gap:var(--s6); background:#101419; justify-content:space-between; }
.split .right { padding:var(--s6); display:flex; flex-direction:column;
                justify-content:center; gap:var(--s5); }
.split .card { max-width:390px; width:100%; }
.claim { font-size:30px; font-weight:600; letter-spacing:-.025em; line-height:1.2;
         max-width:480px; text-wrap:pretty; }
/* .claim + .sub went with the second line under the login claim: nothing carries
   class="sub" any more, so the rule could only ever match nothing. */
.btn.google { height:50px; background:#fff; color:#1c2028; }
.btn.google:hover { background:#e9edf3; color:#1c2028; }

/* The wall of simulated camera feeds on the sign-in page (templates/_wall.html).
 *
 * Everything below is CSS and inline SVG. Nothing is fetched, nothing is a photograph,
 * and nothing on it is a real face - see the comment at the top of that template for
 * why that is a decision and not a shortcut.
 *
 * IT IS DARK IN EVERY PALETTE, and that is deliberate: a monitor wall is dark, and a
 * light-mode CCTV feed reads as a diagram of one rather than the thing itself. So it
 * gets five tokens of its own (--wall*) instead of borrowing --surface, and a second
 * palette can leave all five exactly where they are. Everything else here - the accent
 * on the brackets, the ink on the caption, the radius, the spacing - is the console's.
 *
 * IT SITS ON THE PANEL AS A CARD, at --radius, with --s2 between tiles: the version
 * this was ported from was a full-bleed black panel, which in this console would be the
 * one surface with no edge. Same wall, this console's furniture.
 *
 * MOTION IS TRANSFORMS ONLY. The login page can sit open for hours on a kiosk, so every
 * animated property here is one the compositor can do on its own - transform and
 * opacity - and not one of them costs a layout. There is no JavaScript on this page at
 * all: no clock, no counter, no wall built at runtime.
 */
/* The wall is as tall as its tiles and no taller - it does NOT stretch to the panel.
   Stretched, a 900px-high window gave 190px of empty black above the grid and the same
   below it, which reads as a wall that failed to load. The left column spaces itself
   instead: brand at the top, wall in the middle, claim at the foot. */
.wall { flex:none; display:flex; flex-direction:column; gap:var(--s3);
        padding:var(--s4); border-radius:var(--radius); background:var(--wall);
        overflow:hidden; }
.wall-head { display:flex; align-items:center; gap:var(--s3); flex:none; }
.wall-title { font-size:var(--t-sm); font-weight:600; color:var(--wall-ink); }
.wall-live { margin-left:auto; display:flex; align-items:center; gap:var(--s2);
             font-family:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
             font-size:var(--t-xs); font-weight:600; letter-spacing:.11em;
             color:var(--ink-3); }
/* Red, because --bad is what this console paints "recording" and "cannot be undone"
   with, and a recording light has always been that colour anyway. */
.wall-dot { width:7px; height:7px; border-radius:50%; background:var(--bad); flex:none;
            animation:wall-pulse 1.8s ease-in-out infinite; }
@keyframes wall-pulse { 0%,100% { opacity:1 } 50% { opacity:.25 } }

/* Three across, two down, and the ROWS COME FROM THE TILE, not from the grid: a camera
   feed is landscape, and a tile stretched to fill a tall panel is a portrait box with a
   person standing in the middle of a lot of floor. So the tile keeps 16:10 and the grid
   centres whatever is left over. */
.wall-grid { display:grid; gap:var(--s2); grid-template-columns:repeat(3,1fr); }
.wall-tile { position:relative; min-width:0; aspect-ratio:16/10; overflow:hidden;
             border-radius:var(--radius-xs); background:var(--wall-tile);
             border:1px solid var(--line); container-type:inline-size;
             /* the three knobs every walker in this tile is offset by; see below */
             --wt:0s; --wk:1; --wx:0%; }
/* The scene. A far wall above and a floor below, and THE FLOOR IS THE LIGHTER OF THE
   TWO: a dark figure on a dark floor is not a silhouette, it is nothing, which is what
   the first attempt at this rendered.
   The horizon is at 38%, which is not a look - it is where it has to be. The walker
   standing furthest away is at bottom:57%, and a horizon any lower would leave him
   standing on the wall. Below it the floor fades back to the tile colour, so the
   picture has a near and a far instead of one flat slab. */
.wall-scene { position:absolute; inset:0;
              background:linear-gradient(var(--wall-tile) 0 38%,
                                         var(--wall-floor) 38%, var(--wall-deck) 100%); }
/* One soft light at the horizon. Without it the far wall is a flat dark band across the
   top of every tile and the six of them read as empty boxes. It is the accent at 12%,
   mixed rather than written, so it moves with the palette like everything else. */
.wall-scene::after { content:''; position:absolute; inset:0;
                     background:radial-gradient(ellipse at 50% 38%,
                       color-mix(in srgb, var(--accent) 12%, transparent), transparent 62%); }

/* A walker is a FULL-WIDTH STRIP with the figure pinned to its left edge, and the strip
   is what moves. That is the whole trick: translateX(104%) on a strip crosses the tile,
   while translateX(104%) on a 20px figure moves it 20px and reads as a twitch. */
.w-p { position:absolute; left:0; right:0; height:0; animation:w-walk linear infinite; }
@keyframes w-walk { from { transform:translateX(-16%) } to { transform:translateX(104%) } }
.w-fig { position:absolute; left:0; bottom:0; color:var(--wall-figure); }
.w-fig.flip { transform:scaleX(-1); }
.w-body { display:block; width:100%; height:100%; fill:currentColor;
          filter:drop-shadow(0 1px 2px rgba(0,0,0,.55)); }
/* A shadow on the floor. Without it the figures hover. */
.w-fig::after { content:''; position:absolute; bottom:-2px; left:-30%; width:160%;
                height:5px; border-radius:50%; background:rgba(0,0,0,.5);
                filter:blur(2px); }

/* Depth, size, speed and starting point, one rule per position in the tile. Higher up
   the frame is further away, so it is smaller and it takes longer to cross.
   Every duration and delay is multiplied and shifted by the TILE's --wk and --wt, so
   the same five rules run thirty different walkers and no two tiles fall into step -
   which is the giveaway that would make the whole wall read as one animation. */
.w-p:nth-child(1) { bottom:12%; animation-duration:calc(26s * var(--wk));
                    animation-delay:calc(var(--wt) - 1.6s); }
.w-p:nth-child(2) { bottom:33%; animation-duration:calc(38s * var(--wk));
                    animation-delay:calc(var(--wt) - 12s); }
.w-p:nth-child(3) { bottom:22%; animation-duration:calc(31s * var(--wk));
                    animation-delay:calc(var(--wt) - 6.4s); }
.w-p:nth-child(4) { bottom:46%; animation-duration:calc(47s * var(--wk));
                    animation-delay:calc(var(--wt) - 22s); }
.w-p:nth-child(5) { bottom:57%; animation-duration:calc(41s * var(--wk));
                    animation-delay:calc(var(--wt) - 33s); }
.w-p:nth-child(1) .w-fig { width:20px; height:48px; }
.w-p:nth-child(2) .w-fig { width:15px; height:36px; }
.w-p:nth-child(3) .w-fig { width:18px; height:43px; }
.w-p:nth-child(4) .w-fig { width:12px; height:29px; }
.w-p:nth-child(5) .w-fig { width:10px; height:24px; }

/* --wt shifts every walker's phase, --wk stretches every walker's speed, and --wx is
   where this tile's crowd stands when nothing is moving at all (see the reduced-motion
   block below - without it all six tiles freeze into the same photograph). */
.wall-tile:nth-child(1) { --wt:0s;   --wk:1;    --wx:0%; }
.wall-tile:nth-child(2) { --wt:-5s;  --wk:1.13; --wx:13%; }
.wall-tile:nth-child(3) { --wt:-11s; --wk:.87;  --wx:-9%; }
.wall-tile:nth-child(4) { --wt:-17s; --wk:1.24; --wx:21%; }
.wall-tile:nth-child(5) { --wt:-3s;  --wk:.94;  --wx:6%; }
.wall-tile:nth-child(6) { --wt:-8s;  --wk:1.07; --wx:-15%; }

/* The detection box: corner brackets, the way every tracker on earth draws them. It is
   a CHILD of the walker, so it tracks the person instead of hanging in the air beside
   them - and it fades in, holds, and lets go, because that is what detection on a
   moving crowd actually does.
   --accent and not --strong: green in this console is the strongest STATUS there is,
   and a box round a face means "found something", never "confirmed". There is no score
   printed in it either - this project publishes no precision at all. */
.w-det { position:absolute; top:-6%; left:-14%; width:128%; height:34%;
         border:1.5px solid var(--accent); border-radius:2px;
         animation:w-lock 6s ease-in-out infinite; }
.w-det::before, .w-det::after { content:''; position:absolute; width:5px; height:5px;
                                border:1.5px solid var(--accent); }
.w-det::before { top:-1.5px; left:-1.5px; border-right:0; border-bottom:0; }
.w-det::after { bottom:-1.5px; right:-1.5px; border-left:0; border-top:0; }
@keyframes w-lock { 0%,12% { opacity:0 } 20%,72% { opacity:1 } 86%,100% { opacity:0 } }
/* Only two positions carry a box, and they let go at different moments. */
.w-p:nth-child(2) .w-det { animation-delay:calc(var(--wt) - 2.3s); }
.w-p:nth-child(4) .w-det { animation-delay:calc(var(--wt) - 4.1s); }

/* Scanlines and a vignette. Cheap, and the difference between "a dark box" and "a
   screen": both are one painted gradient, neither animates. */
.wall-fx { position:absolute; inset:0; pointer-events:none;
           background:repeating-linear-gradient(rgba(255,255,255,.03) 0 1px,
                                                transparent 1px 3px);
           box-shadow:inset 0 0 40px rgba(0,0,0,.6); }

.wall-cap { position:absolute; left:0; right:0; bottom:0; display:flex;
            align-items:center; gap:var(--s2); white-space:nowrap; overflow:hidden;
            padding:var(--s1) var(--s2);
            background:linear-gradient(transparent, rgba(4,7,11,.92) 60%);
            font-family:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
            font-size:var(--t-xs); letter-spacing:.04em; }
.wall-cap .w-n { color:var(--wall-ink); font-weight:600; overflow:hidden;
                 text-overflow:ellipsis; min-width:0; }
/* The place gives way before the camera name: it is the only part of the caption that
   says nothing about what the wall is for. The name never gives way.
   The face count that used to sit on the right is gone - see _wall.html for why - which
   is also why the width rules below are gentler than they were. */
.wall-cap .w-loc { color:var(--ink-3); overflow:hidden; text-overflow:ellipsis;
                   min-width:0; flex-shrink:100; }
/* Measured against the TILE, not the window. With the count gone the caption needs far
   less room, but a narrow tile can still crowd "CAM 01 PLATFORM 3", so the place still
   goes first - at the width where it actually starts to bite rather than the old 210px,
   which was set by a number that is no longer there. */
@container (max-width:150px) { .wall-cap .w-loc { display:none; } }
/* And one step tighter: 900px is where the grid falls to two columns, so 901px is the
   narrowest three-across the wall can draw - the tile is 128px there. The caption gives
   up its padding and its gap before it gives up a name. */
@container (max-width:120px) { .wall-cap { gap:var(--s1); padding:var(--s1); } }

/* A wall of walking figures is precisely what this setting exists for. Everything
   stops - the walkers, the brackets, the recording light.
   Stopping an animation puts the element back at its own transform, which for every
   walker is none: sixty figures stacked on the left edge. So each position gets the
   place its phase would have put it, and the wall still says what it is - six cameras,
   people in them, boxes on some of them. It is a photograph of the wall. */
@media (prefers-reduced-motion:reduce) {
  .wall-dot, .w-p, .w-det { animation:none; }
  .w-p:nth-child(1) { transform:translateX(calc(9%  + var(--wx))); }
  .w-p:nth-child(2) { transform:translateX(calc(61% + var(--wx))); }
  .w-p:nth-child(3) { transform:translateX(calc(34% + var(--wx))); }
  .w-p:nth-child(4) { transform:translateX(calc(78% + var(--wx))); }
  .w-p:nth-child(5) { transform:translateX(calc(47% + var(--wx))); }
}

/* Below 900px the sign-in becomes ONE column and the wall is a band above it, not half
   the page. Six tiles there are six slivers, so it keeps four - and the caption keeps
   the camera and the face count, which are the two things it is for. */
@media (max-width:900px) {
  .wall { padding:var(--s3); }
  .wall-grid { grid-template-columns:repeat(2,1fr); }
  .wall-tile:nth-child(n+5) { display:none; }
  .wall-cap .w-loc { display:none; }
}

/* ---------------------------------------------------------------- misc */
.devbanner { margin:var(--s4) var(--pad-x) 0; }
.empty { display:flex; flex-direction:column; align-items:center; gap:var(--s3);
         padding:var(--s6) var(--s5); text-align:center; }
.empty .b6 { font-size:var(--t-h1); }
.empty.s { padding:var(--s5) var(--s3); }
@media (max-width:900px) {
  :root { --pad-x:20px; }
  .app { grid-template-columns:1fr; }
  .rail { flex-direction:row; align-items:center; gap:var(--s4);
          padding:var(--s3) var(--s4); overflow-x:auto; }
  .navgroup { flex-direction:row; }
  .railfoot { margin:0 0 0 auto; }
  .split { grid-template-columns:1fr; }
  .split .left,.split .right { padding:var(--s6) var(--s5); }
  /* One column, or a fixed count divides a phone into slivers: three tiles at 375px
     clipped "20,000,000" to "20,000" and squeezed a camera's name out of its card
     entirely. A figure that is not readable is not a figure. */
  .g2,.g3,.g2.side { grid-template-columns:1fr; }
}

/* live view - what the camera's analyser sees now */
.panel.live { margin-bottom:0; }
/* position:relative so the face-box overlay below can sit exactly on the picture. */
.live-frame { background:#0b0e12; border-radius:12px; overflow:hidden; aspect-ratio:16/9;
              display:flex; align-items:center; justify-content:center;
              margin:var(--s3) 0 var(--s2); position:relative; }
/* The <video> is sized here rather than left to its intrinsic size: the overlay reads
   clientWidth/clientHeight as the frame box and letterboxes the boxes inside it exactly
   as object-fit:contain letterboxes the picture. Same rule as the img before it. */
.live-frame img,.live-frame video { width:100%; height:100%; object-fit:contain;
                                    display:block; }

/* FACE BOXES over the live video. The camera publishes coordinates; this is the only
   place they become pixels. Nothing is drawn into the picture or the recording.
   pointer-events:none so the player's own controls stay clickable underneath. The svg's
   user units are CSS pixels of this box - common.js scales the frame's coordinates into
   it and sets x/y/width/height on each <rect>.

   --accent and not --strong, for the same reason as the sign-in wall's box below: green
   in this console is the strongest STATUS there is, and a box round a face means "found
   something", never "confirmed". No score is printed in it - this project publishes no
   precision at all. */
.live-boxes { position:absolute; inset:0; width:100%; height:100%; pointer-events:none; }
/* GREEN, and --strong is the console's own green (the "Live" pill is the same one), so
   the overlay is not a fourth colour somebody chose by eye. The thin dark outline under
   it is what keeps a green box readable over a pale desk or a white shirt - a single
   stroke disappears against a light background exactly where faces tend to be. */
.live-box { fill:none; stroke:var(--strong); stroke-width:2;
            paint-order:stroke; vector-effect:non-scaling-stroke;
            filter:drop-shadow(0 0 1px rgba(0,0,0,.9)); }
#segment-player { width:100%; border-radius:var(--radius); background:#07090c;
                  display:block; }

/* ------------------------------------------------------- search across cameras */
.camrow { padding:var(--s3) var(--s4); }
.camrow + .camrow { border-top:1px solid #1c222a; }
.btn.s svg { width:14px; height:14px; }

/* ------------------------------------------------------------------- sources */
.srcrow { padding:var(--s4) 0; }
.srcrow + .srcrow { border-top:1px solid #1c222a; }
.srcstats { gap:var(--s5); margin:var(--s2) 0 0 50px; font-size:var(--t-sm);
            color:var(--ink-2); }

/* The one player's popup ("Play visit", a timeline face's "Play"). Built by player.js; the
   video inside plays the camera's own recording straight from the camera (or its keyhole). */
.clip-modal { position:fixed; inset:0; z-index:50; background:rgba(5,7,10,.8);
              display:flex; align-items:center; justify-content:center; padding:var(--s5); }
.clip-box { background:var(--surface); border-radius:var(--radius); width:100%;
            max-width:880px; padding:var(--s5); display:flex; flex-direction:column;
            gap:var(--s3); }
.clip-box .head { margin-bottom:0; }
.clip-box .clip-title { font-size:var(--t-h2); font-weight:600; }
.clip-video { width:100%; aspect-ratio:16/9; border-radius:var(--radius-s);
              background:#07090c; display:block; }
/* A visit's faces (0.2.9 K9): a column beside the player, as tall as the player and its
   bar and never taller - `contain:size` keeps a thousand pictures from growing the row -
   scrolling on its own. The box widens by the column, so the video keeps its size. On a
   narrow screen (player.js NARROW_POPUP puts .narrow on the popup) the faces are a row
   under the video that scrolls sideways.
   ITS FLOOR IS THE VIDEO'S OWN HEIGHT, whether or not the video shows: the 16:9 of the
   stage's width (the row's, less the column and the gap - a container query, so it is
   exact). Hidden - while the pieces are asked for, or for good when the video is gone,
   which is ordinary since video is kept for less time than faces - the stage is one line
   high, and the column shrank to a sliver of one picture exactly when the pictures were
   all the popup had to show (review 2026-09-26). Three pictures where the query is not
   understood: never taller than the smallest wide stage playing. */
.clip-box.has-faces { max-width:1000px; }
.clip-main { display:flex; gap:var(--s4); container-type:inline-size; }
.clip-stage { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; gap:var(--s3); }
.clip-faces { flex:0 0 104px; contain:size; overflow-y:auto; display:flex;
              flex-direction:column; gap:var(--s2); position:relative;
              min-height:calc(3 * 104px + 2 * var(--s2));
              min-height:calc((100cqw - 104px - var(--s4)) * 9 / 16); }
.clip-face { flex:none; width:100%; aspect-ratio:1; padding:0; border:0; cursor:pointer;
             border-radius:var(--radius-xs); overflow:hidden; background:#07090c; }
.clip-face img { display:block; width:100%; height:100%; object-fit:cover; }
.clip-face:disabled { cursor:default; }
/* The face of the moment being played - an outline drawn INSIDE the picture, so the
   scrolling column never clips it. The same blue as a focused mark on the bar. */
.clip-face.on { outline:3px solid var(--accent); outline-offset:-3px; }
.clip-face:focus-visible { outline:2px solid #2b5f9e; outline-offset:-2px; }
.clip-face.on:focus-visible { outline:3px solid var(--accent); outline-offset:-3px; }
.clip-modal.narrow .clip-main { flex-direction:column; }
.clip-modal.narrow .clip-faces { flex:none; height:72px; min-height:0; flex-direction:row;
                                  overflow-x:auto; overflow-y:hidden; }
.clip-modal.narrow .clip-face { width:72px; height:72px; }
/* "Stop the current search?" - leaving a page whose search still streams (common.js
   wireLeaving). The clip's overlay and box, sized to one question and three buttons. */
.leave-box { max-width:480px; }

/* ------------------------------------------------------------------ timeline
   A single-series column chart in plain HTML. One slot per bucket, full width so the
   hover target is bigger than the mark; the mark itself is capped at 24px, rounded at
   its data end and square on the baseline, with a 2px gap between neighbours. Colour is
   the accent for "faces seen" and nothing else: a flat line is "watching, nobody
   there", hatching is "not watching" - never the same mark, never zero-height bars. */
/* minmax(0,1fr), not 1fr: a plain 1fr column grows to the chart's content width, and
   120 bars then push the chart out of its panel (seen while building this). */
.tl { display:grid; grid-template-columns:30px minmax(0,1fr); column-gap:var(--s2);
      row-gap:var(--s2); }
.tl-y { display:flex; flex-direction:column; justify-content:space-between;
        height:180px; text-align:right; line-height:1; }
.tl-chart { display:flex; align-items:stretch; height:180px; min-width:0;
            border-top:1px solid #1c222a; border-bottom:1px solid #2a323d; }
.tl-bar { position:relative; flex:1 1 0; min-width:0; display:block; }
/* Absolutely placed in its slot: 1px of air each side (the 2px gap), at most 24px wide,
   centred. A flex-sized mark with a percentage height kept its 24px and spilled into
   its neighbours at 120 bars. */
.tl-bar > span { position:absolute; bottom:0; left:1px; right:1px; max-width:24px;
                 margin:0 auto; border-radius:4px 4px 0 0; background:var(--accent-solid); }
/* On a phone 150 slots are ~2px each: the gap would eat the whole bar, so drop it. */
@media (max-width:600px) { .tl-bar > span { left:0; right:0; border-radius:1px 1px 0 0; } }
a.tl-bar:hover > span, a.tl-bar:focus-visible > span { background:var(--accent); }
a.tl-bar:focus-visible { outline:2px solid #2b5f9e; outline-offset:1px; }
.tl-bar.zero > span { height:2px !important; border-radius:0; background:#3a4452; }
.tl-bar.gap { background:repeating-linear-gradient(135deg, #1d242d 0 2px, transparent 2px 6px); }
.tl-bar.on { background:#15283d; border-radius:4px 4px 0 0; }
.tl-bar.on > span { background:var(--accent); }
.tl-bar[data-tip]:hover::after, .tl-bar[data-tip]:focus-visible::after {
  content:attr(data-tip); position:absolute; bottom:calc(100% + 8px); left:50%;
  transform:translateX(-50%); white-space:nowrap; pointer-events:none; z-index:5;
  background:#0b0e12; color:var(--ink); border:1px solid var(--line); border-radius:8px;
  padding:6px 10px; font-size:12px; font-family:"IBM Plex Mono",ui-monospace,Menlo,monospace; }
.tl-axis { display:flex; justify-content:space-between; }
.tl-key { gap:var(--s5); margin-top:var(--s4); }
.tl-swatch { display:inline-block; width:14px; height:10px; border-radius:3px;
             margin-right:7px; vertical-align:-1px; }
.tl-swatch.faces { background:var(--accent-solid); }
.tl-swatch.zero { height:2px; border-radius:0; vertical-align:3px; background:#3a4452; }
.tl-swatch.gap { background:repeating-linear-gradient(135deg, #3a4452 0 2px, transparent 2px 4px); }

/* The custom range, beside the three fixed windows. It sits in the same 32px row as
   .seg and .btn.s, so the head does not grow a second line on a laptop; below 900px it
   wraps onto its own line rather than squeezing the two fields into slivers. The global
   input rule sets width:100% and max-width:420px, which would give each field the whole
   row - overridden here, and only here. */
.tl-range { display:flex; align-items:center; gap:var(--s2); flex-wrap:wrap;
            background:#161b22; border-radius:var(--radius-s); padding:var(--s1) var(--s3); }
.tl-range.on { background:#1b2634; }
.tl-range input[type=datetime-local] { width:auto; max-width:none; height:32px;
                                       font-size:var(--t-sm);
                                       border-radius:var(--radius-xs); }
.tl-range label { white-space:nowrap; }

.faces { display:grid; grid-template-columns:repeat(auto-fill,minmax(128px,1fr));
         gap:var(--s4); }
.face { display:flex; flex-direction:column; gap:var(--s2); }
.face .btn { width:100%; }
.face .xs { text-align:center; }

/* ------------------------------------------------------------------- groups
   The group is the outermost layer of the console: its name heads the rail and starts
   every page's trail. Roles are shown as calm pills - colour here still means STATUS
   only, and a role is not a status. */
.crumbs { display:flex; align-items:center; flex-wrap:wrap; gap:7px;
          font-size:var(--t-sm); color:var(--ink-3); margin-bottom:var(--s1); }
.crumbs a { color:var(--ink-2); }
.crumbs a:hover { color:var(--ink); }
.crumbs .sep { color:#3a4452; }
.crumb-group { font-weight:600; }
.flash { margin:var(--s4) var(--pad-x) 0; }

.gswitch { position:relative; }
.gswitch > summary { list-style:none; cursor:pointer; display:flex; align-items:center;
                     gap:var(--s2); padding:var(--s3); border-radius:12px;
                     background:#151a21; }
.gswitch > summary::-webkit-details-marker { display:none; }
.gswitch > summary:hover { background:#19202a; }
.gswitch > summary .chev svg { transition:transform .15s ease; }
.gswitch[open] > summary .chev svg { transform:rotate(180deg); }
.gswitch .col { min-width:0; }
.gname { font-size:var(--t-h2); font-weight:600; color:var(--ink); overflow:hidden;
         text-overflow:ellipsis; white-space:nowrap; }
.gmenu { position:absolute; left:0; right:0; top:calc(100% + 6px); z-index:40;
         min-width:220px; background:#161c24; border:1px solid var(--line);
         border-radius:12px; padding:6px; display:flex; flex-direction:column; gap:2px;
         box-shadow:0 12px 30px rgba(0,0,0,.45); }
.gmenu form { margin:0; }
.gmenu .label { padding:var(--s1) var(--s2); }
.gitem { display:flex; align-items:center; justify-content:space-between; gap:var(--s2);
         width:100%; padding:9px 10px; border-radius:9px; background:transparent; border:0;
         color:var(--ink-2); font:inherit; font-size:var(--t-body); text-align:left;
         cursor:pointer; }
.gitem:hover { background:#1c2530; color:var(--ink); }
.gitem.on { background:#1b2634; color:var(--ink); }
.gitem-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:500; }

.mrow { padding:var(--s3); gap:var(--s3); }
.mrow + .mrow { border-top:1px solid #1c222a; }
.mrow form { display:flex; gap:var(--s2); align-items:center; margin:0; }
.mrow select { height:32px; width:auto; min-width:118px; font-size:var(--t-sm); }
.mrow .col { min-width:200px; flex:1; gap:1px; }
.pill.s { height:24px; padding:0 var(--s2); font-size:12px; }
.pills { display:flex; flex-wrap:wrap; gap:6px; }
.tbl-wrap { overflow-x:auto; }
table.tbl { width:100%; border-collapse:collapse; font-size:var(--t-sm); }
table.tbl th { text-align:left; padding:9px 12px; color:var(--ink-3); font-weight:500;
               font-size:12px; white-space:nowrap; }
table.tbl td { padding:11px 12px; border-top:1px solid #1c222a; vertical-align:top; }
table.tbl td.num,table.tbl th.num { text-align:right; }
table.tbl td.act { text-align:right; white-space:nowrap; }
table.tbl td.nowrap { white-space:nowrap; }
@media (max-width:900px) {
  .gmenu { position:fixed; left:12px; right:12px; top:72px; }
}

/* ---------------------------------------------------------- a visit's sightings */
/* The strip on a visit card (0.2.9 E2): the visit's length, and a mark per sighting - a
   face stored as an interval is as wide as it lasted. The same colour for every card:
   there is one pass mark, and the order of the cards says which is stronger. */
.shot .strip { position:relative; display:block; height:6px; border-radius:3px;
               background:#262d38; overflow:hidden; }
.shot .strip > i { position:absolute; top:0; bottom:0; min-width:2px;
                   background:var(--strong); border-radius:1px; }
.shot.dim .strip > i { background:var(--weak); }
/* The player's own bar, under the video: how far into the visit it is, and a mark at
   every sighting. A click goes to that moment of the recording. */
.clip-bar { position:relative; height:10px; border-radius:5px; background:#262d38;
            cursor:pointer; margin:var(--s2) 0; }
.clip-bar-fill { position:absolute; left:0; top:0; bottom:0; width:0;
                 background:#3a4a5e; border-radius:5px; pointer-events:none; }
/* A mark is a button: 12px wide to hit, 2px wide to see. A click goes to that sighting. */
.clip-bar .clip-mark { position:absolute; top:-4px; bottom:-4px; width:12px; margin-left:-6px;
                       padding:0; border:0; background:transparent; cursor:pointer; }
.clip-bar .clip-mark::before { content:""; position:absolute; left:5px; top:2px; bottom:2px;
                               width:2px; background:var(--strong); }
.clip-bar .clip-mark:hover::before, .clip-bar .clip-mark:focus-visible::before {
  left:4px; width:4px; }
.clip-bar .clip-mark:focus-visible { outline:2px solid #2b5f9e; outline-offset:1px; }

/* ---------------------------------------------------------- a search as it runs */
/* #page wraps a page's head and content so a streamed search can swap them whole; it
   draws nothing of its own. */
.page { display:contents; }
/* The search as it runs: ONE bar that only ever grows - 0 to 100 % by the questions the
   cameras have answered (0.2.10: one search, no phase 2) - moving slowly between what the
   cameras have said so far and never past the next thing they can say (static/common.js).
   A width and nothing else: no number, no word (section 12 rule 2). Accent while it runs,
   ink once it is over. */
.progress { background:var(--surface); border-radius:var(--radius); padding:var(--s4);
            display:flex; flex-direction:column; gap:var(--s3); }
.searchbar { position:relative; height:10px; border-radius:5px; background:#1c222b;
             overflow:hidden; }
.searchbar i { position:absolute; left:0; top:0; bottom:0; background:#3a4a5e;
               transition:width .25s linear; }
.progress[data-state="running"] .searchbar i { background:var(--accent); }
.progress-cams { display:flex; flex-direction:column; gap:var(--s1); }
/* A camera that stopped part way, said while the answer is still arriving. Empty, it
   takes no room: the content column's gap would otherwise open a hole for nothing. */
#stream-lines { display:flex; flex-direction:column; gap:var(--s2); }
#stream-lines:empty { display:none; }
.progress[data-state="done"] .pill, .progress[data-state="stopped"] .pill { transition:none; }
.progress[data-state="stopped"] .searchbar i { transition:none; }
@media (prefers-reduced-motion:reduce) { .searchbar i { transition:none; } }
@media (max-width:600px) {
  .progress { padding:var(--s3); }
}

/* ---------------------------------------------------------- describe search (0.2.11)
   An experiment, drawn only while NFACE_DESCRIBE_ON is set. Its pictures are a whole
   person at a moment - not a square face - so they are shown whole. */
.shot.whole .pic { aspect-ratio:3/4; overflow:hidden; }
.shot.whole .pic img { object-fit:contain; }
