:root{
  --bg:#110F10; --card:#1A1918; --surface:#232221; --surface2:#2A2827;
  --accent:#D8BD71; --accent-rgb:216,189,113;
  --red:#D73D51; --red-rgb:215,61,81;
  --green:#22C55E; --green-rgb:34,197,94;
  --blue:#60A5FA; --orange:#FB923C; --violet:#A78BFA;
  --text:#F5F0EB; --muted:#A09B96; --border:#2E2D2C;
  --r:12px; --rs:8px;
}
*{margin:0;padding:0;box-sizing:border-box}
/* `hidden` means hidden. The browser's own `[hidden]{display:none}`
   lives in the UA stylesheet, and ANY author rule beats it -- so a
   component that sets its own `display` (`.ctl-tip` is `display:flex`)
   stays on screen with the attribute set, and `el.hidden = true` in
   app.js silently does nothing. That is what left the strip's hover
   card stuck on the page after the pointer had gone. */
[hidden]{display:none !important}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
  background:var(--bg);color:var(--text);min-height:100vh;padding-top:56px;font-size:14px;line-height:1.45}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}

/* header */
.hdr{position:fixed;top:0;left:0;right:0;height:56px;z-index:100;background:var(--card);
  border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;padding:0 16px;overflow-x:auto}
.brand{color:var(--accent);font-weight:700;text-transform:uppercase;letter-spacing:1px;font-size:15px;white-space:nowrap}
.brand{display:inline-flex;align-items:center}
.brand img{height:15px;width:auto;display:block}
.brand small{color:var(--muted);font-weight:400;letter-spacing:0;text-transform:none;font-size:11px;margin-left:6px}
/* Sits beside the brand with a gap, ahead of the sections: the control
   room is where the portal opens, and the sections are where you go
   from it. Styled like a nav pill so the header still reads as one row
   of controls. */
.hdr-control{margin-left:14px;padding:6px 11px;border-radius:999px;color:var(--muted);font-size:12.5px;
  font-weight:600;text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;border:1px solid transparent}
.hdr-control:hover{background:var(--surface);color:var(--text);text-decoration:none}
.hdr-control.on{color:var(--text);background:rgba(var(--accent-rgb),.15);border-color:rgba(var(--accent-rgb),.45)}
.nav{display:flex;gap:4px;align-items:center;margin-left:auto}
.nav a{padding:6px 11px;border-radius:999px;color:var(--muted);font-size:12.5px;font-weight:600;
  text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;border:1px solid transparent}
.nav a:hover{background:var(--surface);color:var(--text);text-decoration:none}
.nav a.on{color:var(--text);background:rgba(var(--accent-rgb),.15);border-color:rgba(var(--accent-rgb),.45)}
.nav a.live.on{background:rgba(var(--red-rgb),.15);border-color:rgba(var(--red-rgb),.45)}
.nav a.acc{color:var(--text);text-transform:none;letter-spacing:0;font-weight:500;font-size:13px}
.dot{width:7px;height:7px;border-radius:50%;background:var(--red);display:inline-block;margin-right:5px;
  box-shadow:0 0 6px rgba(var(--red-rgb),.7);animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}

/* layout */
.wrap{max-width:1320px;margin:0 auto;padding:18px 16px 60px}
/* A page whose table is the point (trainings): as wide as its TABLE needs,
   never narrower than the usual column, never wider than the window (then
   the table scrolls in .tw). Only the table sizes the page: every other
   block is contain:inline-size, so a filter bar that would happily sit on
   one long line does not stretch it -- it fills whatever width the table
   chose. */
.wrap.full{max-width:100%;width:max-content;min-width:min(1320px,100%);box-sizing:border-box}
.wrap.full>:not(.tw){contain:inline-size}
h1{font-size:20px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
/* An id inside a heading keeps its own case and spacing. Uppercasing a
   UUID and then letter-spacing it turns the one string on the page a
   person might compare by eye into a smear: "01A0BF7E-62B7-7928". */
h1 .mono{text-transform:none;letter-spacing:0;font-size:17px;font-weight:600}
h2{font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin:26px 0 12px}
h3{font-size:13px;font-weight:600;color:var(--muted);margin:0 0 8px}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px}
.crumbs{font-size:13px;color:var(--muted);margin-bottom:12px;display:flex;gap:6px;flex-wrap:wrap;align-items:center}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.spread{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;margin-bottom:12px}

/* demo ribbon */
.demo{background:rgba(var(--accent-rgb),.1);border:1px solid rgba(var(--accent-rgb),.35);color:var(--accent);
  border-radius:var(--rs);padding:8px 12px;font-size:12.5px;margin-bottom:18px}

/* controls */
.bar{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:14px;margin-bottom:16px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px}
label.f{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:5px;font-weight:600}
input,select,textarea{width:100%;min-width:0;padding:8px 10px;background:var(--surface);border:1px solid var(--border);
  border-radius:var(--rs);color:var(--text);font-size:13.5px;font-family:inherit}
input:focus,select:focus{outline:none;border-color:rgba(var(--accent-rgb),.6)}
select[multiple]{height:92px}
.btn{padding:8px 15px;background:var(--accent);color:#191817;border:none;border-radius:var(--rs);
  font-weight:600;font-size:13.5px;cursor:pointer;font-family:inherit;white-space:nowrap}
.btn:hover{opacity:.9}
.btn.ghost{background:transparent;color:var(--accent);border:1px solid rgba(var(--accent-rgb),.5)}
.btn.ghost:hover{background:rgba(var(--accent-rgb),.1)}
.btn.danger{background:transparent;color:var(--red);border:1px solid rgba(var(--red-rgb),.5)}
.btn.danger:hover{background:rgba(var(--red-rgb),.12)}
.btn.sm{padding:5px 10px;font-size:12px}
/* A disabled button must LOOK disabled. `disabled` only removes the
   interaction: without these rules a greyed-out control kept the accent
   text, the accent border, the pointer cursor and -- because CSS :hover
   still matches disabled elements -- the .btn.ghost:hover highlight, so
   it was indistinguishable from the live link beside it. Both rules tie
   on specificity with .btn.ghost / .btn.ghost:hover and win on SOURCE
   ORDER, so they must stay below them. */
.btn:disabled,.btn[disabled]{background:transparent;color:var(--muted);border:1px solid var(--border);
  cursor:not-allowed;opacity:.6}
.btn:disabled:hover,.btn[disabled]:hover{background:transparent;opacity:.6}
.seg{display:inline-flex;background:var(--surface);border:1px solid var(--border);border-radius:999px;padding:2px}
.seg button{background:none;border:none;color:var(--muted);padding:5px 13px;border-radius:999px;cursor:pointer;
  font-size:12.5px;font-weight:600;font-family:inherit;white-space:nowrap}
.seg button.on{background:rgba(var(--accent-rgb),.18);color:var(--text)}
/* The same toggle made of links (the Control room's curve split): looks
   identical to the button one, so a toggle reads as a toggle. */
.seg a{color:var(--muted);padding:5px 13px;border-radius:999px;font-size:12.5px;font-weight:600;text-decoration:none;white-space:nowrap}
.seg a.on{background:rgba(var(--accent-rgb),.18);color:var(--text)}
.chk{display:flex;align-items:center;gap:7px;font-size:13px;color:var(--text);padding:3px 0;cursor:pointer}
/* `flex:none`: a box beside a label that wraps to two lines (a person with a
   long gym list) keeps its size -- as a flex item it would otherwise give
   its width to the text and shrink to a dot. */
.chk input{width:auto;margin:0;flex:none}
/* The footage request form's rings (footage_requests.html): ticks side by side. */
.footreq-rings{display:flex;flex-wrap:wrap;gap:2px 22px}
/* The check talks to every camera (a few seconds): say so while it runs. */
.footreq-busy{display:inline-flex;align-items:center;gap:8px;font-size:13px;color:var(--muted)}
.footreq-busy .spin{width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);
  border-radius:50%;animation:spin .8s linear infinite}
/* A result for a form that has since changed is not the answer to it. */
.footreq-stale{display:none;margin:0}
.footreq-result.stale .footreq-stale{display:inline}
.footreq-result.stale table,.footreq-result.stale .demo{opacity:.45}
.footreq-result.stale .footreq-queue button[type=submit]{display:none}
.chklist{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:2px 14px;
  background:var(--surface);border:1px solid var(--border);border-radius:var(--rs);padding:10px;max-height:180px;overflow-y:auto}

/* tables */
.tw{overflow-x:auto;border:1px solid var(--border);border-radius:var(--r);background:var(--card)}
table{width:100%;border-collapse:collapse;font-size:13.5px;min-width:600px}
th{text-align:left;padding:9px 13px;background:var(--surface);color:var(--muted);font-size:11px;font-weight:600;
  text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:9px 13px;border-bottom:1px solid var(--border);white-space:nowrap}
tr:last-child td{border-bottom:none}
tbody tr:hover{background:var(--surface)}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted)}
.dim{color:var(--muted)}
/* The trainings list's ID column: the whole uuid, as ONE text node, on
   ONE line, shown in full. The page sizes itself to the table
   (.wrap.full), so a wide screen has the room; a narrow one scrolls the
   table (.tw) rather than wrapping or eliding the id. One node matters:
   in-page search matches the full id and a drag-select copies all 36
   characters with nothing inserted. The <bdi> and the title are kept from
   the elided variant: harmless, and the title still names the id. */
.idlink{display:inline-block;white-space:nowrap;vertical-align:bottom}
.idlink bdi{direction:ltr;unicode-bidi:isolate}
.idc .copy{background:none;border:0;color:var(--muted);cursor:pointer;font-size:13px;
  padding:0 0 0 6px;vertical-align:bottom;line-height:1}
.idc .copy:hover{color:var(--text)}
.idc .copy.ok{color:var(--green)}

/* badges */
.b{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10.5px;font-weight:700;
  text-transform:uppercase;letter-spacing:.5px}
.b.gold{background:rgba(var(--accent-rgb),.15);color:var(--accent)}
.b.green{background:rgba(var(--green-rgb),.15);color:var(--green)}
.b.red{background:rgba(var(--red-rgb),.15);color:var(--red)}
.b.grey{background:var(--surface2);color:var(--muted)}
.b.blue{background:rgba(96,165,250,.15);color:var(--blue)}
.b.orange{background:rgba(251,146,60,.15);color:var(--orange)}
.b.violet{background:rgba(167,139,250,.15);color:var(--violet)}

/* cards -- a page's facts are a `.kvcard` (see the ANCHOR further down),
   not a grid of tiles: the `.meta`/`.mc` tiles are gone from every page */
.panel{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:16px;margin-bottom:16px}
/* One link, meant to be copied into a message: the label above it in
   the same small caps the meta cells use, the URL on its own line so a
   double-click takes the whole of it, and the note about who it works
   for underneath rather than trailing off the end of the line. */
.copyline{background:var(--surface);border:1px solid var(--border);border-radius:var(--rs);
  padding:10px 12px;margin-bottom:14px}
.copyline .k{font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:4px}
.copyline a{font-size:13px;word-break:break-all}
.copyline .note{margin-top:4px}

/* collapsible */
.blk{border:1px solid var(--border);border-radius:var(--r);background:var(--card);margin-bottom:14px;overflow:hidden}
/* `.blk` clips so the table inside it follows the rounded corner --
   and a POPOVER inside it is clipped too. The participants picker's
   panel opened 119px tall with 17 names in it and was cut to a sliver,
   which is how it shipped: the DOM said the options were there, and
   the screen showed an empty box. A block that holds a popover opts
   out; its corners go square under the table, which nobody will miss.
   Every other multiselect in the portal lives in a filter row, which
   is why this is the first one to hit it. */
.blk.pops{overflow:visible}
.blk>summary{padding:13px 16px;cursor:pointer;list-style:none;display:flex;justify-content:space-between;
  align-items:center;gap:10px;font-weight:600;font-size:14px;background:var(--surface)}
.blk>summary::-webkit-details-marker{display:none}
.blk>summary::after{content:"▾";color:var(--muted);font-size:12px}
.blk[open]>summary::after{content:"▴"}
.blk .body{padding:14px 16px}
.blk .body>.tw{border-radius:var(--rs)}

/* thumbs */
.files{display:grid;grid-template-columns:repeat(auto-fill,minmax(212px,1fr));gap:12px}
.file{background:var(--card);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;cursor:pointer}
.file:hover{border-color:rgba(var(--accent-rgb),.5)}
.file .th{position:relative;aspect-ratio:16/9;background:#000;display:block}
a.file{color:inherit;text-decoration:none}
/* photo viewer (app.js): left half under the header; the page shifts right */
.pv{display:none;position:fixed;left:0;top:56px;bottom:0;width:50vw;z-index:90;background:#000;flex-direction:column}
body.pv-open .pv{display:flex}
body.pv-open .wrap{margin-left:50vw;max-width:none}
.pv img{flex:1;min-height:0;width:100%;object-fit:contain}
.pv-bar{display:flex;gap:8px;align-items:center;padding:8px 12px;background:var(--card);border-bottom:1px solid var(--border)}
.pv-t{flex:1;font-size:13px}
.pv-nav{position:absolute;top:50%;left:8px;width:40px;height:56px;border:none;border-radius:var(--rs);
  background:rgba(0,0,0,.5);color:#fff;font-size:30px;cursor:pointer}
.pv-nav.nx{left:auto;right:8px}
.rec[data-photo]{cursor:pointer}
.file.sel,.rec.sel{outline:2px solid var(--accent);outline-offset:-2px}
@media(max-width:760px){.pv{top:0;width:100vw;z-index:150} body.pv-open .wrap{margin-left:0}}
.file .th img,.file .th video{width:100%;height:100%;display:block;object-fit:cover}
.file .dur{position:absolute;right:6px;bottom:6px;background:rgba(0,0,0,.75);color:#fff;font-size:11px;
  padding:1px 5px;border-radius:3px;font-family:ui-monospace,Menlo,monospace}
.file .cap{padding:8px 10px;font-size:12.5px;display:flex;justify-content:space-between;gap:8px}
.file .cap .s{color:var(--muted);font-size:11.5px}
.folders{display:grid;grid-template-columns:repeat(auto-fill,minmax(168px,1fr));gap:11px;margin-bottom:8px}
.fold{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:14px;font-size:14px}
.fold:hover{border-color:rgba(var(--accent-rgb),.5)}
.fold .n{font-weight:600;margin-bottom:3px}
.fold .c{color:var(--muted);font-size:12px}
.grp{margin:18px 0 9px;font-size:12px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);
  border-bottom:1px solid var(--border);padding-bottom:5px}

/* live */
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}
.tile{position:relative;background:#000;border:1px solid var(--border);border-radius:var(--r);overflow:hidden;
  aspect-ratio:16/9;cursor:pointer}
.tile img,.tile video{width:100%;height:100%;display:block;object-fit:cover}
.tile .lbl{position:absolute;left:8px;bottom:8px;background:rgba(0,0,0,.7);padding:2px 7px;border-radius:4px;font-size:11.5px}
.tile .pill{position:absolute;right:8px;top:8px;background:rgba(0,0,0,.7);padding:2px 7px;border-radius:4px;
  font-size:10.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px}
/* `>` on every panel selector, not tidiness: the pill is a direct child of
   .tile too, so `.tile .over` / `.tile .err` also matched the badge. See
   the live-tile-states block below. */
.tile>.over{position:absolute;inset:0;background:rgba(10,9,9,.82);display:flex;flex-direction:column;
  align-items:center;justify-content:center;gap:9px;text-align:center;padding:14px}
.capnote{font-size:12.5px;color:var(--muted);margin:10px 0 4px}

/* modal */
.mask{position:fixed;inset:0;background:rgba(0,0,0,.78);z-index:200;display:none;align-items:center;justify-content:center;padding:16px}
.mask.show{display:flex}
.modal{background:var(--card);border:1px solid var(--border);border-radius:var(--r);max-width:560px;width:100%;
  padding:20px;max-height:90vh;overflow-y:auto}
.modal h3{font-size:16px;color:var(--text);text-transform:none;margin-bottom:10px}
.modal .foot{display:flex;gap:9px;justify-content:flex-end;margin-top:18px;flex-wrap:wrap}
.big{max-width:1000px}
.big video{width:100%;height:auto;border-radius:var(--rs);display:block;background:#000}
/* the recording player's playlist mode (_base.html #play, app.js plOpen):
   video + day rail on the left, the recordings list on the right. With
   one recording both stay hidden and .play-body is just the video. */
.playmod{max-width:1180px}
/* The frame must not breathe when the playlist advances: between src
   swap and loadedmetadata a video has no intrinsic size and the box
   would collapse, then re-grow -- so the player's box is a fixed 16:9
   (every ring camera's shape) and an odd clip letterboxes inside it
   instead of reshaping the modal. */
.playmod video{aspect-ratio:16/9;width:100%;height:auto;object-fit:contain}
.play-body{display:flex;gap:14px;align-items:flex-start}
.play-main{flex:1;min-width:0}
.play-list{width:234px;max-height:66vh;overflow-y:auto;flex:none;
  display:flex;flex-direction:column;gap:4px;padding-right:2px}
.pl-item{display:flex;gap:8px;align-items:center;padding:5px;border-radius:var(--rs);cursor:pointer;
  border:1px solid transparent}
.pl-item:hover{background:var(--surface)}
.pl-item.cur{background:var(--surface);border-color:var(--accent)}
.pl-item img,.pl-ph{width:74px;aspect-ratio:16/9;object-fit:cover;border-radius:4px;background:#000;flex:none}
.pl-meta{font-size:12px;line-height:1.35;min-width:0}
.play-range{display:flex;gap:12px;align-items:center;margin-left:auto;margin-right:12px;
  font-size:12px;color:var(--muted)}
.play-range input{background:var(--surface);border:1px solid var(--border);border-radius:var(--rs);
  color:var(--text);padding:3px 6px;font-size:12px;width:64px;text-align:center;
  font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.play-range input.bad{border-color:var(--red)}
.play-ctl{display:flex;justify-content:flex-start;align-items:center;gap:6px;margin-top:8px;
  font-size:12px;color:var(--muted)}
.play-ctl label{display:flex;align-items:center;gap:6px}
.play-rate{background:var(--surface);border:1px solid var(--border);border-radius:var(--rs);
  color:var(--text);padding:2px 5px;font-size:11px;width:auto;flex:none}
.pl-none{padding:14px 6px}
.play-train{display:flex;gap:14px;align-items:center;flex-wrap:wrap;margin-top:8px;font-size:12.5px}
.pt-item{display:inline-flex;gap:8px;align-items:center}
.play-bar{margin-top:10px;padding-bottom:6px}
.pb-track{position:relative;height:16px;background:var(--surface);border-radius:8px;cursor:pointer;
  touch-action:none}
.pb-seg{position:absolute;top:4px;bottom:4px;background:var(--accent);opacity:.45;border-radius:3px}
.pb-seg.cur{opacity:1}
.pb-head{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--text);border-radius:1px;
  pointer-events:none}
.pb-tip{position:absolute;top:calc(100% + 4px);transform:translateX(-50%);pointer-events:none;z-index:2;
  background:var(--surface2);border:1px solid var(--border);border-radius:var(--rs);
  padding:3px 8px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;
  color:var(--text);white-space:pre-line;text-align:center;line-height:1.45}
.pb-times{display:flex;justify-content:space-between;font-size:11px;color:var(--muted);margin-top:4px;gap:8px}
.pb-now{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text)}
@media (max-width: 900px){
  .play-body{flex-direction:column}
  .play-list{width:100%;max-height:30vh}
}

/* misc */
.note{font-size:12.5px;color:var(--muted);margin:8px 0}
.kv{display:flex;gap:8px;font-size:13.5px;padding:6px 0;border-bottom:1px solid var(--border)}
.kv:last-child{border:none}
.kv .k{color:var(--muted);min-width:170px}
.pillrow{display:flex;gap:6px;flex-wrap:wrap}
.empty{text-align:center;padding:30px;color:var(--muted)}
.idx{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:12px}
.idx a{display:block;background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:14px}
.idx a:hover{border-color:rgba(var(--accent-rgb),.5);text-decoration:none}
.idx .t{font-weight:600;color:var(--text);margin-bottom:3px}
.idx .d{font-size:12px;color:var(--muted)}

/* An unbreakable string must never be able to widen the page -- and where
   it cannot widen the page, must never be silently amputated instead.
   These carry raw user/system values with no spaces in them: `.mc .v` an
   email (user.html) / a raw id (training.html) / an ip:port (camera.html),
   `.kv` the same kind of thing, `.crumbs` a path segment, `.chk` a gym or
   user label in a filter list, and the four archive folder cells that
   render an admin-typed gym or ring name -- `.fold .n` / `.fold .c` (grid
   tile name and caption) and `.foldrow .fn` / `.foldrow .fm` (list row
   name and ring).

   Everything up to `.chk` sits in no `.tw` scroll container, so an
   over-long value there pushes the BODY's scroll width and the whole page
   scrolls sideways. The two archive halves fail DIFFERENTLY, and it is
   worth being exact about which, because the difference is invisible in
   the markup:
   - GRID (`.fold ...`): `.folders` is a grid with a hard `minmax(168px,1fr)`
     track and no overflow containment, so a long name overflows its track
     and widens the body exactly like the selectors above.
   - LIST (`.foldrow ...`): `.foldlist` is `overflow:hidden` (folder list
     block below), which establishes a BFC and clips -- so the page stays
     put and the tail of the name just disappears, with no ellipsis to say
     that it did. Quieter, not better, and still worth guarding.

   All four MUST stay listed together whatever the reason differs: they
   render the SAME admin-typed value (the gym/ring name fields carry no
   maxlength) and the account's saved `archive.view` decides which pair it
   lands in, so dropping either pair leaves half the accounts on the broken
   rendering -- and the seeded test data is far too short for anything but
   tests/test_responsive_shell.py to catch it.

   NOT inside a media query, and `anywhere` rather than `break-word`, both
   on purpose:
   - not media-scoped, because the narrowest box is not always the phone.
     a facts card's `.kvg` is one column at 390px and a long email fits,
     while a landscape phone gets two narrower ones and the same email
     overflows. Scoping this to
     <=720px would leave the wider viewport broken.
   - `anywhere`, because only `anywhere` also shrinks the box's min-content
     contribution; `break-word` wraps the glyphs but still reports the long
     word's full width to the layout, which is what pushes a flex/grid item
     out. Only the min-content half actually stops the page growing.
   Track sizing is unaffected: every grid above uses a hard `minmax(NNNpx,..)`
   floor, never `auto`, so no column can collapse as a side effect. On
   desktop this is inert unless the value was ALREADY overflowing. */
.kvg dd,.kv,.crumbs,.fold .n,.fold .c,.foldrow .fn,.foldrow .fm,.chk{overflow-wrap:anywhere}

@media(max-width:640px){
  .wrap{padding:14px 11px 50px}
  /* .wrap drops to an 11px gutter above; .flashes and the footer are
     siblings of it, not children, and kept their own 16px -- so flash
     text and the timezone note sat 5px inboard of the content they
     annotate. Same value, same block, so the two cannot drift again. */
  /* `div.flashes`, not `.flashes`: the base rule further down this file
     (`.flashes{...padding:12px 16px 0}`) ties on specificity (0,1,0) and
     wins on source order, so the bare class here was silently reset back
     to a 16px gutter. A media query adds no specificity -- only the type
     selector does. `footer.foot` already carried one and worked by luck. */
  div.flashes,footer.foot{padding-left:11px;padding-right:11px}
  .hdr{padding:0 11px;gap:8px}
  .nav a{padding:5px 8px;font-size:11.5px}
  .brand small{display:none}
  h1{font-size:17px}
  .kv{flex-direction:column;gap:2px}
  .kv .k{min-width:0}
}

/* ── API keys: the profile panel and the admin list ─────────────
   The secret box is the only place in the portal that shows a value
   once and never again, so it is styled to be noticed and easy to
   select: full width, monospace, wrapping rather than truncating --
   a key clipped by an ellipsis is a key somebody copies wrong. */
.newkey{background:rgba(var(--accent-rgb),.08);border:1px solid rgba(var(--accent-rgb),.4);
  border-radius:var(--rs);padding:12px 14px;margin-bottom:14px}
.newkey .f{margin-bottom:8px}
.secret{display:block;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px;
  color:var(--accent);background:var(--bg);border:1px solid var(--border);border-radius:var(--rs);
  padding:10px 12px;word-break:break-all;user-select:all}
/* The runnable example. Scrolls inside itself: the panel must not
   start scrolling the page sideways on a phone. */
.snip{background:var(--surface);border:1px solid var(--border);border-radius:var(--rs);
  padding:12px 14px;overflow-x:auto;font-size:12.5px;line-height:1.55;color:var(--text)}
.snip code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre}
.note.warn{color:var(--orange)}
/* Digits that get compared down a column -- request counts, error
   counts -- line up. Everywhere else the proportional figures read
   better in prose. */
td.num,.num{font-variant-numeric:tabular-nums}

/* ── admin API keys ─────────────────────────────────────────────
   A number that has gone wrong reads as wrong at a glance: the error
   share turns red past a tenth. Colour is the SECOND signal here, not
   the only one -- the figure itself is still on the page, so this
   survives being printed or read by somebody who does not see red. */
.bad{color:var(--red)}
/* Prose tables. Cells here are sentences, not data, and the site-wide
   `td{white-space:nowrap}` -- right for a date or a duration, which must
   never break in half -- turns a paragraph into one long line that can
   only be read by scrolling the table sideways.
   Same opt-out shape as `td.gyms`, and it has to be table-scoped rather
   than a bare `.prose` so it outranks the element selector. */
table.prose td{white-space:normal}
table.prose td:first-child{white-space:nowrap;vertical-align:top}
/* The expanded breakdown under a key's row. It borrows the table's own
   background so the two read as one row, not as a second table that
   happens to sit underneath. */
tr.sub-row>td{padding:0 16px 12px;border-bottom:1px solid var(--border);
  /* `white-space:normal`, and it is load-bearing. Table cells on this
     site are `nowrap` so that a date or a duration never breaks in
     half -- and `white-space` INHERITS, so every paragraph inside this
     expanded row silently refused to wrap and painted straight across
     the column beside it. The boxes measured correctly the whole time;
     only the text overflowed them, which is why it read as two blocks
     overlapping rather than as one being too wide. */
  white-space:normal}
.split{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:20px}
/* `min-width:0`, and it is not optional. A grid item defaults to
   `min-width:auto`, which refuses to shrink below its widest child --
   here a table of endpoint paths. Without this the left column pushes
   past its track and its paragraph runs underneath the sparkline in the
   right one, which reads as two blocks overlapping rather than as one
   column being too wide. */
.split>*{min-width:0}
.split h4{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:0 0 8px}
table.mini{width:100%;font-size:12.5px}
table.mini td{padding:3px 8px 3px 0;border:none}
table.mini td:first-child{color:var(--text)}
/* Daily traffic as bars. NOT named `.bar` -- that is the filter bar,
   whose background, border and padding would otherwise apply to all
   thirty columns.
   `align-items:flex-end` is what makes them grow upward from a shared
   baseline; without it every column hangs from the top and the shape
   inverts. */
.spark{display:flex;align-items:flex-end;gap:3px;height:56px;
  border-bottom:1px solid var(--border);padding-bottom:1px}
.spark-col{flex:1;min-width:3px;height:100%;display:flex;align-items:flex-end}
.spark-col span{display:block;width:100%;min-height:2px;border-radius:2px 2px 0 0;
  background:rgba(var(--accent-rgb),.55)}
.spark-col:hover span{background:var(--accent)}

/* A person's facts (user.html .kvcard): two columns of label / value rows.
   A dozen short facts read as a list, not as a dozen tiles -- the tiles made
   the card three rows tall and broke an email across lines. A column is
   wide enough for an email on one line; on a phone it is one column. */
.kvcard{padding:6px 16px 10px}
.kvg{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,400px),1fr));column-gap:36px;margin:0}
.kvg>div{display:grid;grid-template-columns:112px minmax(0,1fr);align-items:baseline;gap:12px;
  padding:7px 0;border-bottom:1px solid var(--border)}
.kvg dt{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)}
.kvg dd{margin:0;min-width:0;overflow-wrap:anywhere}
.kvg dd .dim{font-size:12.5px}
.kvz{margin:8px 0 0;font-size:11.5px;color:var(--muted);text-align:right}
/* `.tight`: a few figures (activity, key counts, calendar stats) side by
   side -- label then the number, as many to a row as fit. */
.kvg.tight{grid-template-columns:repeat(auto-fit,minmax(min(100%,230px),1fr))}
.kvg.tight>div{grid-template-columns:auto minmax(0,1fr);gap:14px}
.kvg.tight dd{font-weight:600;text-align:right}
.kvcard.flush{padding:0;background:none;border:none;margin-bottom:12px}

/* ANCHOR: date/time field (.dt) */
/* The person picker is never a 190px cell: it is the filter reached for
   most often and the only one whose value is a LIST, so in the compact
   /trainings bar (.fbar below) it takes the left of the top row. */
.pickrow .ms-btn{max-width:340px}
/* The compact /trainings filter bar: the picker and the bar-wide controls
   (filter timezone, Apply, the count) on the top row, every other filter
   on one row below with each from/to pair side by side -- two rows where
   there were four. Flex rather than the auto-fit grid, because the date
   pair needs twice a number pair's width, and flex-basis wraps it onto a
   phone's rows without a breakpoint per field. */
.fbar-top{display:flex;flex-wrap:wrap;align-items:flex-start;gap:12px 18px;margin-bottom:12px}
.fbar-top .pickrow{flex:1 1 300px;min-width:0}
.fbar-side{display:flex;flex-wrap:wrap;align-items:flex-end;gap:10px 12px;margin-left:auto}
.fbar-side .ff{width:170px}
.fbar-side .dim{padding-bottom:8px}
.fbar-row{display:flex;flex-wrap:wrap;align-items:flex-start;gap:12px}
.fbar-row .ff{flex:1 1 150px;min-width:0;max-width:260px}
.fbar-row .ff.wide{flex:2 1 320px;max-width:440px}
.ff.pair{display:flex;gap:8px}
.ff.pair>div{flex:1 1 0;min-width:0}
.nums{display:flex;align-items:center;gap:6px}
/* The same bar on the other list pages (users, cameras, search, live and
   the admin lists): ONE wrapping row of fields, the actions -- Apply,
   Reset, the count -- closing it on the right, on the inputs' baseline. */
.fbar-row>.fbar-side{flex:0 0 auto;align-self:flex-end}
/* A checkbox that is a filter of its own carries no caption above it, so
   it sits on the inputs' baseline rather than under an empty label. */
.fbar-row .ff.tick{flex:0 1 auto;align-self:flex-end;max-width:none}
.ff.tick .chk{min-height:36px}
.ff .seg-in{display:flex;align-items:center;gap:14px;min-height:36px}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
.chips:empty{display:none}
/* A chip is a button, so it is keyboard-reachable and announced as one --
   the remove affordance must not be hover-only on a site with a phone pass. */
.chip{display:inline-flex;align-items:center;gap:6px;min-height:30px;padding:4px 10px;
  background:var(--surface);border:1px solid var(--border);border-radius:999px;
  color:var(--text);font-size:12.5px;font-family:inherit;cursor:pointer}
.chip:hover,.chip:focus-visible{border-color:var(--accent);color:var(--accent)}
.chip span{font-size:15px;line-height:1;opacity:.6}


/* ── date and date-time fields ── */
/* The palette is a hardcoded dark theme and nothing in the document ever
   said so to the ENGINE, which only mattered once native date controls
   arrived: without this the calendar glyph paints near-black on the
   near-black --surface and the dropdown itself opens as a white panel in
   the middle of a dark page. Scoped to these inputs rather than :root so
   it changes the controls this branch introduces and nothing else.
   type="time" is in the list for the same reason -- /trainings' start-time
   pair is native too, and its clock glyph is drawn by the same engine
   path as the calendar one. */
input[type="date"],input[type="datetime-local"],input[type="time"]{color-scheme:dark}

/* The date-time half only (see the `datefield` macro): a typed text box
   with the native picker parked under the calendar button. The donor
   keeps a REAL 1px box instead of display:none because showPicker()
   anchors its popup to the element's box -- a box-less element has
   nowhere to put it. It carries no `name`, so it never submits. */
.dt{position:relative}
.dt>input[name]{padding-right:34px}
.dt-b{position:absolute;right:1px;top:1px;bottom:1px;width:32px;padding:0;border:none;background:none;
  color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center}
.dt-b:hover{color:var(--text)}
.dt-n{position:absolute;right:10px;bottom:2px;width:1px;height:1px;min-width:0;padding:0;border:none;
  opacity:0;pointer-events:none}
/* A TOUCH screen opens no picker from script: iOS WebKit (Safari, and
   Chrome on iPhone with it) shows its date wheel only for a tap on the
   input itself, so the button's showPicker()/focus() did nothing and the
   calendar icon was dead. There the donor is laid OVER the button, still
   transparent: the thumb lands on the native input, the OS opens its own
   picker, and dtTake copies the pick into the box as before (dtSeed has
   already set it to what was typed). 16px keeps iOS from zooming in. */
@media (pointer:coarse){
  .dt-n{right:1px;top:1px;bottom:auto;width:40px;height:calc(100% - 2px);font-size:16px;
    pointer-events:auto;z-index:1;cursor:pointer}
}

/* ── multiselect with type-to-filter ── */
.ms{position:relative}
.ms-btn{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;
  padding:8px 10px;background:var(--surface);border:1px solid var(--border);border-radius:var(--rs);
  color:var(--text);font-size:13.5px;font-family:inherit;cursor:pointer;text-align:left}
.ms-btn:hover{border-color:rgba(var(--accent-rgb),.45)}
.ms-btn .sum{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ms-btn .sum.none{color:var(--muted)}
.ms-btn .car{color:var(--muted);font-size:11px;flex-shrink:0}
.ms-panel{display:none;position:absolute;z-index:60;top:calc(100% + 4px);left:0;right:0;min-width:240px;
  background:var(--card);border:1px solid var(--border);border-radius:var(--rs);
  box-shadow:0 10px 28px rgba(0,0,0,.55);padding:8px}
.ms.open .ms-panel{display:block}
.ms-search{margin-bottom:6px}
.ms-opts{max-height:190px;overflow-y:auto}
.ms-opts .chk{padding:5px 4px;border-radius:5px}
.ms-opts .chk:hover{background:var(--surface)}
.ms-opts .chk.hide{display:none}
.ms-none{color:var(--muted);font-size:12.5px;padding:6px 4px}
.ms-foot{display:flex;gap:8px;justify-content:space-between;border-top:1px solid var(--border);
  margin-top:6px;padding-top:6px}
.ms-foot button{background:none;border:none;color:var(--accent);font-size:12px;cursor:pointer;
  font-family:inherit;padding:2px}
/* Two-line option (person picker). The name stays the row's voice; the
   sub-line carries nickname + gyms + training count, which is the only
   thing separating the five boxers who share one name in production.
   min-width:0 lets the flex row shrink instead of stretching the panel. */
.ms-l{display:block;min-width:0}
.ms-sub{display:block;color:var(--muted);font-size:12px;line-height:1.35}
/* 190px was sized for one-line rows. A person row is two lines, and the
   phone rule below adds 6px of padding on top of that, which left 3-4 of
   95 people on screen -- a keyhole onto the pre-rendered roster that was
   the whole argument against a search endpoint. :has() is progressive:
   a browser without it keeps the old 190px, which is today's behaviour. */
.ms-opts:has(.ms-sub){max-height:320px}
/* Keyboard highlight: the row Enter would pick (app.js msKey). Outline
   rather than border so the row does not shift by a pixel as it moves. */
.ms-opts .chk.cur{background:var(--surface);outline:1px solid rgba(var(--accent-rgb),.45)}

/* ANCHOR: table cells -- sortable th, gym cell */
/* The participants cell on /trainings: one count that opens the names.
   The names were inline once, which needed `white-space:normal` and a
   180px floor here, and that pushed the whole table sideways on every
   row. The cell is a number again, so site-wide `td{white-space:nowrap}`
   applies to it and the column is as narrow as its header.

   .peo-pop is a NATIVE popover, so the browser paints it in the top
   layer and `.tw{overflow-x:auto}` around this table cannot clip it (a
   scroll container clips descendants on both axes -- an absolutely
   positioned panel could never have hung out of the cell). The UA sheet
   gives [popover] `position:fixed;inset:0;margin:auto`; all three are
   overridden below and app.js sets left/top from the button's rect,
   because CSS anchor positioning is not everywhere yet. */
td.people .num{font-variant-numeric:tabular-nums}
.peo{display:inline-flex;align-items:center;gap:5px;font:inherit;color:inherit;cursor:pointer;
  background:none;border:1px solid transparent;border-radius:6px;padding:1px 5px;margin:-1px -5px}
.peo:hover,.peo[aria-expanded="true"]{background:var(--surface);border-color:rgba(var(--accent-rgb),.45)}
.peo .car{color:var(--muted);font-size:11px;line-height:1}
.peo-pop{position:fixed;inset:auto;margin:0;padding:6px;max-width:280px;overflow:visible;
  background:var(--card);color:var(--text);border:1px solid var(--border);border-radius:var(--rs);
  box-shadow:0 10px 28px rgba(0,0,0,.55)}
.peo-pop a{display:block;padding:3px 7px;border-radius:5px;white-space:nowrap;
  overflow:hidden;text-overflow:ellipsis}
.peo-pop a:hover{background:var(--surface)}
/* No popover support: the UA sheet is not hiding the panel, so rather
   than leave a floating box mid-page, fall back to what this cell did
   before -- the names, inline, in a cell that wraps. */
@supports not selector(:popover-open){
  .peo{display:none}
  .peo-pop{position:static;display:inline;padding:0;max-width:none;
    background:none;border:none;box-shadow:none}
  .peo-pop a{display:inline;padding:0}
  .peo-pop a+a::before{content:", "}
  td.people{white-space:normal;min-width:180px}
}


/* ── sortable column headers ──
   `color:inherit` is the load-bearing declaration, not a tidying one: the
   global `a{color:var(--accent)}` at the top of this file would otherwise
   repaint every clickable header accent-gold and destroy the muted-uppercase
   header language that tells a header apart from a row.

   The anchor FILLS the cell -- display:block plus a negative margin that
   cancels th's own 9px/13px padding and puts it back on the link. That buys
   a ~34px tap target on a phone with no addition to the phone block at the
   end of this file (the tap-target rule there is `td a`, and it has no `th a`
   twin for exactly this reason). It also means the whole header cell is the
   click target on a mouse, not just the eight characters of the label.

   The arrow is a span rather than a ::after content string so it can be
   aria-hidden: the direction is already on the th as aria-sort, and a
   screen reader reading "Length ↕" would be reading decoration twice.
   Inactive columns keep the glyph visible but dimmed, and never hover-only:
   a phone has no hover, and a header that only reveals itself under a
   pointer is unusable on the device this portal is most read on. .55 rather
   than the .35 this shipped with, because that glyph IS the whole signal
   that a header is clickable -- on /admin/exports 8 of 11 headers are plain,
   so the reader is asked to tell them apart by one 9px mark, and .35 over
   `th{color:var(--muted)}` lands under the 3:1 WCAG 1.4.11 floor for a
   non-text UI indicator. (Measured off the tokens, not off a screen: this
   repo has no browser. If a human ever finds .55 too loud, the number to
   move is this one, and the arrow's size is the other lever.)

   `.on:hover` is spelled out because it has to be: `th a.sortable:hover` and
   `th a.sortable.on` are the same specificity (0,2,2), so the later `.on`
   rule wins and the ACTIVE header -- the one header whose click does
   something different, reverse -- would be the only one with no hover
   feedback at all. */
th a.sortable{color:inherit;display:block;margin:-9px -13px;padding:9px 13px}
th a.sortable:hover{text-decoration:none;color:var(--text)}
th a.sortable.on{color:var(--accent)}
th a.sortable.on:hover{color:var(--text)}
th a.sortable .ar{margin-left:5px;font-size:9px;opacity:.55}
th a.sortable:hover .ar,th a.sortable.on .ar{opacity:1}
/* Inset like .foldrow's, and for the same reason: the cell is flush against
   the table border, so an outset ring would be clipped by the .tw scroller. */
th a.sortable:focus-visible{outline:2px solid rgba(var(--accent-rgb),.8);outline-offset:-2px}

/* The Gym cell on /users is the ONLY many-valued cell in a table on this
   site: a boxer can belong to several gyms. `td{white-space:nowrap}` above
   is right for every other column -- dates, ids, counts, names are atomic --
   but it makes an N-valued list behave like an atomic value, so the cell's
   width grows linearly with membership count. In an auto-layout table one
   row's max-content sets the column for all 50 rows, so four boxers out of
   69 dictate the layout of the whole page -- and it fires on the DEFAULT
   load, because all four sort onto page 1 under `last_training desc`.

   Every figure below comes from ONE measurement session (headless Chrome,
   1440x900, this stylesheet, a 50-row page carrying prod's real gym
   strings), so the numbers can be compared with each other. Nothing in
   this repo can reproduce them -- there is no browser in the dependency
   set -- which is why they are quoted here and not asserted in a test.

   Today the Gym column is 442px and the table scrolls 601px off-screen,
   with the ID and Email columns entirely past the right edge. The same
   page with no multi-gym boxer on it: 178px and 338px. So four rows out
   of fifty cost 264px of scroll -- more than the Registered or the
   Last-training column is wide.

   THE TWO RULES BELOW ARE A PAIR AND ONLY WORK TOGETHER. The cell wraps;
   each gym NAME does not; so a line break lands between names and never
   inside one. Delete the second rule and nothing errors -- the cell's
   min-content simply becomes its longest WORD ("Academy,"), auto layout
   squeezes the column to 87px, and rows go 78 / 117 / 195px for 1 / 2 / 3
   gyms: EVERY single-gym row doubles in height, the 94% punished to fix
   the 6%. That is why capping the width alone is a trap. With both rules
   the column self-sizes to 181px and the table scrolls 340px -- 2px more
   than a page carrying no multi-gym boxer at all, i.e. the Gym column
   stops costing anything -- single-gym rows stay 39px, and only the real
   2- and 3-gym rows grow, to 58px and 78px. At a true 390px viewport:
   47 / 74 / 102px, with the body's own horizontal overflow still 0 -- the
   `.tw` scroller keeps containing it.

   Wrapping rather than truncating is deliberate: prod's maximum is three
   gyms, so everything fits with no interaction at all, and every gym stays
   a working link. A `max-width` + ellipsis would hide the tail behind
   `title=`, which is invisible on touch, and this site has a mobile pass.

   There is deliberately NO `max-width` here, though an earlier draft had
   one. While the table overflows a cap is measurably inert -- the column
   reads 181px at 190, 220 and 300px and with no cap at all, because auto
   layout has already squeezed a wrappable column to its min-content. It
   could only bite once the Columns panel hides enough columns that the
   table FITS and the free space is handed out, and there it does not act
   as a cap either: at max-width:220px the column measures ~459px against
   678px uncapped, because auto layout distributes free space across
   columns rather than honouring a max-width on a `td`. A number that
   explains neither the case it is inert in nor the case it changes is one
   nobody can defend later, so the merely-untidy wide column in the
   columns-hidden state is left untidy.

   A single very long gym name (the column allows VARCHAR(1024); prod's
   longest is 20 chars) still makes one unbreakable line, but that widens
   the TABLE inside its `.tw` scroller, not the body, which is the
   containment that matters. */
td.gyms{white-space:normal}
td.gyms a{white-space:nowrap}

/* ── recordings list view ── */
.reclist{border:1px solid var(--border);border-radius:var(--r);background:var(--card);overflow:hidden}
.rec{display:flex;align-items:center;gap:14px;padding:9px 13px;border-bottom:1px solid var(--border)}
.rec:last-child{border-bottom:none}
.rec:hover{background:var(--surface)}
.rec .rth{width:86px;flex-shrink:0;aspect-ratio:16/9;border-radius:5px;overflow:hidden;background:#000;cursor:pointer}
.rec .rth img{width:100%;height:100%;object-fit:cover;display:block}
.rec .rt{font-size:14px;min-width:86px}
.rec .rc{color:var(--muted);font-size:12.5px;min-width:74px}
.rec .rd{color:var(--muted);font-size:12.5px;font-family:ui-monospace,Menlo,monospace;min-width:52px}
.rec .rs{color:var(--muted);font-size:12.5px;min-width:78px}
.rec .ra{margin-left:auto;display:flex;gap:7px;flex-shrink:0}
.rec .ra .btn{padding:5px 11px;font-size:12.5px}
.btn.watch{background:var(--red);color:#fff}
.btn.watch:hover{background:#c23545;opacity:1}
@media(max-width:760px){
  .rec{flex-wrap:wrap;gap:8px 12px}
  .rec .ra{margin-left:0;width:100%}
  .rec .ra .btn{flex:1}
}

/* ANCHOR: archive folder list view */

/* ── archive folders, list view ──
   One `archive.view` key covers the whole archive section, folder levels and
   the segment leaf alike; the argument for that lives in ONE place, the module
   docstring of routes/archive.py, and is not repeated here. What follows is
   only what it costs in CSS.

   `.foldlist` is the same rows-in-one-card silhouette as `.reclist` above --
   but NOT the same columns, because a folder is not a video: it has no poster
   and no duration, so an 86px thumbnail slot would be an 86px hole. What a
   folder actually carries is a name (plus, on camera levels only, a ring) and
   two numbers, and lining those two numbers up is the entire reason to
   want a list -- 31 day folders' counts and sizes become comparable at a glance,
   which a caption line wrapped under a 168px tile never is.

   So the row is two groups, not four even columns: the name and its ring read as
   one label on the left, then `margin-left:auto` on `.fc` (the same trick
   `.rec .ra` uses above) throws the numbers to the right edge. Because `.fc` and
   `.fs` are `flex-shrink:0` with min-widths that exceed their content, their
   edges land at the same x on every row REGARDLESS of how long the name or ring
   is -- which is the property that makes the column scannable, and which a
   fixed-width ring column would only have bought at the price of 110px of dead
   space on the four levels that have no ring at all.

   `display:flex` on `.foldrow` is load-bearing, not cosmetic. `.fold` (the grid
   tile) is an <a> that only blockifies because `.folders` is a grid container;
   inside the plain-block `.foldlist` an <a> stays inline and every min-width /
   text-align below is silently dropped. That failure reads as "the columns do
   not line up", i.e. a design problem, for a one-word cause.

   `text-decoration:none` on hover is the same override `.idx a:hover` already
   carries: the bare `a:hover` rule near the top of this file would otherwise
   underline the whole row, every column of it. `color:var(--text)` overrides
   the bare `a` rule for the same reason -- and note it makes the row name
   DIFFER from `.fold`'s accent-gold tile name on purpose. In a card the gold
   name is the click affordance; in a row the affordance is the row (hover
   background, chevron, and the whole strip is the <a>), and a column of gold
   bold text down 31 day folders is noise. Two of those three are hover-based or
   hover-adjacent, which is why the chevron has to survive the narrow-width
   block below -- see the note there. It matches `.reclist`, which is the
   other thing this same toggle produces, and matching that is what makes the
   two halves of the toggle read as one setting.

   Scoped entirely to `.foldlist`/`.foldrow` and deliberately NOT to
   `.fold`/`.folders`: gym.html reuses those two classes for its ring cards and
   has no view toggle at all, so restyling them would change a page that never
   opted in. */
.foldlist{border:1px solid var(--border);border-radius:var(--r);background:var(--card);
  overflow:hidden;margin-bottom:8px}
.foldrow{display:flex;align-items:center;gap:14px;padding:11px 13px;font-size:14px;
  color:var(--text);border-bottom:1px solid var(--border)}
.foldrow:last-child{border-bottom:none}
.foldrow:hover{background:var(--surface);text-decoration:none}
/* `.foldlist` clips (overflow:hidden above) and `.foldrow` is a block whose
   border box coincides with `.foldlist`'s padding box, so an OUTSET focus ring
   -- including the UA default, which is all a keyboard user gets here: the only
   other outline rule in this file is `input:focus,select:focus{outline:none}`,
   which suppresses one and adds none -- is cut off
   at the left and right edges, plus the top edge on the first row and the
   bottom on the last. `outline-offset:-2px` draws it INSIDE the clip region,
   which is the whole reason this rule exists rather than the UA's. `.reclist`
   has the same `overflow:hidden` and never needed this because the things it
   focuses are inset buttons. */
.foldrow:focus-visible{outline:2px solid rgba(var(--accent-rgb),.8);outline-offset:-2px}
.foldrow .fn{min-width:0;font-weight:600}
.foldrow .fm{min-width:0;color:var(--muted);font-size:12.5px}
.foldrow .fc{margin-left:auto;flex-shrink:0;min-width:96px;text-align:right;
  color:var(--muted);font-size:12.5px}
.foldrow .fs{flex-shrink:0;min-width:78px;text-align:right;color:var(--muted);font-size:12.5px;
  font-family:ui-monospace,Menlo,monospace}
.foldrow .fg{flex-shrink:0;color:var(--muted)}

/* A name plus three trailing cells does not fit a 390px screen. Two lines
   instead: the name on its own row, ring/count/size wrapped underneath and
   left-aligned -- a right-aligned column is meaningless once it is no longer a
   column, and `margin-left:auto` on `.fc` has to go with it or the wrapped
   second line is shoved to the far right.

   The chevron STAYS, though it looks like dead weight on a wrapped row. This
   width is a touch device, and a touch device has no hover: `.foldrow:hover`
   above never fires, and `color:var(--text)` has already given up the
   accent-gold that marks `.fold .n` as tappable on the grid side. Hide the
   chevron too and a folder row is off-white bold text on a card, visually
   indistinguishable from a non-interactive table row -- the row would be
   reachable by hover alone, which on a phone means not reachable at all. It
   costs ~8px at the end of the wrapped meta line.

   These overrides sit HERE, straight after the rules they override, and NOT in
   the `@media(max-width:760px)` block a few lines above: that block comes
   earlier in the file, these selectors tie it on specificity, and the later
   rule wins -- so hoisting them up there to "tidy the media queries together"
   would silently do nothing. Same trap as the phone block at the end of the
   file, which must stay last for the same reason; this is a narrower local
   override rather than a fourth breakpoint tier. */
@media(max-width:760px){
  .foldrow{flex-wrap:wrap;gap:2px 10px}
  .foldrow .fn{flex:1 0 100%}
  .foldrow .fm,.foldrow .fc,.foldrow .fs{min-width:0;text-align:left}
  .foldrow .fc{margin-left:0}
}

/* ANCHOR: live tile states */
/* Tile caption: gym above ring. Block-level halves so they stack inside the
   one rounded plate, and the gym is dimmer and smaller because it is the
   qualifier -- the ring is what you are looking for once you know the gym. */
.tile .lbl .lbl-gym{display:block;font-size:10px;opacity:.72;letter-spacing:.2px}
.tile .lbl .lbl-ring{display:block}

/* ── live tile: the four states, told apart at a glance ───────────────────
   Before this the whole grid was one state: a black 16:9 box with a red
   LIVE pill, worn from the instant the stream was REQUESTED and kept
   whether or not a frame ever arrived. So the page did not merely fail to
   distinguish "not asked for", "loading", "playing" and "no feed" -- it
   actively asserted the last one was the first.

   `.tile[data-state]` (app.js setTileState) is the only switch. All three
   panels ship in the markup for every tile and exactly one is shown, so a
   state change is one attribute write and never a DOM rebuild.
   ─────────────────────────────────────────────────────────────────────── */
/* The `>` is load-bearing, not tidiness. The pill is a direct child of
   `.tile` too, and it carries a state class of its own; an unscoped
   `.tile .err` also selected `<span class="pill err">` and turned the PILL
   into a full-tile flex panel -- it centred itself over the message and
   painted a SECOND 82% scrim across the error copy, which is how the one
   state that most needs to be readable ended up the least readable. The
   pill's state classes are kept disjoint from the panel names as well, so
   the two mechanisms cannot collide again. */
.tile>.load,.tile>.err{position:absolute;inset:0;background:rgba(10,9,9,.82);flex-direction:column;
  align-items:center;justify-content:center;gap:9px;text-align:center;padding:14px}
.tile>.over,.tile>.load,.tile>.err{display:none}
.tile[data-state="idle"]>.over,
.tile[data-state="starting"]>.load,
.tile[data-state="error"]>.err{display:flex}
/* `stalled` is a sub-state of live: the last picture is still worth
   showing, so the spinner sits over a much lighter scrim than a full
   overlay and the frozen frame stays readable underneath. */
.tile[data-state="stalled"]>.load{display:flex;background:rgba(10,9,9,.45)}
/* Border colour carries the state from across the room, before any copy
   is legible -- the grid is meant to be scanned, not read. */
.tile[data-state="starting"],.tile[data-state="stalled"]{border-color:rgba(var(--accent-rgb),.45)}
.tile[data-state="live"]{border-color:rgba(var(--red-rgb),.4)}
.tile[data-state="error"]{border-color:rgba(var(--red-rgb),.45)}
.tile>.over p,.tile>.load p,.tile>.err p{font-size:12px;color:var(--muted);max-width:230px}
.tile>.err .msg{color:var(--red)}

/* ── the failures that are not the same failure ──────────────────────────
   Asked one camera at a time on production, MediaMTX answers with three
   different HTTP 500s, and the viewer was shown one sentence for all of
   them:

     {"error":"path '0020' is not configured"}              instantly
     {"status":"error","error":"muxer instance not available"}
     {"error":"source of path '0001' has timed out"}        after 10.0s

   The first means this camera view was never given a live path in
   /etc/mediamtx/mediamtx.yml. It is permanent until an operator edits that
   file, it is FOURTEEN of the thirty cameras this portal shows (all eight
   of Honour & Glory, all four of PROD Office Test Gym, two of Champ Boxing
   Academy), and no number of presses of "Try again" will change it.

   The second is the camera that is working and still cannot be shown. The
   RTSP pull succeeds and the HLS muxer then cannot be built from the track
   it got: the fleet is H.264 only, and the four PROD Office Test Gym
   cameras stuck here on the morning of 2026-08-25 were each set to H.265
   and each recovered on being switched back at the camera. Nothing on this
   page changes an encoder setting, so it gets the same treatment as the
   first -- see app.js WRONG_CODEC for the copy, which names the format and
   the fix instead of blaming a camera and a network that are both fine.

   The third is a camera that exists as far as the collector is concerned
   and did not answer -- transient, retryable, and a genuine hardware
   problem. It keeps its red and it keeps its button.

   app.js reads MediaMTX's sentence off the failed request and puts it in
   `data-reason`; these rules are what the viewer sees of it.

   Retry, only where retrying can work. `display:none` and not `disabled`,
   because a disabled button is still an affordance -- it says "this could
   work, later" -- and here nothing later changes without a person editing
   a config file or standing at a camera. The message is left to carry the
   whole explanation, which is why its wording drops the "No video from
   this camera" opening the retryable failure keeps.

   And they are not painted red. Red on this grid means "something that
   should be working is not", and on production it would fall on 14 of 30
   tiles at once for a reason no viewer of this page can act on -- a wall
   of alarm about a configuration gap, which trains people to read the red
   that DOES mean a broken camera as noise.

   But not idle's chrome either, which is what dropping the red first gave
   them: `border-color:var(--border)` with a muted pill is EXACTLY what an
   untouched tile wears, so eight tiles that had just been asked for and
   refused looked identical to eight nobody had touched -- the same "border
   colour carries the state from across the room" invariant stated four
   rules above, broken in the other direction. `border-style:dashed` is the
   tell: an outline with nothing behind it, distinct from idle's solid one
   at a glance and from error's red at a glance, and it survives being read
   by someone who cannot separate the two greys. The panel scrim, the copy
   size and the state's own layout are unchanged, so it is still
   unmistakably a tile that will not play.

   THE COST, since it is not free and was argued over. `.tile[data-state=
   "idle"]>.over{display:flex}` is what holds the "Start stream" button, so
   a tile that has failed this way has NO control at all: if an operator
   fixes mediamtx.yml, or walks over and switches the camera to H.264,
   while a viewer has /live open, that viewer gets it back only by
   reloading the page (startAll walks DOM order and stops at the ceiling,
   so it will not reach a tile far down a 30-tile grid). Taken knowingly:
   the alternative is a button that is a lie in every minute except the one
   after someone else fixed something, and the failure this whole block
   exists for is the surface asserting things that are not true. A reload
   is the escape hatch; a button that mostly cannot work is not. */
.tile[data-reason="unconfigured"]>.err .btn,
.tile[data-reason="codec"]>.err .btn{display:none}
.tile[data-reason="unconfigured"]>.err .msg,
.tile[data-reason="codec"]>.err .msg{color:var(--muted)}
.tile[data-state="error"][data-reason="unconfigured"],
.tile[data-state="error"][data-reason="codec"]{border-color:var(--border);border-style:dashed}
.tile[data-state="error"][data-reason="unconfigured"] .pill,
.tile[data-state="error"][data-reason="codec"] .pill{color:var(--muted)}
/* The offline note is advisory decoration on the idle overlay, never a
   state of its own: the start button next to it stays enabled, because
   the collector's is_online is stale often enough that refusing to try
   would be the bigger lie. */
.tile .offnote{display:none}
.tile[data-reported-offline="1"] .offnote{display:block}
/* The pill is the state read-out, so it has to stay crisp on top of the
   82%-opaque panels that are painted after it in document order. */
.tile .pill,.tile .lbl{z-index:2}
/* Its colour comes off the SAME attribute as everything else here. The
   pill used to carry a second state vocabulary of its own -- a
   wait/bad/on/off class written by app.js -- which then had to be kept
   disjoint from the panel class names by hand, and was not: the badge
   rendered as `<span class="pill err">` and the unscoped `.tile .err`
   turned it into a full-tile scrim across the error copy. One vocabulary
   cannot collide with itself, and app.js loses a whole column. */
.tile[data-state="idle"] .pill{color:var(--muted)}
.tile[data-state="starting"] .pill,.tile[data-state="stalled"] .pill{color:var(--accent)}
.tile[data-state="live"] .pill,.tile[data-state="error"] .pill{color:var(--red)}
/* The pulsing red dot means "a picture is arriving right now" and belongs
   to exactly one state. It was defined at the top of this file and used
   nowhere; this is its intended home. */
.tile .pill .dot{display:none}
.tile[data-state="live"] .pill .dot{display:inline-block}
.spin{width:22px;height:22px;border:2px solid var(--surface2);border-top-color:var(--accent);
  border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* A grid of eight spinners and eight pulsing dots is exactly the kind of
   motion that makes this page unusable for a vestibular-sensitive viewer.
   The states stay distinguishable without it: colour, copy and border. */
@media (prefers-reduced-motion:reduce){.spin,.tile .pill .dot{animation:none}}

/* Hi-res modal: same states, same vocabulary, own panels. `stopped` is the
   unattended-viewing cutoff -- a stop, not a failure. `.modal.big` beats
   the plain `.modal .cont` rule further down on specificity, so source
   order here is not load-bearing. */
.modal.big .cont{display:none}
.modal.big[data-state] #hiVid{display:none}
.modal.big[data-state="live"] #hiVid,.modal.big[data-state="stalled"] #hiVid{display:block}
.modal.big[data-state="starting"] #hiLoad,
.modal.big[data-state="stalled"] #hiLoad,
.modal.big[data-state="stopped"] #hiCont,
.modal.big[data-state="error"] #hiErr{display:flex}
.modal.big #hiErr .msg{color:var(--red)}
/* The modal carries its own data-reason (setHiState), not the tile's: the
   sub and main streams are separate MediaMTX paths (0001 / 0001_main) and
   a camera can publish its two channels with different encoders, so a
   camera whose grid tile plays can still have no hi-res path at all -- or
   have one this server cannot mux. The modal has no border of its own to
   carry a state, so here the two unretryable reasons only drop the red and
   the button. */
.modal.big[data-reason="unconfigured"] #hiErr .btn,
.modal.big[data-reason="codec"] #hiErr .btn{display:none}
.modal.big[data-reason="unconfigured"] #hiErr .msg,
.modal.big[data-reason="codec"] #hiErr .msg{color:var(--muted)}


/* ── portal additions (not in the demo) ─────────────────────────── */

/* The first launch of a training, at the top of its card (training.html).
   Green, big and first because "not processed" is the one thing left to
   do there, and a quiet button further down went unnoticed for hours.
   `.inflight` is the same box once launched: a line of status, no push. */
.launch-cta{margin:4px 0 16px;padding:18px 20px;border-radius:var(--r);
  background:rgba(var(--green-rgb),.08);border:2px solid rgba(var(--green-rgb),.6)}
/* The processed window's length (red over 1 hour), big (macro dur_badge / app.js durBadge):
   amber when plausible, red when it looks like a typo. */
.dur-badge{display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 14px;margin:6px 0 14px;padding:12px 16px;
  border-radius:var(--r);border:2px solid var(--accent);background:rgba(var(--accent-rgb),.12)}
.dur-badge .dur-k{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.6px;color:var(--muted)}
.dur-badge .dur-v{font-size:30px;font-weight:800;line-height:1.1;color:var(--accent)}
.dur-badge .dur-why{flex-basis:100%;font-size:13px;font-weight:600}
.dur-badge.odd{border-color:#ff5f6d;background:rgba(255,95,109,.14)}
.dur-badge.odd .dur-v,.dur-badge.odd .dur-why{color:#ff6b78}
.launch-cta h2{margin:0 0 6px;color:var(--green);font-size:18px}
.launch-cta>p{margin:0 0 12px}
.launch-cta form{display:flex;flex-wrap:wrap;align-items:center;gap:10px 18px;margin-bottom:12px}
.launch-cta form .note.warn{flex-basis:100%;margin:0}
.launch-cta .chk{margin:0}
.launch-cta .btn.go{background:var(--green);color:#08210F;font-size:16px;font-weight:700;padding:12px 28px}
.launch-cta .btn.go:hover{opacity:.9}
.launch-cta .note.dim{margin:0}
.launch-cta.inflight{background:rgba(var(--accent-rgb),.06);border:1px solid rgba(var(--accent-rgb),.45);padding:12px 16px}
.launch-cta.inflight p{margin:0}

/* flash messages */
.flashes{max-width:1320px;margin:0 auto;padding:12px 16px 0}
.flash{border-radius:var(--rs);padding:9px 13px;font-size:13px;margin-bottom:8px;
  background:rgba(var(--green-rgb),.12);border:1px solid rgba(var(--green-rgb),.4);color:var(--green)}
.flash.error{background:rgba(var(--red-rgb),.12);border-color:rgba(var(--red-rgb),.4);color:var(--red)}

/* footer */
.foot{max-width:1320px;margin:0 auto;padding:0 16px 40px;color:var(--muted);font-size:12px}

/* inline forms (logout button in the header, revoke buttons in tables) */
form.inline{display:inline;margin:0}
/* An action that belongs to the block it sits in, under whatever the
   block was showing: a table, a list, a paragraph. `form.inline` is
   `display:inline`, so a margin on it does nothing -- the add-boxers
   form sat flush against the last table row, reading as one more row
   of it. This is the separation, in one place, for every block that
   grows an action later. */
.blk .body>form.act{display:block;margin-top:14px;padding-top:14px;border-top:1px solid var(--border)}

/* thumb placeholder when no poster exists */
.file .th div.ph,.rec .rth div.ph{width:100%;height:100%;background:var(--surface2)}
.tile>video.ph{background:#000}

/* live: camera cycler under a single-camera tile */
.tilewrap{display:flex;flex-direction:column;gap:6px}
.cyc{display:flex;align-items:center;justify-content:center;gap:10px;font-size:12.5px;color:var(--muted)}
.cyc .cur{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cyc button{background:var(--surface);border:1px solid var(--border);color:var(--text);
  border-radius:var(--rs);padding:3px 10px;cursor:pointer;font-family:inherit;font-size:12.5px}
.cyc button:hover{border-color:rgba(var(--accent-rgb),.5)}

/* admin tri-state feature rows */
.tri{display:flex;align-items:center;gap:14px;padding:7px 0;border-bottom:1px solid var(--border);flex-wrap:wrap}
.tri:last-child{border-bottom:none}
.tri .fname{min-width:150px;font-size:13.5px}
.tri label{display:flex;align-items:center;gap:5px;font-size:12.5px;color:var(--muted);cursor:pointer}
.tri input{width:auto}

/* ring options hidden because their gym is not selected */
.ms-opts .chk.gymhide{display:none}

/* user card: training calendar (gym-local month grids) */
.cal{display:grid;grid-template-columns:repeat(auto-fill,minmax(196px,1fr));gap:12px}
.cal-m{background:var(--surface);border:1px solid var(--border);border-radius:var(--rs);padding:10px 12px}
.cal-t{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin-bottom:7px}
.cal-g{display:grid;grid-template-columns:repeat(7,1fr);gap:2px}
.cal-h{font-size:9.5px;color:var(--muted);text-align:center;padding-bottom:2px}
.cal-d{font-size:10.5px;text-align:center;border-radius:4px;padding:3px 0}
.cal-d.fut{color:var(--muted);opacity:.4}
.cal-d.c1{background:rgba(var(--accent-rgb),.22)}
.cal-d.c2{background:rgba(var(--accent-rgb),.45)}
.cal-d.c3{background:rgba(var(--accent-rgb),.8);color:#191817;font-weight:600}
.cal-d.tdy{outline:1px solid rgba(var(--accent-rgb),.8)}

/* live watch limit: "Continue watching" block in the hi-res modal */
.modal .cont{aspect-ratio:16/9;display:flex;flex-direction:column;align-items:center;justify-content:center;
  gap:12px;background:var(--surface);border:1px solid var(--border);border-radius:var(--rs)}
.modal .cont p{color:var(--muted);font-size:13px;max-width:420px;text-align:center}

/* ── header, wider than the phone block ───────────────────────────────────
   A phone in LANDSCAPE is not a small viewport: iPhone 12..15 report
   844x390 CSS px and the Pro Max 932x430, so a 720px ceiling would leave
   the single worst bug on the site unfixed on the very device just fixed
   in portrait. `.hdr` is a position:fixed, 56px, overflow-x:auto strip
   around a nav that does not wrap; at 844px a staff account still
   overflows it, and it overflows INVISIBLY -- no scrollbar, no
   affordance. The nav simply ends and the rest of the portal is
   unreachable.

   Only the STRUCTURAL rules live here; the phone block below adds sizing
   on top and, being later in the file, wins wherever both apply.
   `position:static` + `body{padding-top:0}` stay ONE ATOMIC PAIR -- the
   56px top padding exists solely to clear the fixed header.
   ─────────────────────────────────────────────────────────────────────── */
@media (max-width:960px){
  body{padding-top:0}
  .hdr{position:static;height:auto;overflow-x:visible;
       flex-wrap:wrap;row-gap:6px}
  .nav{margin-left:0;width:100%;flex-wrap:wrap;gap:5px}
  /* The account pill renders `account.name` verbatim, and that is free
     text an admin types with no maxlength (admin_new_account.html). With
     the header's overflow containment removed above, one long name would
     otherwise push the page's scroll width -- the exact failure this
     block exists to prevent. */
  .nav a.acc{white-space:normal;overflow-wrap:anywhere;max-width:100%}
}

/* ── phone pass ───────────────────────────────────────────────────────────
   The bar here is "works on a phone", not "designed for a phone". This is a
   touch-up on the two narrow media blocks that already exist above, not a
   redesign: no new markup, no JS, no hamburger. Desktop is the primary
   experience and nothing outside this block changes it.

   PLACEMENT IS LOAD-BEARING: this block MUST stay at the end of the file,
   after the `@media(max-width:640px)` block, BOTH `@media(max-width:760px)`
   blocks (the `.rec` one and the `.foldrow` one), and the 960px header block
   just above. Its
   `.hdr` / `.nav` / `input` rules tie those on specificity and win only on
   source order. Pasted higher up, the header fix silently does nothing
   while everything else in the block still works -- which is a very
   expensive way to debug a stylesheet. tests/test_responsive_shell.py
   pins the ordering so a future edit cannot move it quietly.

   Why 720px: it sits above the 640px block and below the 760px ones, giving
   three clean SIZING tiers instead of a fourth overlapping value. Count the
   `@media` lines and you will find five, not three: the second 760px block is
   a local override sitting next to the rules it overrides rather than a tier,
   and the 960px block is the header's STRUCTURAL tier on a different axis
   entirely (see its own comment).
   768px (iPad mini portrait) deliberately stays on the desktop layout --
   the nav and the tables genuinely fit there.
   ─────────────────────────────────────────────────────────────────────── */
/* ── control room (/control, staff only) ─────────────────────────────
   A wall display first and a web page second: fixed-width numbers, one
   colour per gym repeated in every panel, and no control to press. The
   plots are SVG rendered by the server (see controlplot.py), so the
   rules here are about size and contrast, never about data. */
.ctl{--ctl-gap:14px}
.ctl-hdr{display:flex;align-items:baseline;justify-content:space-between;gap:16px;margin-bottom:var(--ctl-gap)}
.ctl-mode{font-size:26px;font-weight:700;letter-spacing:1.5px;color:var(--muted)}
.ctl-mode-count{color:var(--text);font-variant-numeric:tabular-nums;margin-right:4px}
.ctl-mode.on{color:var(--green)}
.ctl-mode-sub{display:block;font-size:12.5px;font-weight:500;letter-spacing:0;color:var(--muted)}
.ctl-clock{font-variant-numeric:tabular-nums;font-size:20px}
.ctl-clock .dim{font-size:12.5px}
/* The headline is the thing being read; the live panel is a glance.
   Two thirds / one third, not the other way round. */
/* The headline needs room for a 54px number and four chips, and no
   more: it had a quarter of itself empty on a wide screen. */
.ctl-grid{display:grid;gap:var(--ctl-gap);
  grid-template-columns:minmax(260px,.95fr) minmax(190px,.75fr) minmax(320px,1.6fr)}
.ctl-box{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:14px}
.ctl-int,.ctl-strip,.ctl-sub,.ctl-cams,.ctl-ticker{grid-column:1 / -1}
/* The logo tile. A sign in the middle of the top row, and the mail
   queue's alarm light when the queue dies -- the one tile that can go
   loud, because nothing else on the page changes when mail stops. */
.ctl-logo{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;
  padding:0;overflow:hidden}
/* The mark is the skin switch (templates/control.html says why), so it
   is a <button>: stripped to nothing but the image, and a pointer is
   the only hint that it does anything.
   The image is sized off the tile's WIDTH and centred on it, so both
   marks land the same size whatever their own proportions are; the tile
   is as tall as the row its neighbours set, and whatever the square
   spills past that is cropped rather than stretching the row. */
.ctl-logo-btn{flex:1 1 auto;min-height:0;align-self:stretch;position:relative;overflow:hidden;
  background:0;border:0;padding:0;cursor:pointer;line-height:0}
.ctl-logo img{position:absolute;left:0;top:50%;transform:translateY(-50%);
  width:100%;max-width:none;height:auto;display:block}
.ctl-logo-btn:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
.ctl-logo .ctl-mail-alarm{padding:0 14px 12px;margin:0}
/* Both selectors carry the element too: `.ctl-logo img` would otherwise
   out-specify a bare class and show both marks at once, which is what
   it did. */
.ctl-logo img.ctl-logo-neon{display:none}
.ctl.neon .ctl-logo img.ctl-logo-plain{display:none}
/* The neon artwork sits high in its own square (the reflection under
   the letters is half the image), so centring the FILE leaves the
   letters above the middle of the tile. 3% of its height down puts the
   letters where the plain mark's are. */
.ctl.neon .ctl-logo img.ctl-logo-neon{display:block;transform:translateY(-47%)}
.ctl-logo.alarm{border-color:var(--red);background:color-mix(in srgb,var(--red) 12%,var(--card))}
.ctl-logo.alarm img{opacity:.35}

/* The alarm is the point of this panel: a dead mail queue changes
   nothing else on the page, so it has to change this. */
.ctl.neon .ctl-logo.alarm{border-color:var(--red);
  box-shadow:0 0 22px rgba(215,61,81,.45),0 0 60px rgba(215,61,81,.25) inset}
.ctl-mail.alarm .ctl-spark polyline{stroke:var(--red)}
.ctl-alarm{color:var(--red);font-weight:700;letter-spacing:.5px;animation:ctl-pulse 1.4s ease-in-out infinite}
.ctl-mail-alarm{color:var(--red);font-size:13px;font-weight:600;margin:6px 0 0;display:flex;align-items:center;gap:6px}
.ctl-skull{font-size:20px;line-height:1}
@media (max-width:1180px){.ctl-grid{grid-template-columns:minmax(300px,1.4fr) minmax(280px,1fr)}
  .ctl-mail{grid-column:1 / -1}}
@media (max-width:860px){.ctl-grid{grid-template-columns:1fr}.ctl-mail{grid-column:auto}}

.ctl-big{font-size:54px;line-height:1.05;font-weight:700;font-variant-numeric:tabular-nums}
.ctl-delta{font-size:12.5px;font-weight:600;color:var(--muted);margin-left:8px;vertical-align:super}
.ctl-delta.up{color:var(--green)} .ctl-delta.down{color:var(--red)}
.ctl-rows{display:flex;gap:18px;margin-top:10px;flex-wrap:wrap}
.ctl-rows div{display:flex;flex-direction:column}
.ctl-rows span{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)}
.ctl-rows b{font-size:18px;font-variant-numeric:tabular-nums}
.ctl-flow{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap;font-size:12px}
.ctl-flow span{padding:2px 8px;border-radius:999px;border:1px solid var(--border);color:var(--muted)}
.ctl-flow .new{color:var(--green);border-color:color-mix(in srgb,var(--green) 40%,transparent)}
.ctl-flow .ret{color:var(--text)}
.ctl-flow .lap{color:var(--red);border-color:color-mix(in srgb,var(--red) 40%,transparent)}
/* Unique app visits, under the headline: its own three windows and a bar
   a day (the 21:00 summaries), small -- a second reading, not a rival. */
.ctl-visits{margin-top:14px;padding-top:12px;border-top:1px solid var(--border)}
.ctl-visits-h{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)}
.ctl-visits .ctl-rows{margin-top:6px}
.ctl-vbars{display:flex;align-items:flex-end;gap:3px;height:36px;margin-top:10px}
.ctl-vbar{flex:1 1 0;min-width:0;height:100%;display:flex;align-items:flex-end}
.ctl-vbar i{display:block;width:100%;min-height:2px;border-radius:2px 2px 0 0;
  background:color-mix(in srgb,var(--blue) 65%,transparent)}

.ctl-live-row{display:flex;align-items:center;gap:8px;padding:7px 0;border-bottom:1px solid var(--border);
  color:var(--text);text-decoration:none;font-size:13px}
.ctl-live-row:last-child{border-bottom:0}
.ctl-live-row .grow{flex:1}
.ctl-live-row.past{color:var(--muted)}
.ctl-live-row.past b{color:var(--text)}
/* The one moving thing on the page, and it moves only while something is
   actually being recorded. */
.ctl-live-row .dot{width:8px;height:8px;border-radius:50%;background:var(--red);animation:ctl-pulse 1.6s ease-in-out infinite}
@keyframes ctl-pulse{0%,100%{opacity:1}50%{opacity:.25}}

.ctl-tabs{display:flex;gap:6px}
/* The curves' switches: split by, drawn as, then which curve. */
.ctl-curve-switches{display:flex;flex-wrap:wrap;align-items:center;gap:8px 18px}
.ctl-tabs a{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);
  color:var(--muted);text-decoration:none}
.ctl-tabs a.on{color:var(--text);border-color:var(--muted)}
.ctl-plot{width:100%;height:220px;display:block}
.ctl-trace{width:100%;height:70px;display:block;margin-top:6px}
.ctl-plot .grid,.ctl-trace .grid,.ctl-lane .grid{stroke:var(--border);stroke-width:1;vector-effect:non-scaling-stroke}
.ctl-trace .axis{stroke:var(--muted);stroke-width:1;vector-effect:non-scaling-stroke}
.ctl-plot .tick{fill:var(--muted);font-size:11px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.ctl-trace .trace{fill:color-mix(in srgb,var(--green) 22%,transparent);stroke:var(--green);stroke-width:1.4;
  vector-effect:non-scaling-stroke}
.ctl-legend{display:flex;gap:14px;flex-wrap:wrap;margin-top:8px;font-size:12px;color:var(--muted)}
.ctl-when{min-width:52px;font-variant-numeric:tabular-nums}
.ctl-plot,.ctl-trace{cursor:crosshair}
.cursor{stroke:var(--muted);stroke-width:1;stroke-dasharray:2 3;vector-effect:non-scaling-stroke}
/* The trace keeps its own y axis: "peak 6" in prose is a caption, and a
   caption is not a scale you can read a height against. */
/* ONE gutter for the whole strip. The trace and the lanes below it are
   the same 48 hours of the same clock, so an hour has to be the same
   number of pixels in both -- otherwise the peak sits to the left of
   the pulse that caused it and the two plots cannot be read against
   each other at all. They were 1232px and 1100px wide on production:
   132px of drift, the lane-name column that only one of them had.
   Three rules share this number and a test pins them together. */
.ctl-strip{--ctl-gutter:158px}
/* The curves' labels live beside the SVG, not in it: the plot zooms along
   x (app.js ctlZoomable) and text inside would stretch with it. The x
   labels are redrawn for what is in view. */
.ctl-plot-wrap{position:relative;margin:10px 0 22px}
.ctl-plot-y span{position:absolute;left:4px;transform:translateY(-110%);font:11px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted);pointer-events:none}
.ctl-plot-x{position:absolute;left:0;right:0;top:100%;height:20px;overflow:hidden}
.ctl-plot-x span{position:absolute;top:4px;padding-left:4px;white-space:nowrap;font:11px ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--muted)}
.ctl-axis span[hidden]{display:none}
.ctl-trace-wrap{position:relative;padding-left:var(--ctl-gutter);margin-top:6px}
/* Right-aligned inside the gutter: the scale belongs to the axis it
   labels, not to the left margin of the page. */
.ctl-yaxis{position:absolute;left:0;top:0;height:60px;width:calc(var(--ctl-gutter) - 8px);
  display:flex;flex-direction:column;align-items:flex-end;
  justify-content:space-between;font-size:10.5px;color:var(--muted);font-variant-numeric:tabular-nums}
.ctl-readout{position:absolute;top:-2px;transform:translateX(-50%);background:var(--surface2);
  border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:11.5px;
  font-variant-numeric:tabular-nums;pointer-events:none;white-space:nowrap}
.ctl-legend i,.ctl-states i,.swatch{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}
.ctl-legend b{color:var(--text);font-variant-numeric:tabular-nums}

.ctl-lanes{margin-top:8px;position:relative}
.ctl-lane{display:grid;grid-template-columns:calc(var(--ctl-gutter) - 8px) 1fr;align-items:center;gap:8px;margin-bottom:3px}
.ctl-lane-name{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ctl-lane-name i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:5px}
.ctl-lane svg{width:100%;height:22px;display:block;background:var(--surface);border-radius:3px}
.ctl-lane .night{fill:rgba(255,255,255,.035)}
/* Every pulse is outlined in the page's own dark, so two sessions that
   touch on a 48-hour axis read as two and not as one long block. The
   `night` bands and the grid keep their own fills -- the rule names
   the blocks, not every rect in the lane. */
.ctl-lane a rect{stroke:var(--card);stroke-width:1;vector-effect:non-scaling-stroke}
.ctl-lane rect.live{stroke:var(--text);stroke-width:1;vector-effect:non-scaling-stroke}
/* Every block is a link to its training. The pointer is the only hint
   it needs; brightening on hover says the block under the card is the
   one that would open. */
.ctl-lane a{cursor:pointer}
.ctl-lane a:hover rect{filter:brightness(1.35)}
/* The legend is the same map the blocks are painted from, so a colour
   on the strip always has a word for it two inches away. */
.ctl-strip-right{display:flex;flex-direction:column;align-items:flex-end;gap:4px}
.ctl-states{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:4px 12px;font-size:11.5px;color:var(--muted)}
/* A failed session keeps its GYM's colour and gets a red outline. It
   used to be painted red outright, which on a page where colour means
   "which gym" made a failed session at Champ look like a session at
   Bronx. */
.ctl-lane rect.failed{stroke:var(--red);stroke-width:2;vector-effect:non-scaling-stroke}
.ctl-axis{position:relative;height:14px;margin-left:var(--ctl-gutter)}
.ctl-axis span{position:absolute;transform:translateX(-50%);font-size:10.5px;color:var(--muted);
  font-variant-numeric:tabular-nums;white-space:nowrap}
/* The plot's lines keep their weight when the y range adapts (app.js). */
.ctl-plot polyline{vector-effect:non-scaling-stroke}

/* Cameras: text on the left, the week as pixels on the right, one row
   per camera. Two columns rather than a stack, because the label is
   read once and the map is read across -- and stacking them made every
   gym twice as tall for no extra information. */
.ctl-cam{display:grid;grid-template-columns:minmax(200px,250px) 1fr;gap:14px;align-items:start;margin-top:12px}
.ctl-cam-head{font-size:12px;color:var(--muted);line-height:1.5}
.ctl-cam-head b{color:var(--text);font-variant-numeric:tabular-nums}
.ctl-cam-head .warn{color:var(--orange)}
.ctl-cam-head .bad{color:var(--red)}
.ctl-cam-name{color:var(--text);font-weight:600;font-size:12.5px;white-space:nowrap;overflow:hidden;
  text-overflow:ellipsis}
.ctl-cam-name i{display:inline-block;width:9px;height:9px;border-radius:2px;margin-right:6px}
.ctl-cam-pixels{display:flex;flex-direction:column;gap:2px}
/* The camera id sits in the row's own height: 9px of type is small, and
   it is the thing an installer needs ("0005 is dark") next to the row
   that shows it. */
.ctl-lens{display:grid;grid-template-columns:30px 1fr;gap:6px;align-items:center}
.ctl-lens-id{font-size:9px;line-height:1;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
  text-align:right;font-variant-numeric:tabular-nums}
.ctl-pixel-axis-inset{margin-left:36px}
/* 168 cells of equal share: a grid, not fixed pixels, so the week spans
   the box on a laptop and on the wall screen alike. */
.ctl-pixels{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:1px;height:9px}
.ctl-pixels{cursor:crosshair}
.ctl-pixels i{display:block;background:var(--surface);border-radius:1px}
.ctl-pixels i.on{background:var(--green)}
/* Recorded, not yet uploaded: the one state invisible everywhere else,
   because the session looks fine until somebody opens it and the video
   is not there. */
.ctl-pixels i.waiting{background:var(--orange)}
.ctl-pixel-axis{display:grid;grid-auto-flow:column;grid-auto-columns:1fr;margin-top:2px;
  font-size:10.5px;color:var(--muted)}
.ctl-pixel-axis span{text-align:left}
.ctl-cam-axis{margin-top:6px}
@media (max-width:760px){.ctl-cam{grid-template-columns:1fr;gap:4px}}

.ctl-state{font-size:11px;text-transform:uppercase;letter-spacing:.5px;padding:2px 8px;border-radius:999px;
  border:1px solid var(--border);color:var(--muted)}
.ctl-state.recording{color:var(--red);border-color:color-mix(in srgb,var(--red) 45%,transparent)}
.ctl-state.ready{color:var(--green);border-color:color-mix(in srgb,var(--green) 45%,transparent)}
.ctl-state.degraded{color:var(--orange);border-color:color-mix(in srgb,var(--orange) 45%,transparent)}
.ctl-sub td.warn{color:var(--orange)}

/* The shift log: one line, scrolling, because a control room reads the
   last thing that happened without scrolling a list. */
.ctl-ticker{display:flex;align-items:center;gap:10px;overflow:hidden;padding:8px 14px}
.ctl-ticker-window{flex:1;overflow:hidden}
.ctl-ticker-next{flex:0 0 auto;background:var(--surface);border:1px solid var(--border);color:var(--muted);
  border-radius:6px;padding:2px 8px;font-size:13px;line-height:1.3;cursor:pointer}
.ctl-ticker-next:hover{color:var(--text);border-color:var(--muted)}
/* One row, every event on it, nothing wrapping: the track is moved as a
   whole and the box must not change height while it moves. */
.ctl-ticker-track{display:flex;gap:28px;white-space:nowrap;will-change:transform}
.ctl-ev{flex:0 0 auto;font-size:12.5px;color:var(--muted)}
.ctl-ev b{color:var(--text);font-variant-numeric:tabular-nums;margin-right:6px}
.ctl-ev-icon{margin-right:4px}
.ctl-ev.training{color:var(--text)}
/* The right edge fades, so an event entering the window looks like it
   came from somewhere rather than appearing out of the border. */
.ctl-ticker-window{-webkit-mask-image:linear-gradient(to right,#000 92%,transparent);
  mask-image:linear-gradient(to right,#000 92%,transparent)}
@media (prefers-reduced-motion:reduce){
  .ctl-ticker-track{transition:none !important}
  .ctl-live-row .dot{animation:none}
}

/* ── control room: the NEON skin ─────────────────────────────────────
   Decoration only, and scoped to `.ctl.neon` so nothing outside this
   page can be lit by accident. Every rule here changes a colour, a
   shadow or a motion -- no layout, no number, and no colour that CARRIES
   meaning (a gym's own colour, the orange of footage still waiting, the
   red of a dead mail queue) is touched, or the page would look better
   and say less.

   The parts: one flat tone behind everything, a magenta/cyan palette,
   glow on the type and the plots, and a hint of CRT.

   Nothing in the background moves, and nothing in it is drawn. A
   sliding grid, a breathing horizon and a sun were all tried and taken
   out: the animation cost more than it added under a page that redraws
   thousands of cells, and the scenery left every panel sitting on a
   different shade of whatever was behind it. The neon lives on the
   ELEMENTS -- borders, type, plots -- which is where it survives being
   looked at for an hour. */
.ctl-scanlines{display:none}
.ctl.neon{--neon-cyan:#4de3ff; --neon-magenta:#ff3df0; --neon-violet:#9b5bff; --neon-lime:#5bff9b;
  position:relative;z-index:0;isolation:isolate}
.ctl.neon .ctl-box{background:#120a26;
  border:1px solid rgba(155,91,255,.45);box-shadow:0 0 0 1px rgba(77,227,255,.08) inset,
  0 0 18px rgba(155,91,255,.22),0 0 42px rgba(255,61,240,.08)}
.ctl.neon .ctl-box h3{color:var(--neon-cyan);text-shadow:0 0 8px rgba(77,227,255,.55);letter-spacing:.6px}

/* One flat tone behind the page, and nothing else. The grid, the
   horizon and the sun were tried and taken out: the panels are the
   thing being read, and every one of them sat on a different shade of
   whatever happened to be behind it. The neon lives on the elements
   now -- borders, type, plots -- which is where it survives being
   looked at for an hour.

   `position:fixed` so scrolling does not slide the tone, and a z-index
   below the panels but above the page's own background. */
.ctl.neon::after{content:"";position:fixed;inset:0;z-index:-3;pointer-events:none;background:#0a0618}
/* A hint of CRT, and only a hint: at .35 it dimmed the numbers. */
.ctl.neon .ctl-scanlines{display:block;position:fixed;inset:0;z-index:2;pointer-events:none;opacity:.14;
  background:repeating-linear-gradient(to bottom,rgba(0,0,0,0) 0 2px,rgba(0,0,0,.28) 2px 4px)}

.ctl.neon .ctl-mode{color:var(--neon-cyan);text-shadow:0 0 12px rgba(77,227,255,.6)}
.ctl.neon .ctl-mode.on{color:var(--neon-magenta);text-shadow:0 0 14px rgba(255,61,240,.7)}
.ctl.neon .ctl-clock b{color:#fff;text-shadow:0 0 8px rgba(77,227,255,.5)}
/* The headline is 54px of white: the glow that suits a 12px label turns
   it into a lamp you cannot read the digits of. A thin halo only. */
.ctl.neon .ctl-big{color:#fff;text-shadow:0 0 6px rgba(155,91,255,.35)}
.ctl.neon .ctl-mode-count{color:var(--neon-lime);text-shadow:0 0 12px rgba(91,255,155,.55)}
.ctl.neon .ctl-delta.up{color:var(--neon-lime);text-shadow:0 0 10px rgba(91,255,155,.5)}
.ctl.neon .ctl-rows b{color:#fff;text-shadow:0 0 8px rgba(155,91,255,.5)}
.ctl.neon .ctl-flow span{border-color:rgba(155,91,255,.5);box-shadow:0 0 10px rgba(155,91,255,.18) inset}
.ctl.neon .ctl-tabs a{border-color:rgba(77,227,255,.35)}
.ctl.neon .ctl-tabs a.on{color:#fff;border-color:var(--neon-cyan);box-shadow:0 0 12px rgba(77,227,255,.35)}
/* The curves' split and view toggles (.seg) in the neon skin: the same
   cyan as the metric tabs beside them, not the plain skin's gold. */
.ctl.neon .seg{background:rgba(10,8,30,.6);border-color:rgba(77,227,255,.35)}
.ctl.neon .seg a{color:rgba(200,230,255,.7)}
.ctl.neon .seg a.on{color:#fff;background:rgba(77,227,255,.18);box-shadow:0 0 12px rgba(77,227,255,.35)}

/* The plots keep their colours -- a gym's colour is data -- and gain a
   glow around them, which is the whole trick of a neon chart. */
.ctl.neon .ctl-plot polyline,.ctl.neon .ctl-trace .trace{filter:drop-shadow(0 0 4px currentColor)}
.ctl.neon .ctl-plot polyline{filter:drop-shadow(0 0 5px rgba(255,255,255,.35))}
.ctl.neon .ctl-plot .grid,.ctl.neon .ctl-trace .grid,.ctl.neon .ctl-lane .grid{stroke:rgba(155,91,255,.35)}
.ctl.neon .ctl-lane svg,.ctl.neon .ctl-pixels i{background:rgba(120,80,200,.10)}
.ctl.neon .ctl-lane rect{filter:drop-shadow(0 0 3px currentColor)}
/* No glow on the camera cells. A 6px halo on each of twenty thousand
   3px cells does not read as neon, it reads as a smudge -- and the
   brightness of a cell is DATA (minutes of footage in that hour), which
   a halo from its neighbour destroys. Solid blocks, full strength. */
/* Toned down from the neon lime: the camera cells were the brightest
   thing on the page, and they are the smallest. */
.ctl.neon .ctl-pixels i.on{background:#2fbf72}
.ctl.neon .ctl-pixels i.waiting{background:#d97a2b}
.ctl.neon .ctl-state.ready{box-shadow:0 0 10px rgba(91,255,155,.25)}
.ctl.neon .ctl-state.recording{box-shadow:0 0 12px rgba(255,61,240,.35)}
.ctl.neon .ctl-live-row .dot{box-shadow:0 0 10px rgba(255,61,240,.9)}
/* The tables bring their own greys from the site-wide stylesheet --
   .tw carries --card and th carries --surface -- and on this page they
   read as a panel inside a panel. They take the skin's own tone. */
.ctl.neon .tw{background:transparent;border-color:rgba(155,91,255,.30)}
.ctl.neon table th{background:rgba(155,91,255,.10);color:var(--neon-cyan);border-color:rgba(155,91,255,.30)}
.ctl.neon table td{border-color:rgba(155,91,255,.18)}
.ctl.neon tbody tr:hover{background:rgba(155,91,255,.10)}
.ctl.neon .ctl-ev b{color:var(--neon-cyan);text-shadow:0 0 8px rgba(77,227,255,.5)}
.ctl.neon .ctl-ticker-next{border-color:rgba(77,227,255,.45);color:var(--neon-cyan)}

.ctl-hdr-right{display:flex;align-items:center;gap:12px}

/* The card that follows the cursor over a pulse on the strip. It is
   pinned to the panel, not to the lane: the lanes are 22px apart, so a
   card anchored to the block itself would cover the two rows either
   side of the one being pointed at. */
.ctl-strip{position:relative}
.ctl-tip{position:absolute;z-index:6;pointer-events:none;max-width:420px;
  background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:10px 12px;
  box-shadow:0 8px 28px rgba(0,0,0,.45);display:flex;flex-direction:column;gap:3px}
.ctl-tip b{font-size:12.5px;color:var(--text)}
.ctl-tip i{font-style:normal;font-size:12px;color:var(--muted)}
.ctl-tip u{text-decoration:none;font-size:12px;color:var(--text);opacity:.85}
.ctl.neon .ctl-tip{background:#160d2c;border-color:rgba(155,91,255,.55);
  box-shadow:0 0 18px rgba(155,91,255,.35)}
.ctl.neon .ctl-tip b{color:#fff;text-shadow:0 0 8px rgba(77,227,255,.5)}
.ctl.neon .ctl-tip i{color:#b9a9e8}
.ctl.neon .ctl-tip u{color:#e7e0ff}

/* The header belongs to whatever page it is sitting on, so in the lit
   control room it is lit too. `body.neon-page` is set by control.html
   ALONE (and by the toggle, on that page only): follow any link out of
   the room and the portal is its ordinary self, preference still on --
   the skin is this screen's, not the account's idea of the product. */
body.neon-page{background:#0a0618}
body.neon-page .hdr{background:#120a26;border-bottom-color:rgba(155,91,255,.45);
  box-shadow:0 1px 20px rgba(155,91,255,.22)}
body.neon-page .brand img{filter:drop-shadow(0 0 7px rgba(77,227,255,.75))}
body.neon-page .brand small{color:#9b8ecf}
body.neon-page .hdr-control,body.neon-page .nav a{color:#9b8ecf}
body.neon-page .hdr-control:hover,body.neon-page .nav a:hover{background:rgba(155,91,255,.16);color:#fff}
body.neon-page .hdr-control.on,body.neon-page .nav a.on{color:#fff;background:rgba(255,61,240,.16);
  border-color:#ff3df0;box-shadow:0 0 14px rgba(255,61,240,.30)}
body.neon-page .nav a.acc{color:#e7e0ff}
body.neon-page .btn.ghost{color:#4de3ff;border-color:rgba(77,227,255,.45)}
body.neon-page .btn.ghost:hover{background:rgba(77,227,255,.12)}

/* Nothing in the neon skin animates, so there is nothing here to drop
   under prefers-reduced-motion. The only movement left on the page is
   the live dot and the shift log, which both predate the skin and both
   already stop there. */

/* TABLET PANEL (/tablets). Production and internal/test gyms share one
   page but not one wall: the segmented control swaps the stable groups.
   The active group is packed by app.js into as many columns as the
   viewport can carry while keeping every portrait tile visible. This is
   deliberately not a fixed five columns: on a wide control-room display
   five left large empty gutters on both sides.

   The tiles are PORTRAIT (the kiosks shoot 1200x1920), so the aspect-ratio
   box is what keeps a row even while the pictures load -- without it the
   grid reflows twice a minute as thumbnails arrive.

   `pointer-events:none` on the image is belt to the braces of "there is
   no link here": nothing in a tile is focusable or clickable, and a
   drag cannot pick the picture up and carry it somewhere either. */
/* Unlike the rest of the portal, the wall is allowed to use the full
   viewport width. The normal 1320px wrapper was the other source of the
   empty side gutters the adaptive grid is meant to reclaim. */
.wrap.tablet-wall-wrap{max-width:none;padding-bottom:18px}
.tabwall{height:calc(100vh - 94px);min-height:340px;display:flex;flex-direction:column;gap:10px}
.tabbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:33px}
.tabbar-actions{display:flex;align-items:center;gap:8px}
.tabenv button span{margin-left:4px;color:var(--muted);font-variant-numeric:tabular-nums}
.tabenv button.on span{color:inherit;opacity:.75}
/* app.js supplies the column count and tile width after measuring the active group.
   The fallback is useful during the first paint and with JavaScript off. */
.tabgrid{display:grid;grid-template-columns:repeat(var(--tab-cols,1),var(--tab-width,auto));
  grid-auto-rows:auto;justify-content:center;align-content:center;gap:10px;
  flex:1;min-height:0;width:100%}
.tabgrid[data-active-environment="prod"] .tab:not([data-environment="prod"]),
.tabgrid[data-active-environment="stage"] .tab:not([data-environment="stage"]){display:none}
.tab{position:relative;margin:0;width:var(--tab-width,auto);height:auto;aspect-ratio:1200/1920;
  background:#0b0d10;border:1px solid var(--line);border-radius:8px;overflow:hidden}
/* `cover`, not `contain`: the tile IS the tablet's shape, so there is
   nothing to letterbox and nothing to crop -- the two agree exactly. */
.tab .shot{position:absolute;inset:0}
.tab .shot img{width:100%;height:100%;object-fit:cover;display:block;pointer-events:none;user-select:none}
.tab .ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
  padding:10px;text-align:center;font-size:11px;line-height:1.35;color:var(--muted)}
/* The caption rides ON the picture. A strip under it would cost 40px of
   every tile -- a tenth of the screen we just fought for -- to say what
   two small lines over a gradient say just as well. */
.tab figcaption{position:absolute;left:0;right:0;bottom:0;padding:16px 7px 5px;
  display:flex;align-items:baseline;justify-content:space-between;gap:6px;
  background:linear-gradient(to top,rgba(0,0,0,.9),rgba(0,0,0,.6) 60%,rgba(0,0,0,0));
  pointer-events:none}
.tab .who{font-weight:600;font-size:11px;line-height:1.2;color:#fff;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-shadow:0 1px 2px rgba(0,0,0,.9)}
/* Right-aligned and never wrapped: "online" and a timestamp are the two
   things a glance across eight tiles is actually reading. Green here is
   the control room's "ready" green -- a live tile and a finished
   training mean the same thing across the portal, "this is good now" --
   and the stale rule below turns it red. Colour, not just wording,
   because a wall panel is read from across the room. */
.tab .when{flex:none;font-size:10px;line-height:1.2;white-space:nowrap;font-weight:600;
  color:#22C55E;text-shadow:0 1px 2px rgba(0,0,0,.9)}
/* A tile nobody has heard from in two rounds. The picture stays -- it is
   still the last thing that screen showed -- but it stops pretending to
   be current. The orange is the control room's "processing" colour,
   written out: there is no --warn token in this stylesheet, and a var()
   with a fallback would have hidden that behind a colour that looks
   deliberate. */
.tab.stale .shot img{opacity:.6;filter:grayscale(.45)}
.tab.stale .when{color:#ff6b6b;font-weight:600}
/* The tile is a way to that tablet's row on /tablets/manage, and to
   nowhere else: an overlay link over the picture, never into the kiosk.
   Above the caption so the whole tile answers, not just the picture. */
.tabgo{position:absolute;inset:0;z-index:1}
.tabgo:focus-visible{outline:2px solid var(--text);outline-offset:-2px}
.tabtz{display:flex;align-items:center;gap:8px}

/* /tablets/manage: one card per tablet. The frame on the left, as tall as
   the card of a grant holder (160px wide = 256px of 1200x1920 portrait,
   what the six rows beside it measure); on the right, top to bottom:
   who/where, the start page on ONE full-width line, the URL field exactly
   as wide right under it, the buttons, what differs, and the rest of
   deviceInfo in columns. A URL wrapped into a narrow
   column is a URL nobody can read or compare, so it gets the card's whole
   width and everything else is grouped around it.
   `.tabrow-live` is `display:contents`: its four children (refreshed as
   one fragment) are grid items of the card, which is what lets the static
   URL field sit between the start page and the rest. */
/* The top margin is room for the corner "Wall" link: where the cards run
   the full content width (~1000px screens) it would otherwise sit on the
   first card's corner. */
.tabrows{display:flex;flex-direction:column;gap:12px;margin-top:16px}
.tabrow{display:grid;grid-template-columns:160px minmax(0,1fr);
  /* The last, empty row takes whatever height the frame has beyond the
     content -- a read-only card, a tablet not yet described. Without it
     the grid spreads that height over every row and opens gaps between
     the start page, its field and the buttons. */
  grid-template-rows:repeat(6,auto) 1fr;
  grid-template-areas:"shot head" "shot start" "shot ctl" "shot btns" "shot diff" "shot params" "shot .";
  column-gap:18px;row-gap:9px;align-items:start;
  background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:12px 14px;
  scroll-margin-top:72px}
.tabrow:target{border-color:var(--text)}
.tabrow.busy{border-color:var(--orange)}
.tabrow-shot{grid-area:shot;position:relative;width:160px;aspect-ratio:1200/1920;background:#0b0d10;
  border:1px solid var(--border);border-radius:6px;overflow:hidden}
.tabrow-shot img{width:100%;height:100%;object-fit:cover;display:block}
.tabrow-shot.stale img{opacity:.6;filter:grayscale(.45)}
.tabrow-shot .ph{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
  padding:6px;text-align:center;font-size:11px;color:var(--muted)}
.tabrow-live{display:contents}
.tabrow-head{grid-area:head;display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;min-width:0}
.tabrow-gym{font-weight:600;font-size:15px}
.tabrow-age{display:inline-flex;gap:10px;font-size:12px;font-weight:600}
.tabrow-age .ok{color:#22C55E}
.tabrow-age .old{color:#ff6b6b}
.tabrow-rec{font-size:12px;font-weight:600;color:var(--orange)}
.tabrow-start{grid-area:start;display:flex;gap:10px;align-items:baseline;min-width:0;font-size:13px}
.tabrow-start .k,.tabrow-params dt{color:var(--muted);flex:none}
/* One line whenever the card is wide enough, which on a desktop it
   always is; broken anywhere only on a screen that cannot hold it. */
.tabrow-start .v{min-width:0;overflow-wrap:anywhere}
.tabrow-ctl{grid-area:ctl;display:flex;flex-direction:column;gap:7px;min-width:0}
.tabrow-ctl input{width:100%;font-size:13px}
.tabrow-btns{grid-area:btns;display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.tabrow-btns .gap{flex:0 0 10px}
/* The one kiosk button an UNLOCKED tablet offers: loud, because that is
   the state that needs undoing today. */
.btn.danger-solid{background:var(--red);border-color:var(--red);color:#fff}
.tabrow-diff{grid-area:diff;display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;min-width:0}
.tabrow-h{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
.tabrow-diff ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}
.tabrow-diff .flag{font-size:12.5px;line-height:1.35;padding:4px 8px;border-radius:6px;border:1px solid var(--border)}
.tabrow-diff .flag.warn{color:var(--orange);border-color:rgba(251,146,60,.4)}
.tabrow-diff .flag.danger{color:#ff6b6b;border-color:rgba(255,107,107,.55);font-weight:600}
.tabrow-diff .flag.none{color:var(--muted);border:0;padding:0}
.tabrow-diff .flag .btn{margin-left:6px}
.tabrow-act{flex-basis:100%;font-size:12.5px;padding:6px 9px;border-radius:6px;background:var(--surface)}
.tabrow-act.failed{color:#ff6b6b}
.tabrow-act.done{color:#22C55E}
.tabrow-act .dim{color:var(--muted);font-size:11.5px;margin-left:6px}
.tabrow-params{grid-area:params;min-width:0}
.tabrow-params dl{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:3px 22px;margin:0;font-size:12.5px}
.tabrow-params dl>div{display:flex;gap:8px;min-width:0}
.tabrow-params dd{margin:0;min-width:0;overflow-wrap:anywhere}
.tabrow-err{font-size:12px;margin:6px 0 0}
/* The gym page's shots: per ring, the tablet's screen beside a 2x2 of
   its cameras -- the wall's tiles (`.tab`), so captions, freshness and
   the stale look are the wall's. Two rings share the row; one takes the
   left half. The combo has a fixed height: the tablet tile takes it at
   its own portrait shape, the cameras split what is left into four. */
.gymshots{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:4px 0 16px}
.gymcombo{display:flex;gap:8px;height:270px;min-width:0}
.tab.gtab{flex:none;height:100%}
.gcams{flex:1;min-width:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));
  grid-template-rows:repeat(2,minmax(0,1fr));gap:8px}
.tab.gcam{height:auto;min-height:0;aspect-ratio:auto}
.tabc-where{margin:0 0 8px}
.tabc-url{overflow-wrap:anywhere;margin:2px 0 10px;padding:7px 9px;border-radius:6px;background:var(--surface)}
.note.danger{color:#ff6b6b;border:1px solid rgba(255,107,107,.55);border-radius:6px;padding:8px 10px}
.tabc-unlock label{display:flex;gap:8px;align-items:flex-start;margin-top:6px;color:var(--text);font-weight:600}

@media (max-width:720px){

  /* 1. THE HEADER. `.hdr` is `position:fixed;height:56px;overflow-x:auto`
        with a `.nav` that does not wrap and pills that are `white-space:
        nowrap`. A staff account renders all 8 feature links plus Admin,
        the account name and Log out -- roughly 900px of content inside a
        390px bar. The result is not a layout bug you can see; it is a
        hidden horizontal scroll strip with no scrollbar and no affordance,
        so on a phone the nav simply ends after "Search" and the rest of
        the portal is unreachable.

        Letting it wrap needs all four of these together: `height:auto` or
        the extra rows are clipped to 56px; `overflow-x:visible` or the
        rows scroll instead of wrapping (and `overflow-x:auto` computes
        `overflow-y` to `auto`, which clips them vertically too);
        `flex-wrap:wrap` to actually wrap; and `.nav{width:100%}` so the
        pills start on their own row under the brand rather than trailing
        it.

        `position:static` + `body{padding-top:0}` are ONE ATOMIC PAIR --
        `body`'s 56px top padding exists solely to clear the fixed header.
        Change one without the other and you get either a 56px dead band
        at the top of every page or a header sitting on top of every <h1>.
        Static rather than sticky is a deliberate trade: wrapped, the nav
        is ~3 rows / ~100px, and pinning that permanently would eat 15% of
        a ~660px phone viewport. It scrolls away instead.

        Vertical padding is set as LONGHANDS on purpose. `padding` shorthand
        here would also overwrite the horizontal gutter, and that gutter has
        to keep tracking `.wrap`'s -- 16px normally, 11px in the 640px block
        -- or the header sits inboard of the content it sits above. Touching
        only top/bottom leaves both cases correct with no third value. */
  .hdr{padding-top:9px;padding-bottom:9px}
  /* supersedes the `.nav a` rule in the 640px block above -- same
     selector, and this file's last word wins. Taller on purpose: see (4). */
  .nav a{padding:9px 11px;font-size:12px}

  /* 2. THE iOS ZOOM TRAP. Mobile Safari zooms the whole page in when a
        form field smaller than 16px takes focus, and it does NOT zoom back
        out on blur. Base `input,select,textarea` is 13.5px, so the first
        tap on any filter bar -- trainings, users, cameras, search, live,
        exports, admin logs, login, profile -- leaves the user stranded at
        ~1.2x with the page overflowing sideways, which reads as "the site
        is broken" far more than anything on the visible-layout list.
        16px is the documented threshold; it is a floor, not a preference.
        Buttons are excluded on purpose -- they never trigger the zoom. */
  input,select,textarea{font-size:16px}

  /* 3. `.seg` is the last non-wrapping flex primitive. archive.html puts
        two ~157px nowrap buttons in it, and `_admin_nav.html:4` already
        patches its own instance with an inline `flex-wrap:wrap` -- which
        is the tell that the primitive itself is wrong. Fixing it here
        means nobody has to add a third inline patch. The 999px pill radius
        looks broken once the buttons sit on two rows, so it squares off
        to the standard small radius while wrapped. */
  .seg{flex-wrap:wrap;border-radius:var(--rs)}
  .seg button{border-radius:var(--rs);min-height:34px}

  /* 4. TAP TARGETS. Not "comfortable" -- just not microscopic. Baseline
        heights were `.btn` 36px, `.btn.sm` 27px, `.seg button` 28px,
        `.cyc button` 26px, `.ms-foot button` 21px, `.chk` 25px.

        `display:inline-flex` on `.btn` is required, not cosmetic: a dozen
        controls are `<a class="btn">` (error.html, exports.html,
        training.html, _pager.html, ...) and `min-height` does not apply to
        a non-replaced INLINE box, so on anchors the floor would be
        silently ignored. Centring is preserved by the two alignment
        properties. `.btn` beats `td a` below on specificity, so button
        anchors inside tables keep this rule rather than that one. */
  .btn{min-height:40px;display:inline-flex;align-items:center;justify-content:center}
  .btn.sm{min-height:34px;padding:8px 12px}
  .cyc button{min-height:34px;padding:7px 12px}
  .ms-btn{min-height:40px}
  /* The calendar button is only 32px wide on desktop and sits flush
     against the right edge of the field, which is the hardest place on a
     filter bar to hit with a thumb. It stretches the field's full height
     already, so only the width needs saying -- and the text box's right
     padding has to grow with it or the typed date runs underneath. */
  .dt-b{width:40px}
  .dt>input[name]{padding-right:42px}
  .ms-foot button{min-height:32px;padding:7px 6px}
  /* A chip is the ONLY way to drop one person without opening the panel
     and hunting the row, so on a phone it has to be tappable as a chip --
     30px is a desktop pointer's size. Grown from the same 32px floor as
     .ms-foot's buttons, with the gap grown too: two chips 6px apart on a
     390px screen is a boundary mis-tap that removes the wrong person. */
  .chip{min-height:32px;padding:6px 12px;font-size:13px}
  .chips{gap:8px}
  .chk{padding:7px 0}
  /* `.ms-opts .chk` is (0,2,0) and outranks the `.chk` above whatever the
     order, so the multiselect's own option rows need saying separately --
     and they are the main tap target on every filter bar. */
  .ms-opts .chk{padding:8px 4px}
  .chk input{width:18px;height:18px}
  /* The link in a table cell is the way into every detail page and is only
     ~20px tall. Every table already lives in a `.tw` scroll container, so
     growing the row costs nothing outside it. inline-block is what makes
     the vertical padding count.

     One cell is not `white-space:nowrap`: `td.gyms` (see the Gym cell
     block above), where this 4px compounds per wrapped line -- part of
     why a three-gym row measures 102px at 390px against 47px for one gym.
     That is the intended trade: three names, three separate links, each a
     full tap target, and nothing hidden behind hover or `title=`. The gap
     between two adjacent gym links is only the ", " between them, so a
     boundary mis-tap lands on another of that same boxer's gyms -- a
     neighbouring page rather than a wrong one, and strictly better than
     what it replaced, where every name in the cell opened one gym that
     was usually not the one tapped. Deliberate, not overlooked. */
  td a{display:inline-block;padding:4px 0}
  /* Same problem as `td a`, same fix: the participants opener is a ~20px
     button in a table cell, and on a phone it is the ONLY way to see who
     boxed without opening the training -- there is no hover to fall back
     on. Negative margin so a taller tap target does not also make every
     row taller, which is what this cell was changed to avoid. */
  .peo{min-height:32px;padding:6px 8px;margin:-6px -8px}

  /* 5. VIDEO MODALS. `.mask` 16px + `.modal` 20px of padding leaves a 16:9
        video 318px wide on a 390px screen -- it fits, it just wastes a
        fifth of the width on frame. Both overlays are `.modal.big` and
        both <video> already carry `playsinline`, so iOS will not hijack
        them into fullscreen; this only reclaims the margin. `max-height`
        goes to 94vh because with the header no longer fixed there is no
        56px strip to stay clear of. `.modal .foot` -- NOT `.foot`, which
        is also the page footer -- stretches Close/Confirm to thumb width. */
  .mask{padding:8px}
  .modal{padding:12px;max-height:94vh}
  .modal .foot .btn{flex:1}

  /* 6. TABLET PANEL. The adaptive desktop wall becomes two columns: the tiles are portrait, so
        on a 390px screen four columns would be 90px wide and the kiosk's
        own header -- the one thing that says WHICH ring this is -- would
        be unreadable. Two by four, which is the same wall turned on its
        side. */
  /* Two across and scrolling. Eight portrait tiles cannot share a
     handset's height, and squeezing them to fit would leave four rows of
     unreadable slivers; the tile keeps the tablet's shape and the page
     scrolls instead. */
  .wrap.tablet-wall-wrap{padding-bottom:50px}
  .tabwall{height:auto;min-height:0}
  .tabbar{align-items:flex-start;flex-wrap:wrap}
  .tabbar-actions{margin-left:auto}
  .tabgrid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;
    height:auto;min-height:0;grid-auto-rows:auto;justify-content:stretch}
  .tab{height:auto;width:auto}

  /* 7. TABLET MANAGE. The frame shrinks and shares only the head's row;
        the start page, the field, the buttons and the rest take the full
        width below it. */
  .tabrow{grid-template-columns:84px minmax(0,1fr);column-gap:12px;
    grid-template-rows:none;
    grid-template-areas:"shot head" "start start" "ctl ctl" "btns btns" "diff diff" "params params"}
  .tabrow-shot{width:84px}
  .tabrow-head{flex-direction:column;align-items:flex-start;gap:3px}
  .tabrow-start{flex-direction:column;gap:2px}
  .tabrow-params dl{grid-template-columns:minmax(0,1fr)}

  /* 8. GYM SHOTS. One ring per row, a little shorter. */
  .gymshots{grid-template-columns:minmax(0,1fr)}
  .gymcombo{height:210px}
}

/* ── boxers' problem reports (/reports, the training card) ──────────
   Cards, not table rows: the point is the boxer's text, which can run to
   4000 characters and must keep its own line breaks. It wraps anywhere
   so one unbroken string cannot widen the page on a phone. */
.reps{display:flex;flex-direction:column;gap:10px}
.rep{background:var(--card);border:1px solid var(--border);border-radius:var(--r);padding:12px 14px}
.blk .rep{background:var(--surface)}
.rep-h{display:flex;flex-wrap:wrap;gap:4px 14px;align-items:baseline;font-size:12.5px;color:var(--muted)}
.rep-h b{color:var(--text)}
.rep-msg{margin:8px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.5;color:var(--text)}

/* ── aligned grid (/trainings/{id}/grid) ─────────────────────────── */
/* Two across at any count: with four cameras that is the 2x2. The
   players share one transport bar; none carries its own controls.
   Heading on one line, the bar, then the cameras, fitting the window
   without scrolling. Two rows of 16:9 pictures are as tall as the grid
   is wide * 9/16, so the grid's width is capped by the height left
   under the page chrome (--vgrid-chrome: header, crumbs, heading, bar,
   gaps). A tall window keeps the usual column width. */
.vgrid-head{display:flex;align-items:baseline;gap:6px 14px;flex-wrap:wrap;margin-bottom:10px}
.vgrid-head h1{margin:0}
.vgrid-head .sub{margin:0}
/* the bar spans exactly the pictures it drives */
.vgrid,.vgrid-bar{--vgrid-chrome:220px;max-width:calc((100dvh - var(--vgrid-chrome)) * 16 / 9 + 8px);
  margin-left:auto;margin-right:auto}
.vgrid{display:grid;grid-template-columns:repeat(2,1fr);gap:8px}
.vgrid figure{position:relative;margin:0;background:#000;border:1px solid var(--border);border-radius:var(--rs);overflow:hidden}
.vgrid video{display:block;width:100%;aspect-ratio:16/9;background:#000}
.vgrid figcaption{position:absolute;top:6px;left:6px;padding:2px 7px;font-size:12px;color:#fff;
  background:rgba(0,0,0,.55);border-radius:4px;pointer-events:none}
.vgrid-bar{display:flex;align-items:center;gap:8px;background:var(--card);border:1px solid var(--border);
  border-radius:var(--rs);padding:6px 10px;margin-bottom:8px}
.vgrid-bar .btn{flex:none}
.vgrid-bar .vgrid-jump{min-width:46px;padding:5px 8px;font-variant-numeric:tabular-nums}
.vgrid-play{display:inline-flex;align-items:center;justify-content:center;width:38px;height:32px;padding:0;margin:0 2px}
.vgrid-play svg{width:16px;height:16px;fill:currentColor}
.vgrid-play .i-pause,.vgrid-play[data-playing="1"] .i-play{display:none}
.vgrid-play[data-playing="1"] .i-pause{display:block}
.vgrid-bar input[type=range]{flex:1;width:auto;min-width:0;padding:0;margin-left:6px;border:0;background:none;accent-color:var(--accent)}
.vgrid-bar #grid-time{flex:none;white-space:nowrap}
.vgrid-bar select{flex:none;width:auto;background:var(--surface);color:var(--text);border:1px solid var(--border);
  border-radius:var(--rs);padding:4px 8px}
.wrap:has(.vgrid){padding-bottom:12px}
@media (max-width: 760px){ .vgrid{grid-template-columns:1fr} .vgrid,.vgrid-bar{max-width:none} }

/* Calendar decorations share one inert, paint-contained plane. Theme files
   own every visual rule and are loaded only on their scheduled dates. */
.season-layer{position:fixed;inset:0;z-index:120;overflow:hidden;pointer-events:none;
  contain:layout paint style;isolation:isolate}
.season-layer,.season-layer *{box-sizing:border-box;pointer-events:none}
.season-action{position:fixed;z-index:121;padding:0;border:0;background:none;color:inherit;cursor:pointer;
  pointer-events:auto;touch-action:manipulation}
.season-action:focus-visible{outline:2px solid var(--accent);outline-offset:5px;border-radius:12px}

/* ── round-split review (/trainings/{id}/review) ─────────────────── */
.rv-ctl{align-items:flex-end;gap:14px}
.rv-ctl input{width:64px;text-align:center}
.rv-vid{width:100%;max-height:56vh;aspect-ratio:16/9;object-fit:contain;background:#000;
  border-radius:var(--rs);display:block}
.rv-rails{display:flex;flex-direction:column;gap:6px;margin-top:10px}
.rv-row{display:flex;align-items:center;gap:10px}
.rv-k{width:72px;flex:none;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);
  text-align:right}
.rv-rail{position:relative;flex:1;height:22px;background:var(--surface);border-radius:6px}
.rv-seg{position:absolute;top:3px;bottom:3px;background:var(--accent);opacity:.75;border-radius:3px;
  cursor:pointer;overflow:hidden}
.rv-seg span{display:block;font-size:10px;font-weight:700;color:#201b10;text-align:center;line-height:16px}
.rv-seg.rv-ok{background:var(--green)}
.rv-seg.rv-clash{background:var(--red);opacity:.9;pointer-events:none;top:0;bottom:0}
.rv-seg.rv-prob{background:var(--red);opacity:.9;pointer-events:none;top:8px;bottom:8px;border-radius:2px}
.rv-head{position:absolute;top:-2px;bottom:-2px;width:2px;background:var(--text);border-radius:1px;
  pointer-events:none}
.rv-now{font-size:11.5px;color:var(--text)}
.rv-tip{position:absolute;bottom:calc(100% + 5px);transform:translateX(-50%);pointer-events:none;z-index:2;
  background:var(--surface2);border:1px solid var(--border);border-radius:var(--rs);
  padding:2px 7px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;color:var(--text);
  white-space:nowrap}
.rv-seg.rv-ok span{color:#06250f}
.rv-crops img{height:40px;border-radius:4px;margin-right:4px;vertical-align:middle}
.rv-in{width:84px;text-align:center;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
tr.rv-bad .rv-in{border-color:var(--red)}
.rv-x{margin-left:6px}
.rv-bx{width:44px;margin-left:8px}
.rv-c{width:180px;text-align:left}
/* the metric charts: web-pwa's MetricChart palette on this page's grid */
.rv-plots{margin-top:18px}
.rv-ph{margin:18px 0 8px;font-size:15px}
.rv-plotrows{display:grid;gap:10px}
.rv-plotgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px}
.rv-chart{position:relative;border:1px solid #262626;background:#191919;border-radius:var(--rs);
  padding:10px 12px}
.rv-chart h4{margin:0 0 4px;font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;
  color:#7D7779}
.rv-chart svg{display:block;width:100%}
.rv-leg{display:flex;justify-content:flex-end;gap:12px;font-size:10px;letter-spacing:.01em;
  text-transform:uppercase;color:#7D7779}
.rv-leg span{display:flex;align-items:center;gap:6px}
.rv-leg i{display:block;width:26px;border-top:3px solid #D8BD70}
.rv-leg i.opp{border-top-style:dashed;border-top-color:#D63D51}

/* ── training schedules (/trainings/schedules) ───────────────────── */
/* Everything else on this page reuses the portal's own classes --
   .bar, .tw, .panel, .row, .btn, .chip, .note. These four have no
   equivalent to borrow, so they are the page's whole stylesheet. */
/* a paused, ended or cancelled row: still readable, visibly not live */
tr.dimrow td{opacity:.55}
/* the actions column, hard right so the buttons line up down the table */
.tw td.right{text-align:right;white-space:nowrap}
/* ten columns do not fit a laptop: the two widest are a monospaced
   RRULE and a date range, and both read fine wrapped. Without this the
   table overflows .tw and the ACTIONS column -- Pause, edit -- is the
   part that scrolls out of sight. */
/* break between RRULE parts, never mid-token: BYDAY=S / A,SU is
   harder to read than a slightly taller cell. */
.sched-tbl code{white-space:normal;overflow-wrap:anywhere;word-break:normal;font-size:12px;line-height:1.5}
.sched-tbl td.runs{white-space:normal;min-width:92px}
.sched-tbl th,.sched-tbl td{padding-left:10px;padding-right:10px}

/* label.f uppercases the FIELD NAME, and a hint written inside the
   label inherits it -- a whole sentence in caps. Put the hint back
   into sentence case wherever it sits inside a label. */
label.f .note,label.f .hint{text-transform:none;letter-spacing:normal;
  font-weight:400;display:block;margin-top:5px}

/* the inline "add" / "edit" disclosures inside a cell: a summary that
   reads as a link, and a body that does not stretch its column */
details.addreg,details.editrule{display:inline-block}
details.addreg>summary,details.editrule>summary{cursor:pointer;font-size:12.5px;
  list-style:none;text-decoration:underline;text-underline-offset:2px}
/* the native marker is a bare triangle mid-sentence; .blk hides its
   own the same way and draws one that belongs to the design */
details.addreg>summary::-webkit-details-marker,
details.editrule>summary::-webkit-details-marker{display:none}
details.addreg[open],details.editrule[open]{display:block;margin-top:6px}

/* The edit form lives in its OWN full-width row (colspan) because
   inside the actions cell it grew the table past .tw and pushed that
   very column off-screen -- 774px of horizontal scroll. Here it has
   the table's width, so the fields lay out like any other form. */
.sched-tbl tr.editrow>td{padding:0 13px 0;text-align:left}
.sched-tbl tr.editrow details[open]>form{padding:4px 0 14px;max-width:46rem}
/* .row centres its children, which floats a short caption beside a
   tall input instead of above it. Top-align inside this form. */
details.editrule[open] .row{flex-wrap:wrap;gap:10px 14px;align-items:flex-start}
details.editrule[open] label.f{display:block;flex:1 1 160px;min-width:0}
details.editrule[open] label.chk{flex:1 0 100%;white-space:nowrap}
/* the caption is a bare text node and the control an inline-block, so
   they share a line and overlap. display:block on the control is what
   puts the caption above it. */
details.editrule[open] input,details.editrule[open] select{display:block;width:100%;max-width:100%;margin-top:3px}
details.editrule[open] label.chk input{display:inline-block;width:auto;margin:0 6px 0 0}
details.editrule[open] .note{white-space:normal}

/* the add-regulars popover opens inside a ~110px cell, so its
   multiselect had nowhere to go: float it over the row instead of
   squeezing the column. */
.sched-tbl td.people{position:relative}
details.addreg[open]>form{position:absolute;right:0;top:100%;z-index:4;width:260px;
  text-align:left;background:var(--surface);border:1px solid var(--border);
  border-radius:var(--rs);padding:12px;box-shadow:0 8px 24px rgba(0,0,0,.45)}
details.addreg[open]>form .btn{margin-top:8px}
