:root{--bg:#efe9dd;--panel:#f8f3ea;--ink:#2b241c;--muted:#7c7264;--line:#e0d7c6;--accent:#bf5a12;--accent-soft:#f5e7d6;--ours:#ebe3d4;--theirs:#f7ecdb;--rail:#e9e1d1;--bad:#b42318}
@media (prefers-color-scheme:dark){:root{--bg:#14110d;--panel:#1d1913;--ink:#ece6dd;--muted:#9c9284;--line:#2d2820;--accent:#d9722e;--accent-soft:#34251a;--ours:#241f18;--theirs:#2b2016;--rail:#1a160f;--bad:#b42318}}
:root[data-theme="light"]{--bg:#efe9dd;--panel:#f8f3ea;--ink:#2b241c;--muted:#7c7264;--line:#e0d7c6;--accent:#bf5a12;--accent-soft:#f5e7d6;--ours:#ebe3d4;--theirs:#f7ecdb;--rail:#e9e1d1;--bad:#b42318}
:root[data-theme="dark"]{--bg:#14110d;--panel:#1d1913;--ink:#ece6dd;--muted:#9c9284;--line:#2d2820;--accent:#d9722e;--accent-soft:#34251a;--ours:#241f18;--theirs:#2b2016;--rail:#1a160f;--bad:#b42318}
/* The MARK's own eight fills, as tokens, because the logo is INLINE SVG now
   (templates/_kozak-mark.svg) rather than a light/dark <img> pair -- an <img>
   is an isolated document no stylesheet can reach into. Same four-block
   structure as the colour tokens above and for the same reason: the OS
   preference AND the explicit data-theme override both have to move them, or a
   system-dark viewer who never picked a theme is served the LIGHT mark.
   Only SIX of the eight change: skin (#E7B48C) and eyes (#2C2825) were already
   byte-identical in the two source files. Values lifted verbatim from
   static/img/kozak-{light,dark}.svg, which stay on disk for the favicon. */
:root{--kz-coat:#E07B2C;--kz-shirt:#F4EAD6;--kz-patch:#C0472A;--kz-hair:#48331E;
  --kz-hat:#2F2B2A;--kz-hatband:#D8B978;--kz-skin:#E7B48C;--kz-eyes:#2C2825}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
  --kz-coat:#ED8636;--kz-shirt:#F6EEDD;--kz-patch:#B23A26;--kz-hair:#6E4A2C;
  --kz-hat:#C25A1E;--kz-hatband:#F2D9A8}}
:root[data-theme="dark"]{
  --kz-coat:#ED8636;--kz-shirt:#F6EEDD;--kz-patch:#B23A26;--kz-hair:#6E4A2C;
  --kz-hat:#C25A1E;--kz-hatband:#F2D9A8}
*{box-sizing:border-box}
/* color-scheme tells the browser to paint NATIVE controls (select popups, the
   selected-option highlight, scrollbars, focus rings) in the page's theme. Without
   it a <select> in dark mode renders its selection with the light-theme white
   highlight/border until it loses focus -- the "white border of sorts" the user
   hit on the compare pickers. It has to track BOTH the OS preference and the
   explicit data-theme override, same as the colour tokens above. */
:root{color-scheme:light}
@media (prefers-color-scheme:dark){:root{color-scheme:dark}}
:root[data-theme="light"]{color-scheme:light}
:root[data-theme="dark"]{color-scheme:dark}
/* and paint the control itself + its option list from our own tokens, so the
   popup matches the panel rather than the OS default. */
select,option{background:var(--panel);color:var(--ink)}
select:focus{outline:2px solid var(--accent);outline-offset:-1px}

/* flex column + min-height:100vh so the left rail (.shell) can claim the
   leftover height under the header -- see the .shell rule below. */
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;display:flex;flex-direction:column;min-height:100vh}
/* The header STICKS: the section name, the ☰ sidebar toggle and the theme
   button have to stay reachable while you scroll a long battery/history page
   (asked for alongside the sticky rail). Needs its own background -- a sticky
   element sits over the content scrolling beneath it -- and a z-index above the
   rail so the rail's sticky nav slides under it, not over it. --hdr is the
   height budget the rail offsets itself by; keep the two in step if the
   header's padding ever changes. */
:root{--hdr:53px}
header.app{position:sticky;top:0;z-index:20;background:var(--bg);display:flex;align-items:center;gap:14px;padding:10px 18px;border-bottom:1px solid var(--line)}
header.app h1{font-size:16px;margin:0;font-weight:650}.spacer{flex:1}
/* the page name IS the link back to that destination's root (same as clicking its
   sidebar entry); reads as a plain heading until hovered */
header.app h1 a.sectionhome{color:inherit;text-decoration:none}
header.app h1 a.sectionhome:hover{color:var(--accent)}
.brandlink{display:flex;align-items:center;gap:9px;text-decoration:none;color:inherit}
.logo{width:30px;height:30px;display:block;flex:none}
/* Replaces the width/height ATTRIBUTES the old <img> pair carried on the login
   page -- the inline <svg> has none, so sizing is this stylesheet's job now. */
.loginbox .logo{width:48px;height:48px}
.env{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-soft);padding:2px 8px;border-radius:20px}
.toolbar{display:flex;gap:6px;align-items:center;padding:10px 18px;flex-wrap:wrap}
.tab{border:1px solid var(--line);background:var(--panel);color:var(--muted);padding:6px 12px;font-size:12.5px;border-radius:8px;text-decoration:none;cursor:pointer}
.tab.on{color:var(--ink);border-color:var(--accent);background:var(--accent-soft)}
.ghost{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:8px;padding:6px 10px;font-size:12.5px;cursor:pointer}
main{padding:14px 18px}
.lptoolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:10px}
.lpchip{font-size:12px;border:1px solid var(--line);border-radius:20px;padding:3px 9px;background:var(--panel)}
.masterck{width:15px;height:15px;cursor:pointer;accent-color:var(--accent)}
.casetoolbar{display:flex;align-items:center;margin-bottom:8px}
.allctrl{display:flex;align-items:center;gap:6px;cursor:pointer}
.muted{color:var(--muted);font-size:12.5px}
.row{background:var(--panel);border:1px solid var(--line);border-radius:12px;margin-bottom:10px}
.row.active{border-color:var(--accent)}
/* Grid (not flex) so the response column starts at the same x-position on
   every row -- with flex, a row with long LP names/pill text grew its pill
   block wider and shoved the ⇄ button, misaligning every row against its
   neighbors. The fixed 280px `.resp` track fixes that; pills stack inside it
   instead of pushing anything else. */
/* The .cid track is narrower than the 90-140px it started at only in the sense
   that it no longer has to fit name + chip + ✉ on ONE line: the chip stacks
   under the case number now, so the track only has to fit the wider of
   "BON01 ✉" and "RFQ 113643". Tighter track + a 10px (was 14px) gap is what
   pulls the tags left. Still a FIXED track rather than max-content: sizing it
   per row would start the tags at a different x on every row, the same
   misalignment the .resp track exists to prevent. */
.rowmain{display:grid;grid-template-columns:auto minmax(108px,124px) 1fr 280px auto;align-items:start;gap:10px;padding:12px 14px}
.cid{display:flex;flex-direction:column;align-items:flex-start;gap:4px}
/* flex-wrap:NOWRAP is the point: the ✉ must hold the same x on every row, and
   it cannot if it can be pushed onto a second line by a neighbour. Also fixes
   it sitting low -- it was an inline-block leaning on vertical-align inside a
   plain block. */
.cid .id{display:flex;align-items:center;gap:8px;flex-wrap:nowrap;font-weight:650}
.cid .solves{font-size:12px;color:var(--muted)}
/* The case name doubles as the tags expander, but ONLY on a row whose tags
   actually overflow -- .has-more is set by app.js after measuring. Without it
   the name would advertise an action that does nothing on most rows. */
.cid .casename{cursor:default}
.row.has-more .cid .casename{cursor:pointer;text-decoration:underline;
  text-decoration-style:dotted;text-underline-offset:3px;text-decoration-color:var(--muted)}
.row.has-more .cid .casename:hover{text-decoration-color:var(--accent);color:var(--accent)}
/* B3.5: the RFQ <nmid> chip -- ONE per case, next to the case id, more
   prominent than the per-LP rfqIDs inside the pills. Empty (case not fired
   yet) must render nothing at all: :empty only matches when the template has
   ZERO whitespace inside the span (see _list.html/_resp_cells.html), so keep
   that markup on one line if it's ever touched. */
.nmidchip{font-size:11.5px;font-weight:700;color:var(--accent);background:var(--accent-soft);border:1px solid var(--accent);border-radius:20px;padding:2px 9px;white-space:nowrap}
.nmidchip:empty{display:none;padding:0;margin:0;border:0}
/* The Quote Mail envelope (_quotemail.html) -- mounted right after .nmidchip
   inside .cid, same precedent: a small icon affordance beside the case id,
   not a primary action, so it stays QUIET -- muted at rest, no new colours,
   just the tokens already in use elsewhere on the page. UNLIKE .nmidchip,
   there is no `.qmail:empty` collapse rule here: .nmidchip's :empty content
   is genuinely conditional (blank span when the case hasn't fired), but
   every branch of _quotemail.html always renders a child (button or span),
   so :empty could never match -- the two controls only share the "stay
   quiet" intent, not this particular mechanism. A success (.qm-done) is a
   permanently inert span, not a button -- the API's dedupe never lets a
   second click do anything -- so it gets a transparent border of the same
   width as .qm-btn purely to avoid a layout nudge when the control swaps
   from button to span. .qm-err borrows --bad, the same colour already used
   for a reject/error state elsewhere (.pill.reject, .exp.mismatch), so a
   failed send reads as an error without a new red. */
.qmail{display:inline-flex;align-items:center}
.qm-btn{border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:20px;padding:1px 7px;font-size:12px;line-height:1.4;cursor:pointer;vertical-align:middle}
.qm-btn:hover:not([disabled]):not(.qm-err){color:var(--accent);border-color:var(--accent)}
.qm-btn.qm-err{color:var(--bad);border-color:var(--bad)}
.qm-done{display:inline-block;border:1px solid transparent;color:var(--muted);border-radius:20px;padding:1px 7px;font-size:12px;line-height:1.4;vertical-align:middle}
/* The tag strip takes its height FROM the row and never gives height back to
   it. A multi-issuer fire stacks several pills in .resp and makes the row tall,
   and the tags used to stay ONE clipped line in the middle of all that empty
   space -- worst on the first rows, which are the tallest. So .chips wraps now,
   but as an ABSOLUTE child of a stretched cell: out of flow it contributes
   nothing to the auto row height (a wrapping IN-flow strip would grow the row
   itself, which is exactly what we do not want -- see .chips.expanded for the
   opt-in version of that), while inset:0 makes it fill whatever height the
   pills beside it happened to impose. Taller row => more tag lines, for free.
   align-self:stretch is load-bearing: without it the cell is height:auto, which
   with only an out-of-flow child is 0, and the tags vanish entirely.
   min-height is the floor for an UNFIRED row, which has no pills and whose
   height would otherwise be set by .cid/.rowbtns alone -- a couple of px short
   of a chip line is enough to slice every tag on the default view of the page.
   One chip line = 11.5px * 1.45 + 3px padding top and bottom ~= 23px. Do not
   delete it as dead weight; nothing else guarantees a whole first line. */
.chipcell{position:relative;min-width:120px;align-self:stretch;min-height:23px}
.chips{position:absolute;inset:0;display:flex;flex-wrap:wrap;overflow:hidden;gap:6px;align-content:flex-start}
/* Clicking the case name is still the "show me ALL of them" escape hatch, and
   there the strip IS allowed to push the row taller: back in flow, no clip. */
.chips.expanded{position:static;overflow:visible}
.chip{font-size:11.5px;background:var(--accent-soft);color:var(--ink);border-radius:7px;padding:3px 8px;white-space:nowrap}
/* B3.4: pills stack full-width in a column so their 3 columns
   [ LP short-name | outcome | rfqID ] line up across stacked pills (and, since
   every row's .resp is the same fixed track, across rows too). */
.resp{display:flex;flex-direction:column;gap:5px}
/* Each pill is a mini fixed-column grid: a fixed LP column (short names -- widest
   is "Leonteq T" @ 9 chars, 68px) + a FIXED outcome column (120px -- B4.2:
   sized to fit "Priced @ 999.99" (the widest priced label -- see
   rfq_harness/results.py::response_outcome) and "Unsupported" in full, with
   margin; a reject remark still clips with an ellipsis WITHIN this column) +
   an auto rfqID column that now hugs the outcome column immediately instead
   of being pushed to the far right by a 1fr outcome. tabular-nums keeps the
   price/rfqID digits aligned. width:max-content so the coloured pill itself
   ends right after the rfqID instead of stretching across the whole 280px
   .resp track (max-width:100% is a safety clamp).

   The outcome column is 118px because .oc is `flex:none` and does NOT clip: at
   the 90px it was tightened to, "Priced @ 999.99" simply overflowed its track
   and collided with the rfqID (seen on BST01). Anything that narrows this again
   has to make .oc shrinkable first, or the same overlap comes straight back. */
.pill{display:grid;grid-template-columns:68px 118px auto;column-gap:10px;align-items:baseline;font-size:11.5px;border-radius:9px;padding:3px 9px;border:1px solid var(--line);font-variant-numeric:tabular-nums;width:max-content;max-width:100%}
.pill.priced{background:#e6f6ec;color:#1a7f45;border-color:#57b97e}
.pill.reject{background:#fdeceb;color:#b42318;border-color:#e08a82}
.pill.wait{background:#fbe6c8;color:#8a5a00;border-color:#e6b23a}
.pill.unsup{background:#ece6db;color:#7c7264}
.pill .lp{font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ONE name on screen at a time. .lp carries both the alias (.lpshort, visible
   while closed) and the full name (.lpfull, visible while open) so expanding
   the pill EXPANDS THE NAME IN PLACE -- no second name, no extra line. Which
   string .lpfull holds is decided by lp_names.full_lp, not by the ppID: two
   aliases are more readable than the id they replace. */
.pill .lpfull{display:none}
.pill.open .lpshort{display:none}
.pill.open .lpfull{display:inline}
/* outcome column: the base label .oc (never clips) + the reject/error remark
   .rmk (clips with an ellipsis; full text on hover via title and on click via .open). */
.pill .out{display:flex;min-width:0;gap:4px;align-items:baseline}
/* The selectable MESSAGE: outcome + remark, excluding the ✓/✗ expectation
   glyph. A wrapper element rather than user-select on .out, because a
   descendant of a `user-select:all` box cannot reliably opt back out of it --
   putting it on .out would sweep the glyph into every copied rejection. */
.pill .msg{display:flex;min-width:0;gap:4px;align-items:baseline;user-select:none;-webkit-user-select:none}
/* Selectable only ONCE OPEN. The pill toggle bails whenever a selection
   exists, so a permanently-selectable message swallowed the very first
   click and the pill could never be expanded by clicking the thing you
   wanted to read. Closed -> the click expands; open -> the click takes the
   whole message. `none` rather than the default `text` when closed, so a
   stray drag cannot create a selection that blocks the toggle either. */
.pill.open .msg{user-select:all;-webkit-user-select:all}
.pill .oc{flex:none;white-space:nowrap}
/* a priced quote whose validity ran out (render.pill_for): quiet, not a failure.
   Shown only on an EXPANDED pill -- collapsed, the outcome track is too narrow and
   the tag ran over the rfqID column (user, 2026-09-24). */
.pill .expd{display:none;flex:none;margin-left:6px;padding:0 5px;font-size:10.5px;line-height:1.5;border:1px dashed currentColor;border-radius:8px;opacity:.7;white-space:nowrap}
.pill.open .expd{display:inline-block}
/* Does this outcome MATCH the matrix? Two states and nothing else, leading the
   outcome column. Read-only -- a span, never a control; marking lives on the
   Connectivity Matrix.
   Its own colours, NOT currentColor: the pill is already coloured by status, so
   inheriting made the answer invisible inside a green priced pill, which is what
   the faint first version got wrong. A filled disc reads at a glance while
   scanning a run; the mismatch is the one you must not be able to miss. */
.pill .exp{flex:none;display:inline-flex;align-items:center;justify-content:center;
  width:15px;height:15px;border-radius:50%;font-size:11px;font-weight:800;
  line-height:1;cursor:help;align-self:center}
.pill .exp.match{background:#1a7f45;color:#fff}
.pill .exp.mismatch{background:#e8a317;color:#3a2600}
/* The two greens/ambers above are fixed, not themed: they sit ON the pill's own
   light status background in both themes, so a dark-mode variant would put dark
   on dark. */
.pill .rmk{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;opacity:.85}
.pill .rmk::before{content:":\00a0"}
.pill.has-detail{cursor:pointer}
/* click-to-expand (toggled in app.js): unclamp the remark so it wraps in full.
   B4.2: the outcome column is now a narrow FIXED 120px track -- too tight to
   read a wrapped remark in -- so on open, .out spans the pill's FULL width on
   its own row (grid-column:1/-1, same technique as .extid below), same as a
   revealed .extid. .lp/.rfqid are pinned to explicit grid-column+grid-row (not
   left to auto-placement) so they stay put on the header row in their normal
   left-right order -- an item locked to only ONE axis (e.g. rfqid via
   grid-row alone) is placed by the grid BEFORE fully-auto items like .lp,
   which would swap their positions. width:100% overrides the collapsed
   state's width:max-content: a wrap-enabled (white-space:normal) spanning
   item inside a max-content-sized container sizes to its UNWRAPPED text width
   (defeats the wrap), so open needs a definite width to force real wrapping.
   That width is CLAMPED to 340px (min() against the track): on the History page
   .resp sits in a `1fr` track (see .histcase), so a bare width:100% blew the
   opened pill across the whole row -- the user wants it to grow DOWNWARD, not
   rightward. 340px is close to the List page's 280px .resp track (open pills
   look alike on both pages) and leaves ~148px for the rfqID column after the
   68+90 fixed tracks, gaps and padding -- comfortably more than a 7-digit id
   needs, so nothing escapes the pill. A long ext id wraps to a second line
   inside it rather than widening it; that's the intended direction of growth.
   Promoting .out onto its own full-width row started as a has-remark-only rule
   (the 120px outcome track is too tight to wrap a remark in) and is now
   UNIVERSAL, because the full issuer name took the LP+outcome tracks on row 1
   and left the outcome nowhere else to sit. So every open pill is
   [ full name | rfqID ] / [ outcome ] / [ details ], with only .msg's wrapping
   still gated on has-remark.
   NB: during a live run a still-polling sibling case's ~2s OOB refresh re-renders this
   #resp-{case} and resets .open; the full remark / external id stays available on hover. */
/* Opening grows the pill strictly DOWNWARD. The pill is `width:max-content`, so
   ANY full-width row it gains on open (the reflowed remark, the external id)
   would otherwise become its widest row and drag it out to the right. Both such
   rows therefore contribute nothing to intrinsic width (width:0) and then fill
   the row the closed pill's own tracks sized (min-width:100%). The text wraps
   inside that width instead of stretching it -- which is the whole point. */
.pill.open{row-gap:4px}
/* The full name needs more than the 68px LP track, and the pill must not grow
   SIDEWAYS to give it room (.resp is a fixed 280px track on the List page --
   widening column 1 would push the rfqID out through the pill's own edge). So
   on open the name spans the LP + outcome tracks (196px, one line for every
   issuer name in the roster) and the outcome drops to its own full-width row
   below -- which is what a pill with a remark has always done on open; this
   just makes it universal, so every opened pill has the same shape. .lp stops
   clipping here too: that ellipsis is the OTHER way a name goes missing, and
   it hits unmapped names ("Swissquote") that the alias map never touches. */
.pill.open .lp{grid-column:1/3;grid-row:1;white-space:normal;overflow:visible;text-overflow:clip}
.pill.open .rfqid{grid-column:3;grid-row:1}
/* ── Indicative termsheet control ────────────────────────────────────────────
   Hidden until the pill is expanded, then column 3 of ROW 2 -- directly under
   the rfqID and level with the price, which is where it was asked for. The
   outcome normally spans the full width on open (it needs that room to wrap a
   remark), so it gives column 3 back on a pill that has a control. That is
   safe rather than lucky: only a PRICED leg gets one, and render.pill_for only
   fills `remark` for rejected/expired rows, so a pill with a control never has
   a remark to wrap.
   The slot itself is the hx-preserve anchor (see _pills.html) -- it must keep
   its identity across the ~2s OOB re-render of #resp-{case}, or an armed
   control is detached mid-poll and stops silently. Nothing here may be applied
   to the inner button, which swaps freely. */
.pill .tsslot{display:none}
/* Its OWN full-width row, right-aligned -- NOT column 3 beside the rfqID.
   Column 3 carries the rfqID, and the pill is width:max-content inside a fixed
   280px .resp track, so a control wider than a 7-digit id grew that column past
   the pill's own clamp and pushed BOTH itself and the rfqID out through the
   right-hand border (observed 2026-09-17). width:0 + min-width:100% is the same
   idiom the reflowed remark and the ext-id row use: contribute nothing to
   intrinsic width, then fill the width the closed pill's tracks already sized.
   That makes overflow impossible whatever the control's label says, which
   matters because its label changes with its state. */
.pill.open .tsslot{display:flex;justify-content:flex-end;grid-column:1/-1;
  width:0;min-width:100%}
.tsb{display:inline-flex;align-items:center;gap:4px;font:inherit;font-size:10.5px;
  line-height:1;padding:3px 7px;border-radius:7px;border:1px solid var(--line);
  background:var(--panel);color:var(--fg);cursor:pointer;white-space:nowrap;
  text-decoration:none}
.tsb .ic{font-size:11px}
.tsb:hover:not([disabled]){border-color:#8a8a8a}
/* Disabled is a FINDING, not chrome: this LP has no Document_Setup row, and it
   lands on well-priced quotes. Dashed rather than merely faded, so it reads as
   "cannot", not "loading". */
.tsb[disabled]{cursor:not-allowed;opacity:.55;border-style:dashed}
.tsb-wait{background:#fbe6c8;color:#8a5a00;border-color:#e6b23a;cursor:default}
.tsb-done{background:#e6f6ec;color:#1a7f45;border-color:#57b97e;font-weight:650}
.tsb-err{background:#fdeceb;color:#b42318;border-color:#e08a82}
/* gaveup / error: the look-first button and "Send again" swap as ONE node. */
.tsgrp{display:inline-flex;align-items:center;gap:4px}
.pill.open .out{grid-column:1/-1;grid-row:2;
  width:0;min-width:100%;box-sizing:border-box}
.pill.open.has-remark .msg{flex-wrap:wrap;flex:1 1 100%;min-width:0}
.pill.open .rmk{flex:1 1 100%;white-space:normal;overflow:visible;text-overflow:clip}
.pill .rfqid{justify-self:end;white-space:nowrap;opacity:.75}
/* B3.5: the per-LP external id (LONG) -- a 4th grid item, HIDDEN by default,
   spanning the pill's full width on its own row, revealed only on .open
   (same click as the remark). Muted/monospace so it reads as a secondary
   detail, not the primary price/outcome. */
.pill .extid{display:none;grid-column:1/-1;margin-top:3px;padding-top:3px;border-top:1px dashed var(--line);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--muted);word-break:break-all;opacity:.85}
.pill .perr{display:none;grid-column:1/-1;margin-top:3px;padding-top:3px;border-top:1px dashed var(--line);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:10.5px;color:var(--muted);word-break:break-all;opacity:.85}
/* width:0 + min-width:100% is what stops the pill growing SIDEWAYS when it
   opens. The pill is `width:max-content`, so a long external id on its own
   full-width row became the widest row and dragged the whole pill out to the
   right -- the id line has to contribute NOTHING to intrinsic width (width:0)
   and then fill the row the other tracks sized (min-width:100%). box-sizing so
   the padding stays inside that 100%. */
.pill.open .extid{display:block;width:0;min-width:100%;box-sizing:border-box}
.pill.open .perr{display:block;width:0;min-width:100%;box-sizing:border-box}
/* Double-click (or a single click, with user-select:all) grabs the WHOLE id
   rather than one dash-separated fragment of it. The pill's delegated click
   handler already bails when a selection exists, so selecting an id does not
   also collapse the pill. Same treatment for all three ids on the page. */
.pill .extid,.pill .perr,.pill .rfqid,.nmidchip .val{user-select:all;-webkit-user-select:all}
/* The "RFQ" label is explicitly NOT selectable, so a double click on the chip
   yields the bare number -- which is what gets pasted into a lookup. The gap
   replaces the literal space that used to sit between them in the markup;
   a text node there would land inside the selection. */
.nmidchip{display:inline-flex;align-items:baseline;gap:4px}
.nmidchip .lbl{user-select:none;-webkit-user-select:none}
.cmpbtn{border:1px solid var(--line);background:var(--panel);color:var(--ink);width:34px;height:34px;border-radius:9px;cursor:pointer;font-size:16px}
.cmpbtn:hover:not([disabled]){border-color:var(--accent);color:var(--accent)}
.cmpbtn[disabled]{opacity:.4;cursor:not-allowed}
.rowbtns{display:flex;gap:6px;align-items:start}
.expbtn{border:1px solid var(--line);background:var(--panel);color:var(--ink);width:34px;height:34px;border-radius:9px;cursor:pointer;font-size:14px}
.expbtn:hover{border-color:var(--accent);color:var(--accent)}
.expbtn.on{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.fields{border-top:1px dashed var(--line);padding:14px}
.fields-head{margin-bottom:10px}
.fields-head .t{font-weight:650}
/* A submit failure's fields table still renders (the request WAS built and
   sent) -- this banner is what marks the case as failed anyway. Follows
   .battery-error's pattern (bg+color pair), NOT .warn's color-only rule --
   #b42318 alone is ~2.9:1 on the dark theme's --panel, below AA; paired with
   its own light background (as .pill.reject/.battery-error/.cancelbadge all
   already do) it stays readable in both themes. */
.fields .err{background:#fdeceb;color:#b42318;border:1px solid #e08a82;border-radius:8px;padding:8px 12px;margin:0 0 10px;font-size:12.5px}
.fieldgroups{display:flex;flex-wrap:wrap;gap:14px}
/* min-width raised from 220 with the labels below: a group narrower than its
   longest label forces a wrap, and 220 was not enough for the Participation
   sections ("Downside Barrier Level (%)"). Shortening the labels instead was
   rejected -- dropping the Upside/Downside prefix reads fine UNDER a section
   heading but collides in the flat views that have no headings (the Grid's
   column titles and share.py's CSV header would carry two "Strike (%)" and two
   "Barrier Type" columns). One label per field, sized to fit. */
.fieldgroup{flex:1;min-width:296px;border-collapse:collapse}
/* The label column sizes to its content and never wraps, so every field is
   exactly one row; the value column takes what's left. Replaces the fixed
   width:46% from .kv td.k, which is what split the long labels over two lines. */
.kv.fieldgroup td.k{width:1%;white-space:nowrap;padding-right:16px}
.fieldgroup th{text-align:left;color:var(--muted);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px;padding:0 8px 7px}
.results-actions{display:flex;gap:8px;margin:10px 0}
/* B3.6: top action bar (Price / Clear) directly under the product tabs --
   consolidates what used to be a header control + a bottom firebar + a Clear
   form buried in .results-actions. B4.1 dropped the redundant "Price all"
   (Price with all cases checked, the default, already fires all) and added
   #run-status (below) as a live run indicator next to Price. */
.actionbar{display:flex;gap:10px;align-items:center;padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid var(--line)}
.primary{background:var(--accent);color:#fff;border:0;border-radius:8px;padding:7px 13px;font-size:12.5px;font-weight:600;cursor:pointer}
/* B4.1: prominent live run-status mounted in the TOP .actionbar next to
   Price -- an OOB-swapped span (see _progress.html/_results.html) that the
   fire/progress/stop routes' existing status_line fills in, so the run state
   ("Submitting X/Y...", "Pricing... Ns", "Stopping...") is impossible to miss
   the way the bottom .runbar was (users kept double-clicking Price). Empty
   (idle/done) must render nothing: :empty only matches when the template has
   ZERO whitespace inside the span (see the markup + .nmidchip's same rule). */
.runstatus{font-weight:700;color:var(--accent);font-size:12.5px}
.runstatus:empty{display:none}
/* Stop moved out of the bottom .runbar into the action bar, immediately left of
   Clear (B: easier to reach than hunting for it mid-page). Present only while a
   run is live: the fire/progress/stop routes OOB-fill this slot and
   _results.html empties it, and :empty then hides it -- which requires ZERO
   whitespace inside the span in every template that renders it. */
.stopslot:empty{display:none}
/* Draft B (chosen from mockups): full-width band for the PROD confirm card
   and the empty-selection error, directly below the action bar and above
   the upload row -- always in view, no scrolling needed. Same slot pattern
   as .stopslot above: the fire/confirm/error fragments OOB-fill it and
   _progress.html/_results.html OOB-empty it again, and :empty then hides it
   -- which likewise requires ZERO whitespace inside the div in every
   template that renders it. */
.firenotice:empty{display:none}
.runstatus.busy{display:inline-flex;align-items:center;gap:6px;background:var(--accent-soft);border:1px solid var(--accent);border-radius:20px;padding:4px 11px 4px 9px}
.runstatus.busy::before{content:"";width:7px;height:7px;border-radius:50%;background:var(--accent);animation:runstatus-pulse 1.1s ease-in-out infinite}
@keyframes runstatus-pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.7)}}
.compare{border-top:1px dashed var(--line);padding:14px}
.cmp-head{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.cmp-head .t{font-weight:650}.cmp-head .close{margin-left:auto;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 9px;cursor:pointer;font-size:14px;line-height:1}
.cmp-head .close:hover{border-color:var(--accent);color:var(--accent)}
.cmp-head .lplabel{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px;font-weight:600}
/* .lptabs is the issuer selector -- a segmented group of short-name pills (full
   name on hover via title=). display:flex+wrap (not just relying on the
   parent .cmp-head's wrap) so up to ~18 tabs stay a tidy, evenly-gapped block
   instead of ragged inline text-flow wrapping. */
.lptabs{display:flex;flex-wrap:wrap;gap:6px}
.lptabs .lpt,.subtabs button{border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:7px;padding:4px 11px;font-size:12px;cursor:pointer}
.lptabs .lpt:hover:not(.on),.subtabs button:hover:not(.on){border-color:var(--accent);color:var(--ink)}
.lptabs .lpt.on,.subtabs button.on{border-color:var(--accent);color:var(--ink);background:var(--accent-soft);font-weight:650}
/* "Fetch all" sits beside the issuer tabs and warms every tab's message in the
   background. Dashed border so it reads as an ACTION on the tab strip rather
   than another tab you could select -- picking it must never look like picking
   an issuer, which is the confusion the old bulk button created. Its ticks are
   rendered INSIDE each .lpt, so a tab keeps one box and simply gains a glyph. */
.cmp-head .fetchall{border:1px dashed var(--line);background:transparent;color:var(--muted);
  border-radius:7px;padding:4px 11px;font-size:12px;cursor:pointer;white-space:nowrap}
.cmp-head .fetchall:hover:not(:disabled){border-color:var(--accent);color:var(--ink)}
.cmp-head .fetchall:disabled{cursor:progress;opacity:.7;border-style:solid}
.sbs{display:flex;gap:14px;overflow-x:auto}.col{flex:1;min-width:250px}
/* "We sent" (always the first .col in a .sbs) gets more width than "received"
   so its two-column .kv2 has room (~230px/col, labels don't wrap). */
.sbs .col:first-child{flex:1.6}
.col h4{margin:0 0 7px;font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.4px}
.kv{width:100%;border-collapse:collapse;font-size:12.5px}
.kv td{padding:5px 8px;border-bottom:1px solid var(--line)}.kv td.k{color:var(--muted);width:46%}.kv td.v{text-align:right;font-variant-numeric:tabular-nums}
.kv.align th{text-align:left;color:var(--muted);font-weight:600;font-size:11px;text-transform:uppercase;padding:5px 8px;border-bottom:1px solid var(--line)}
.kv tr.match td{background:rgba(87,185,126,.12)}
.kv tr.mismatch td{background:rgba(224,101,90,.18)}
.kv td.mk{font-size:11px}.ok{color:#1a7f45}.warn{color:#b42318}
/* B4.5: "We sent" (LEFT, side-by-side) flows into 2 columns so the ~30-row
   panel is ~half as tall. Tables don't column-break, so this is divs + CSS
   multi-column instead of another <table class="kv">; each row mirrors the
   .kv look (muted key, tabular-num value) via flex instead of table cells. */
.kv2{column-count:2;column-gap:18px}
.kv2row{display:flex;justify-content:space-between;gap:10px;padding:5px 8px;border-bottom:1px solid var(--line);font-size:12.5px;break-inside:avoid;-webkit-column-break-inside:avoid}
.kv2row .k{color:var(--muted)}
.kv2row .v{text-align:right;font-variant-numeric:tabular-nums}
/* ⇄ FinIQ inputs: a field the case tests -- same treatment as .kv.fieldgroup tr.hi */
.kv2row.hi{background:var(--accent-soft)}
.kv2row.hi .v{font-weight:700}
/* B5.1: sbs header bands (labeled, no arrow) + curated grouped "We sent".
   "ours" band is a neutral --ours tint with muted text; "theirs" band is solid
   accent with white text; the received CONTENT sits on a faint --theirs tint. */
.cmp-band{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;padding:6px 10px;border-radius:7px 7px 0 0;margin:0 0 8px}
.cmp-band.ours{background:var(--ours);color:var(--muted)}
.cmp-band.theirs{background:var(--accent);color:#fff}
.theirs-tint{background:var(--theirs);border-radius:0 0 7px 7px;padding:8px 10px}
/* each curated section is a break-inside:avoid block inside the .kv2 two-column
   flow, led by a small accent sub-header (mirrors the ▸ row-expand groups). */
.sentgrp{break-inside:avoid;-webkit-column-break-inside:avoid;margin-bottom:8px}
.sentgrp-h{font-size:11px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.4px;padding:2px 8px 3px}
.legend{font-size:11px;color:var(--muted);margin:0 0 8px}.legend .ok,.legend .warn{font-weight:600}.legend .blank{color:var(--ink)}
.rawcap{font-size:11.5px;color:var(--muted)}
/* Copy-to-clipboard on a raw API (JSON) message. The .copywrap container is
   what copy.js resolves [data-copy-src] against (see copy.js on why not ids);
   it must not disturb layout, so it only positions the button. On the accent
   `.cmp-band.theirs` header the button rides at the right end, inheriting the
   band's white ink over a translucent chip. */
/* Envelope Header strip: routing context above the payload, deliberately NOT a
   third vertical key/value table -- it wraps as a dense inline run so it costs
   one or two lines, not sixteen. URLs are long and unbreakable at spaces, so
   the value gets break-all rather than overflowing the panel. */
.msghdr{display:flex;flex-wrap:wrap;gap:1px 14px;font-size:11px;line-height:1.65;margin:0 0 9px;padding-bottom:8px;border-bottom:1px dashed var(--line)}
.msghdr .hpair{max-width:100%}
.msghdr .hk{color:var(--muted);margin-right:5px}
.msghdr .hv{font-family:ui-monospace,Consolas,monospace;word-break:break-all}
/* Provenance chip: only rendered when a message came off the local PROD
   replica rather than the live server (rfq_harness/archive_mirror.py).
   Tinted with --accent-soft so it reads as a statement about the message
   rather than one more header field, and themed via the tokens so it
   works in both schemes. */
.msghdr .hsrc{background:var(--accent-soft);border-radius:20px;padding:1px 9px}
.msghdr .hsrc .hk{color:var(--accent)}
.msghdr .hsrc .hv{font-family:inherit;color:var(--accent)}
.cmp-band + .theirs-tint .msghdr{border-bottom-color:var(--line)}
.copywrap{position:relative}
.copywrap [hidden]{display:none}          /* the History copy source is an invisible <pre> */
.copybtn{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:3px 9px;font-size:11.5px;font-weight:600;cursor:pointer;white-space:nowrap;margin-left:8px;vertical-align:middle}
.copybtn:hover{border-color:var(--accent);color:var(--accent)}
.copybar{display:flex;justify-content:flex-end;margin-bottom:6px}
.copybar .copybtn{margin-left:0}
.cmp-band .copybtn{float:right;margin-top:-2px;border-color:rgba(255,255,255,.5);background:rgba(255,255,255,.16);color:#fff;text-transform:none;letter-spacing:0}
.cmp-band .copybtn:hover{background:rgba(255,255,255,.3);color:#fff}
.raw{background:#0f1626;color:#d7e0f0;border-radius:10px;padding:14px;font:12px/1.55 ui-monospace,Menlo,Consolas,monospace;overflow-x:auto;white-space:pre}
.gridbar{display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap}
/* Grid "Edit highlights" mode: a dashed edge marks a cell whose state came from
   a manual highlight/no_highlight entry rather than the first-occurrence rule. */
.hl-clickable{cursor:pointer}
.hl-override{box-shadow:inset 0 0 0 2px var(--accent);outline:1px dashed var(--accent);outline-offset:-3px}
#hlmode.on{background:var(--accent-soft);color:var(--accent);font-weight:600}
#hlhint.hl-error{color:#b42318}
#grid{background:var(--panel);border:1px solid var(--line);border-radius:10px}
.batterybar{display:flex;gap:10px;align-items:center;margin-bottom:10px;flex-wrap:wrap}
.uploadform{display:flex;gap:6px;align-items:center}
.batteryform{display:flex;gap:8px;align-items:center}
/* Battery selector: styled EXACTLY like the Entity / Env selectors beside it
   (.entityinline / .envinline) -- same native control, padding, radius, font
   and uppercase muted label -- so the three pickers on the List bar read as
   one family (user, 2026-09-28). */
.batteryform label{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
.batteryform select{padding:3px 8px;border:1px solid var(--line);border-radius:5px;
  background:var(--panel);color:var(--ink);font-size:13px;cursor:pointer;max-width:260px}
.uploadform input[type="file"]{font-size:12px;max-width:200px}
.badge{font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-soft);padding:3px 9px;border-radius:20px}
.battery-error{background:#fdeceb;color:#b42318;border:1px solid #e08a82;border-radius:8px;padding:8px 12px;margin-bottom:10px;font-size:12.5px}
.inline-form{display:contents}
/* htmx's recommended indicator CSS -- htmx itself only toggles the
   .htmx-request class; it ships no default styling for it. Hidden with
   display:none at rest (not opacity:0) so "Submitting…" reserves NO width and
   leaves no permanent gap; shown inline only while the request is in flight. */
.htmx-indicator{display:none}
.htmx-request.htmx-indicator,.htmx-request .htmx-indicator{display:inline}
button[disabled]{opacity:.55;cursor:not-allowed}
/* --- Top-level left-rail nav (destinations) --- added 2026-07-16.
   Sits below the full-width header, left of each page's sub-nav + content.
   Additive only; the harness product tabs (.tab) stay inside .shell-main.
   Destinations: Harness / Connectivity Matrix (soon) / Onboarding. */
/* flex:1 -- the rail must reach the bottom of the window even when a page's
   content is short. .shell is only as tall as its tallest child, so on a short
   page (the RFQ Lookup landing state is ~240px in a ~1000px viewport) the rail
   stopped mid-screen with bare body background below it. body is a flex column
   (see above), so .shell claims the leftover height -- no hard-coded header
   height to drift. */
.shell{display:flex;align-items:stretch;flex:1}
.sidenav{flex:0 0 172px;border-right:1px solid var(--line);padding:14px 10px;background:var(--rail)}
/* The rail's links FOLLOW THE SCROLL. .sidenav itself still stretches to the
   full page height (that's the rail background); .navsticky is the flex column
   that was .sidenav's own layout and now sticks to the top of the viewport, so
   the destinations never scroll out of reach on a long page. max-height + its
   own overflow keeps it usable if the nav ever outgrows the viewport. */
.navsticky{position:sticky;top:calc(var(--hdr) + 12px);display:flex;flex-direction:column;gap:3px;max-height:calc(100vh - var(--hdr) - 24px);overflow-y:auto}
.sidenav .dest{display:block;padding:8px 12px;border-radius:8px;border:1px solid transparent;color:var(--muted);text-decoration:none;font-size:13px;font-weight:600}
.sidenav .dest:hover{color:var(--ink);background:var(--accent-soft)}
.sidenav .dest.on{color:var(--ink);background:var(--accent-soft);border-color:var(--accent)}
.shell-main{flex:1;min-width:0}
/* collapsible left rail: the ☰ toggle in the header flips html.nav-collapsed
   (persisted in localStorage, restored before paint -> no flash). */
html.nav-collapsed .sidenav{display:none}
.navtoggle{font-size:15px;line-height:1;padding:6px 10px}
/* brand moved into the rail (2026-07-16): logo + "Kozak" wordmark at the top of
   the sidebar; the header <h1> now shows the current section name. */
.sidenav .brandlink{padding:2px 6px 13px;margin-bottom:7px;border-bottom:1px solid var(--line)}
.sidenav .brandname{font-size:16px;font-weight:750;letter-spacing:.2px}

/* ---- History page ---- */
.histfilter{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
.histfilter select{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:8px;padding:5px 9px;font-size:12.5px}
.histfilter input{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:8px;padding:5px 9px;font-size:12.5px;width:15ch}
/* The across-runs total. Sits between the filter bar and the first card and
   reads as a summary of the bar above it, not as another run. */
/* The "not here, but there" line. Deliberately not .muted: it is the only
   thing on an empty page and the whole point is that it gets read. */
.histhint{margin:10px 0;padding:9px 11px;border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;background:var(--panel);font-size:clamp(12px,.85rem,13.5px);line-height:1.5}
/* What the searched id turned out to be, on the card it was found on. */
.hitchip{display:inline-block;padding:1px 6px;border:1px solid var(--line);border-radius:999px;background:var(--panel);font-size:clamp(10.5px,.72rem,12px)}
.histtotals{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:-4px 0 12px;padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}
.histrun .histrow{grid-template-columns:minmax(160px,240px) 1fr auto}
.summ{display:flex;flex-wrap:wrap;gap:6px;align-content:flex-start}
.scount{font-size:11.5px;border-radius:7px;padding:3px 8px;white-space:nowrap;border:1px solid var(--line);background:var(--panel);color:var(--muted)}
.scount.priced{background:#e6f6ec;color:#1a7f45;border-color:#57b97e}
.scount.rejected,.scount.failed{background:#fdeceb;color:#b42318;border-color:#e08a82}
.scount.unsupported{background:#eceff3;color:#6b7280}
.scount.timeout{background:#fff4d6;color:#8a5a00;border-color:#e6b23a}
.cancelbadge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:#b42318;background:#fdeceb;border:1px solid #e08a82;border-radius:20px;padding:1px 7px;vertical-align:middle}
.histdetail{border-top:1px dashed var(--line);padding:12px 14px}
.histcase{display:grid;grid-template-columns:minmax(90px,150px) 1fr auto;gap:14px;align-items:start;padding:8px 0}
.histcase+.histcase{border-top:1px solid var(--line)}
/* below the cases now, mirroring the harness's own bottom results toolbar */
.histexports{display:flex;gap:8px;margin-top:14px;padding-top:12px;border-top:1px solid var(--line);flex-wrap:wrap}
/* both row-expands (▸ fields, ⇄ compare) span the whole case row */
.histcmp-mount,.histfields-mount{grid-column:1/-1}
.notarch{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px dashed var(--line);border-radius:8px;background:var(--panel);margin-top:6px}
.notarch .msg{font-size:12px;color:var(--muted)}
.fetchbtn{border:1px solid var(--accent);background:var(--accent-soft);color:var(--accent);border-radius:8px;padding:5px 11px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap}

/* ---- Compare response picker (⇄ Side-by-side per-column message picker) ---- */
/* Side-by-side vs Raw is a pure DISPLAY toggle over content that is already in
   the DOM: every message block (fetched fragment AND the "FinIQ inputs" block)
   ships both a .m-sbs (parsed/grouped) and a .m-raw (lossless payload), and the
   panel's view- class picks one. That's what makes the Raw view the same
   layout, same pickers, same columns as side-by-side -- and it means switching
   views never re-fetches. Driven by :class="'view-'+view" on .compare, so the
   class is always exactly one of these two. */
.compare.view-sbs .m-raw{display:none}
.compare.view-raw .m-sbs{display:none}
.msgpick{width:100%;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px 7px 0 0;padding:6px 9px;font-size:12.5px;font-weight:600;cursor:pointer;margin-bottom:8px}
.sbs.sbs-pick .col{flex:1}                 /* equal columns (override the We-sent 1.6 bias) */
/* ---- The column splitter (.cmpsplit) -------------------------------------
   The gap between the two columns is a full-height drag grip: drag it and
   app.js writes flex-grow onto the two .col inline, double-click resets.
   `flex:none` + `align-self:stretch` is what makes it a fixed-width, full-bleed
   strip in a row whose other two items are growable -- a plain button could
   only ever be grabbable at its own 30px of height, which is precisely the
   "where do I aim?" problem a splitter must not have.

   The grip occupies the SAME 30px the bare .swap button used to, so adding it
   moves nothing: the button simply became its content, and keeps its own
   cursor:pointer so a click on it still reads as a click, not a drag handle.

   touch-action:none is load-bearing for pen/touch input -- without it the
   browser claims the gesture for panning and no pointermove ever arrives. */
/* touch-action:PAN-Y, not none. The grip is a full-panel-height strip, so
   `none` made it a dead zone for touch: a swipe starting anywhere in the gutter
   panned nothing -- not the page vertically, not .sbs horizontally. Worst at
   phone width, where app.js returns early (avail <= minL + minR, i.e. under
   ~558px) so the drag it was claiming the gesture FOR cannot even begin.
   pan-y keeps vertical page scrolling and still reserves the horizontal
   direction, which is the only axis the drag uses. */
.sbs .cmpsplit{flex:none;align-self:stretch;width:30px;display:flex;align-items:center;justify-content:center;
  cursor:col-resize;position:relative;touch-action:pan-y}
/* The hairline shows the grip is grabbable, and only on hover/drag: drawn
   always, it reads as a border between two panes that were never meant to have
   one. The button's solid --panel background sits on top of it by z-order, so
   the line renders as a track with a handle on it rather than a line crossed
   out by a box. */
.sbs .cmpsplit::before{content:"";position:absolute;top:2px;bottom:2px;left:50%;width:1px;margin-left:-.5px;
  background:var(--line);opacity:0;transition:opacity .12s,background .12s}
.sbs .cmpsplit:hover::before,.sbs .cmpsplit.on::before{opacity:1;background:var(--accent)}
.sbs .swap{border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:7px;width:30px;height:30px;cursor:pointer;font-size:14px;align-self:center;flex:none;position:relative}
.sbs .swap:hover{border-color:var(--accent);color:var(--accent)}
/* While a drag is live the cursor and the selection lock are set on <html>,
   not on the panel: pointer capture keeps delivering the move events after the
   pointer has left the 30px strip, so anything scoped to .sbs would drop both
   the col-resize cursor and the no-select guard exactly when the drag is at its
   widest. !important because it has to beat every element's own cursor rule
   (.swap's pointer, the selects, the tab buttons) for the duration. */
html.cmp-resizing,html.cmp-resizing *{cursor:col-resize !important;user-select:none !important;-webkit-user-select:none !important}

.kv.fieldgroup tr.hi td{background:var(--accent-soft)}
.kv.fieldgroup tr.hi td.v{font-weight:700}

/* --- Entity (buyside) selector -------------------------------------------
   Its own row directly under the product/view tabs and above the action bar:
   the entity governs what the page shows, but sits below List/Grid/History
   because those are view preferences that survive a switch.

   Rendered INSIDE nav.toolbar as a full-width wrapped flex item
   (flex-basis:100%), so it inherits the toolbar's horizontal padding and aligns
   with the product tabs BY CONSTRUCTION -- as a sibling it had to restate that
   18px itself, which is exactly the kind of duplicated magic number that drifts.
   No border-bottom: .actionbar just below already draws one, and two rules that
   close together read as a mistake. */
.entitybar{flex-basis:100%;display:flex;align-items:center;gap:8px;
           margin-top:12px;padding:0}
/* Breathing room ABOVE the selector, tight BELOW it. The gap underneath is
   otherwise doubled up -- the toolbar's own bottom padding plus main's 14px
   top padding -- which left the row hugging the tabs and floating far above
   Price. Scoped with :has() to the toolbar that actually carries an entity
   row, because .toolbar is shared with the matrix and onboarding pages and
   their spacing must not change. Browsers without :has() simply keep the
   roomier original gap. */
nav.toolbar:has(.entitybar){padding-bottom:0}
/* Inline mount (List): the selector rides in .actionbar left of Price, so it
   costs no vertical space at all. .inline-form is display:contents, so the
   label and select become flex children of THIS span, not of .actionbar --
   which is what keeps them grouped together instead of spreading across the
   bar. The divider marks where entity-scoping ends and the run controls
   begin. */
.entityinline{display:flex;align-items:center;gap:8px;padding-right:12px;
              margin-right:2px;border-right:1px solid var(--line)}
.entityinline label{font-size:12px;color:var(--muted);text-transform:uppercase;
                    letter-spacing:.04em}
.entityinline select{padding:3px 8px;border:1px solid var(--line);border-radius:5px;
                     background:var(--panel);color:var(--ink);font-size:13px;
                     cursor:pointer}
.entitybar label{font-size:12px;color:var(--muted);text-transform:uppercase;
                 letter-spacing:.04em}
.entitybar select{padding:3px 8px;border:1px solid var(--line);border-radius:5px;
                  background:var(--panel);color:var(--ink);font-size:13px;
                  cursor:pointer}
/* --- Env (PROD/UAT) selector -----------------------------------------------
   Mounted immediately alongside the entity selector at both its points (see
   _env.html). On Grid/History it lands inside the SAME .entitybar row as the
   entity selector -- .inline-form's display:contents makes its label/select
   bubble up as flex children of that div directly, so .entitybar's existing
   label/select rules above already style it: no extra CSS needed there. List
   is the one mount that needs its own class, mirroring .entityinline exactly
   (its own divider, since env is now the last scoping control before Price). */
.envinline{display:flex;align-items:center;gap:8px;padding-right:12px;
           margin-right:2px;border-right:1px solid var(--line)}
.envinline label{font-size:12px;color:var(--muted);text-transform:uppercase;
                 letter-spacing:.04em}
.envinline select{padding:3px 8px;border:1px solid var(--line);border-radius:5px;
                  background:var(--panel);color:var(--ink);font-size:13px;
                  cursor:pointer}

/* Empty-selection guard: the fire route refuses rather than firing everything.
   Sits inside #fire-notice (_list.html), a band between .actionbar (which
   already carries its own margin-bottom) and .batterybar -- margin-bottom
   only, matching .battery-error's convention, so it reads as a band rather
   than a floating card. */
.fire-error{border:1px solid var(--accent,#c2570a);border-radius:6px;
            padding:10px 14px;margin-bottom:10px}
.fire-error ul{margin:6px 0 4px 18px}

/* --- PROD environment banner + fire confirm (Task 6) ---------------------
   Solid alarm red rather than the themed --panel/--line/--accent palette
   used everywhere else: this must read as a warning in BOTH light and dark
   mode, not blend in like every other card on the page. Same red as the
   existing .scount.rejected / .cancelbadge accent (#b42318) so the app isn't
   introducing a second, competing "danger" hue. */
.prodbanner{background:#b42318;color:#fff;text-align:center;font-weight:700;
            font-size:13px;padding:9px 18px;letter-spacing:.02em}
/* Draft B (chosen from mockups): the confirm fragment sits inside
   #fire-notice (a band, not a full-width strip), so it borrows the alarm
   red as a border+heading accent instead of a solid fill -- it still needs
   to hold the summary list and the confirm/cancel actions legibly.
   margin-bottom only (like .fire-error / .battery-error): .actionbar just
   above already carries its own margin-bottom, so this reads as a band
   between the two bars rather than something floating. */
.prodconfirm{border:2px solid #b42318;border-radius:6px;padding:12px 16px;margin-bottom:10px}
.prodconfirm strong{color:#b42318}
.prodconfirm-summary{margin:8px 0 10px 18px}
.prodconfirm-actions{display:flex;gap:10px;align-items:center}
.prodconfirm-actions a.ghost{text-decoration:none;display:inline-block}
/* Async client export: the bar sits inline beside the button it replaced. */
.exportslot{display:inline-flex;align-items:center;gap:10px;flex-wrap:wrap}
.exportbar{display:inline-flex;align-items:center;gap:8px}
.exportbar-track{width:120px;height:6px;border-radius:3px;background:var(--line);overflow:hidden}
.exportbar-fill{display:block;height:100%;background:var(--accent);transition:width .3s ease}
.exportbar-count{font-size:.85rem;color:var(--muted)}
.exportbar-now{font-size:.8rem}
.exportdone{font-size:.85rem;color:var(--muted)}
/* Colour-only text fails AA on the dark --panel (~2.9:1, per the
   .fire-error/.battery-error comments above) -- paired with its own
   background+border, same convention as .battery-error/.cancelbadge. */
.exporterr{font-size:.85rem;color:var(--bad);background:#fdeceb;border:1px solid #e08a82;border-radius:8px;padding:2px 8px}
/* The "include counterparty messages" opt-in, beside (never inside) the
   exportslot -- see _export_progress.html's comment for why it lives there. */
.exportmsgs{display:inline-flex;align-items:center;gap:5px;font-size:12.5px;color:var(--muted);cursor:pointer}

/* Second-level variation strip (webapp/templates/_variations.html): a product's
   sub-shapes, e.g. Participation's Booster / Bonus / Cap Guar. Deliberately
   quieter and smaller than .tab -- it sits UNDER the product tabs and must not
   read as a peer of them, or Bonus looks like a sibling of Autocallable. Only
   the selected chip takes a border, so the strip is a thin line of text until
   it is being used. */
.vartabs{display:flex;gap:4px;align-items:center;padding:0 18px 9px;flex-wrap:wrap}
.vartabs::before{content:"";width:10px;height:1px;background:var(--line);margin-right:4px}
.vartab{border:1px solid transparent;background:transparent;color:var(--muted);
        padding:3px 10px;font-size:11.5px;border-radius:999px;text-decoration:none;cursor:pointer}
.vartab:hover{color:var(--ink)}
.vartab.on{color:var(--ink);border-color:var(--accent);background:var(--accent-soft)}
/* Inside nav.toolbar the strip must claim the whole row, or it lands beside the
   product tabs and reads as more of them. Same device the env/entity controls
   already use (see the flex-basis note above). */
.toolbar > .vartabs{flex-basis:100%;padding-left:0;padding-bottom:2px}

/* Transient Quote Mail feedback -- the ✉ stack in the top-right corner.

   The ✉ control says nothing permanent on success by design: the endpoint cannot
   distinguish a fresh insert from a dedupe, and ~6% of accepted rows never leave
   the queue, so no lasting claim is supportable. That leaves a registered click
   and a dead button looking identical -- this toast is the only thing that tells
   them apart, which is why it is NOT optional.

   ★ 2026-08-21. The first version of this was verified working end to end --
   server 200, correct OOB fragment, node in the DOM, animation running 0->1->0
   -- and the user still reported "clicking does nothing". Measured in the real
   page: 13px text, background var(--panel) on a var(--bg) body (#1d1913 on
   #14110d in dark: a 4% luminance step, effectively no box at all), 196px wide,
   sitting ~1600px to the RIGHT of the ✉ that had just been clicked, gone in
   ~3s. Every part of that was working; none of it was legible. Hence:
     - an --accent (--bad on failure) LEFT BAR, the one thing that reads as
       "something happened here" at the edge of vision;
     - a real shadow + a border that contrasts with the panel, not with the
       page, so the box has an edge in both themes;
     - two lines, the rfqID on its own, because "which row did I just click?"
       is the actual question;
     - 7s, not 4.5s, and they STACK rather than replace (see _qm_toast.html:
       8 clicks in 18s destroyed 7 of 8 confirmations under the old slot).
   pointer-events:none on the stack so a faded-but-present toast can never
   swallow a click on the header behind it. */
/* A fixed-width column, not shrink-to-fit: align-items:flex-end sized every
   toast to its own text, so a stack of them had a ragged left edge and the
   accent bars -- the whole point of the bars -- landed at four different x
   positions. One width makes the stack read as one thing. */
.qmtoasts{position:fixed;top:calc(var(--hdr) + 8px);right:14px;z-index:60;
  display:flex;flex-direction:column;align-items:stretch;gap:6px;
  pointer-events:none;width:min(340px,calc(100vw - 28px))}
/* opacity:1 is the BASE, and the animation fades OUT from it -- deliberately not
   the other way round. The old rule based the toast at opacity:0 and relied on a
   CSS animation to bring it up, which means anything that stops the animation
   (a reduce-motion extension, animation:none in a user stylesheet, a throttled
   background tab) leaves a toast that is in the DOM, has correct text, and is
   permanently invisible -- the exact failure that is impossible to diagnose from
   the outside. This way a broken animation degrades to a toast that stays put;
   app.js's fallback timer removes it. */
/* ★ clamp(), and px for the box -- NOT plain rem. Measured on the machine that
   reported this: Chrome's font-size preference is set to Small, so the ROOT font
   is 12px, not the 16px everyone assumes when they write `.82rem`. The old toast
   therefore rendered at 9.8px, not the ~13px it was written for -- a quarter
   smaller than intended, on the one element in the app whose entire job is to be
   noticed. Any `rem` here silently inherits that setting.
   The clamp still scales UP for anyone who raises their base font for
   accessibility; it just cannot go below legible. */
.qmtoast{background:var(--panel);color:var(--ink);opacity:1;
  border:1px solid var(--line);border-left:4px solid var(--accent);
  border-radius:6px;padding:9px 12px;
  box-shadow:0 6px 22px rgb(0 0 0 / .35);
  font-size:clamp(13px,.92rem,18px);line-height:1.35;text-align:left;
  animation:qmtoastfade 7s ease forwards}
.qmtoast .qmt-head{font-weight:600;letter-spacing:.01em}
.qmtoast .qmt-detail{margin-top:2px;color:var(--muted);
  font-size:clamp(12px,.86rem,17px);word-break:break-word}
.qmtoast.err{border-left-color:var(--bad)}
.qmtoast.err .qmt-head{color:var(--bad)}
@keyframes qmtoastfade{
  0%{opacity:0;transform:translateX(8px)}
  4%{opacity:1;transform:none}
  88%{opacity:1;transform:none}
  100%{opacity:0;transform:translateX(8px)}
}
/* Reduced motion drops the slide, but NOT the fade: the toast disappearing on
   its own is the behaviour, not decoration. */
@media (prefers-reduced-motion:reduce){
  @keyframes qmtoastfade{0%{opacity:0}4%{opacity:1}88%{opacity:1}100%{opacity:0}}
}72%{opacity:1}100%{opacity:0}}
}

/* Bulk Pricer Summary trigger. Sits after Clear in the action bar and stays a
   live button forever -- the bulk mode is not deduped the way the per-case ✉ is,
   so it must not adopt the envelope's inert-after-success look. Only the error
   state is coloured; success is a tick, deliberately quiet, because a 200 from
   that endpoint means ACCEPTED, not sent. */
.bulkmail{display:inline-flex}
.bulk-btn{white-space:nowrap}
.bulk-btn.bulk-err{border-color:var(--bad);color:var(--bad)}

/* Login — a standalone page, no shell. */
.loginpage { display: grid; place-items: center; min-height: 100vh; margin: 0; }
.loginbox { display: flex; flex-direction: column; gap: .9rem; align-items: center;
            width: min(22rem, 90vw); }
.loginbox h1 { margin: 0 0 .4rem; font-size: 1.5rem; }
.loginbox form { display: flex; flex-direction: column; gap: .8rem; width: 100%; }
.loginbox label { display: flex; flex-direction: column; gap: .3rem; font-size: .85rem; }
.loginbox input { padding: .5rem .6rem; font: inherit; }
.loginbox button { padding: .55rem; font: inherit; cursor: pointer; }
.loginerr { margin: 0; padding: .5rem .7rem; width: 100%; font-size: .85rem;
            border-left: 3px solid #b3261e; background: rgba(179,38,30,.09); }

/* Forced password change -- lives inside the normal shell (unlike .loginbox
   above, which is a standalone page), so it only needs the form itself
   styled, not a full-page layout. Reuses .loginerr for the error banner. */
.pwbox { display: flex; flex-direction: column; gap: .9rem; width: min(22rem, 90vw); padding: 1rem; }
.pwbox form { display: flex; flex-direction: column; gap: .8rem; }
.pwbox label { display: flex; flex-direction: column; gap: .3rem; font-size: .85rem; }
.pwbox input { padding: .5rem .6rem; font: inherit; }
.pwbox button { padding: .55rem; font: inherit; cursor: pointer; }
.pwnote { margin: 0; font-size: .85rem; opacity: .8; }

/* .who survives for the matrix approvals list, which still uses it as a
   requester byline; the header no longer does. */
.who { font-size: .8rem; opacity: .75; }
.logoutform { display: inline; margin: 0; }

/* --- The account menu (base.html) ----------------------------------------
   One avatar in the header, opening onto everything that used to be spread
   across four controls: identity, Change password, the two admin pages, and
   Sign out. A <details>, so it opens with no JS and cannot be broken by a
   quoting slip in an x-data attribute.

   Sized in px, not rem: this browser's root font is 12px rather than the 16px
   a rem scale assumes, so a .85rem control renders a quarter smaller here than
   it reads in the stylesheet -- the same trap already worked around on
   /admin/users. */
.acct{position:relative}
/* summary defaults to display:list-item; the marker has to go from BOTH the
   standard and the WebKit pseudo or a triangle sits inside the avatar. */
.acct>summary{display:inline-block;list-style:none;cursor:pointer;user-select:none}
.acct>summary::-webkit-details-marker{display:none}
.acct>summary::marker{content:""}
.acct>summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:50%}
.avatar{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;
  border:1px solid var(--line);background:var(--accent-soft);color:var(--ink);
  font-size:10.5px;font-weight:700;letter-spacing:.02em;line-height:1}
.acct>summary:hover .avatar{border-color:var(--accent)}
.acct[open]>summary .avatar{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent-soft)}
.avatar-lg{width:32px;height:32px;font-size:12px}

/* z-index above header.app's own 20, or the popup renders behind the sticky
   header's background on the pages that scroll under it. */
.acctpop{position:absolute;top:calc(100% + 7px);right:0;z-index:30;
  min-width:232px;max-width:min(300px,92vw);padding:6px;
  background:var(--panel);border:1px solid var(--line);border-radius:10px;
  box-shadow:0 10px 28px -12px rgba(0,0,0,.45)}

.acctid{display:flex;align-items:center;gap:9px;padding:7px 8px 10px;
  border-bottom:1px solid var(--line);margin-bottom:5px}
/* min-width:0 is what lets the ellipsis below actually apply -- a flex child
   defaults to min-width:auto and refuses to shrink under its text. */
.acctwho{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
.acctwho b{font-size:12.5px;font-weight:650;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.acctmail{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.acctrole{flex:none;font-size:9.5px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
  padding:2px 6px;border-radius:999px;border:1px solid var(--line);color:var(--muted)}
.acctrole.is-admin{border-color:var(--accent);background:var(--accent-soft);color:var(--accent)}

.acctsep{font-size:9.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;
  color:var(--muted);padding:8px 8px 4px;margin-top:4px;border-top:1px solid var(--line)}
/* One rule for the links AND the sign-out button: the button needs the <a>'s
   box back (browsers give it a border, a centred label and its own font). */
.acctitem{display:block;width:100%;box-sizing:border-box;text-align:left;
  padding:7px 8px;border:0;border-radius:7px;background:transparent;
  color:var(--ink);font:inherit;font-size:12.5px;text-decoration:none;cursor:pointer}
.acctitem:hover,.acctitem:focus-visible{background:var(--accent-soft);outline:none}
.acctitem:focus-visible{box-shadow:inset 0 0 0 1px var(--accent)}
.acctout{margin-top:2px;border-top:1px solid var(--line);border-radius:0 0 7px 7px}

/* An RFQ that travels as MORE THAN ONE message renders each extra leg under the
   primary payload -- BNP sends ContractCreation (the product) plus Solvings
   ({solveFor, targetPrice}), and showing only the first hid what we asked them
   to solve. The leg reuses .copywrap/.m-raw so it reads as the same kind of
   thing as the payload above it, not a new widget.
   .copybar is justify-content:flex-end, which would shove the kind label up
   against the copy button; a leg has a label AND a button, so it spaces them. */
.m-leg{margin-top:14px}
.m-leg .copybar{justify-content:space-between;align-items:center}
.legname{font:600 12px/1 ui-monospace,Menlo,Consolas,monospace;letter-spacing:.04em;color:var(--muted)}
/* A leg with no parseable fields has nothing for SBS, so its raw stays visible there. */
.compare.view-sbs .m-leg .m-raw.m-raw-only{display:block}
/* compare.salvage_body: FinIQ sent a payload that isn't valid JSON (BNP, since
   23-Sep). Says so above the recovered field table. */
.msgwarn{margin:6px 0 8px;padding:6px 10px;border:1px solid #e0a54a;border-radius:7px;
  background:rgba(224,165,74,.12);color:var(--ink);font-size:12px;line-height:1.4}

/* Termsheet mail in the compare panel (_compare_doc.html). The attachment is the
   issuer's answer, so it takes the place an RFQ message gives its field table:
   one chip per file, name first, size as an aside. */
.docfiles{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px}
.docfile{display:inline-flex;align-items:center;gap:7px;max-width:100%;padding:6px 11px;
  border:1px solid #57b97e;border-radius:8px;background:#e6f6ec;color:#1a7f45;
  font-size:12px;font-weight:650;text-decoration:none}
.docfile:hover{border-color:#1a7f45}
.docfile .nm{overflow-wrap:anywhere}
.docfile .sz{font-weight:500;opacity:.75;white-space:nowrap}
/* the sender's own words -- usually just a signature, which is itself the finding */
.doctext{margin:0 0 10px;padding:8px 10px;border-left:3px solid var(--line);
  font:inherit;font-size:12px;line-height:1.5;white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink)}
.docretry{margin-top:8px;padding:4px 10px;font-size:12px}
.docnote{margin:0 0 9px;font-size:11.5px;line-height:1.5;overflow-wrap:anywhere}

/* /admin/users. Sized in px, not rem: this browser's root font is 12px, not
   the 16px a rem-based scale assumes, so a .85rem control renders a quarter
   smaller here than it reads in the stylesheet (the same trap the harness
   pages already work around). .loginerr is reused for the error banner; this
   is its success counterpart. */
.okmsg{margin:0 0 10px;padding:7px 10px;font-size:12.5px;
       border-left:3px solid #1a7f45;background:rgba(26,127,69,.09)}
/* A deactivated account stays legible -- an admin has to be able to read the
   row in order to reactivate it -- but must not read as a live login. */
.rowoff td{opacity:.55}
.rowoff td:first-child,.rowoff td:nth-child(5){opacity:1}
/* <details> holding a small form: the password fields for one row, and the
   add-user form. Collapsed by default so twelve accounts do not become forty
   password boxes. summary carries .ghost, so it needs list-style stripped or
   the marker sits inside the button outline. */
.pwmini>summary{display:inline-block;list-style:none;user-select:none}
.pwmini>summary::-webkit-details-marker{display:none}
.pwmini[open]>summary{margin-bottom:8px}
.pwmini form{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:0}
.pwmini input[type=email],.pwmini input[type=text],.pwmini input[type=password]{
  padding:5px 7px;font:inherit;font-size:12.5px;border:1px solid var(--line);
  border-radius:8px;background:var(--panel);color:var(--ink);min-width:150px}
.pwmini button[type=submit]{padding:5px 10px;font:inherit;font-size:12.5px;
  border:1px solid var(--line);border-radius:8px;background:var(--accent-soft);
  color:var(--ink);cursor:pointer}
.pwmini p.muted{margin:6px 0 0;max-width:52ch}
.pwmini .adminbox{display:flex;align-items:center;gap:5px;font-size:12.5px;white-space:nowrap}
.adduser{margin-top:18px}
/* .headerlink lived here to make an <a> wearing .ghost sit level with the
   Sign out button. Both are gone: the account links moved into the avatar menu
   (.acctitem), which styles its own <a>s and its one <button> from one rule. */

/* --- LP Connect (MS <> SIMON) payload fragment ----------------------------
   Styles the read-only _lpconnect_rfq.html fragment embedded in the RFQ Lookup
   Console (the "Simon-MS" Entity source), NOT a dedicated page. px sizing
   throughout -- the root font here is 12px, so a rem would render 25% small
   (reference_browser_root_font_12px). */
.lpc-mono{font:600 12px/1.3 ui-monospace,Menlo,Consolas,monospace}
.lpc-corr{display:flex;gap:20px;flex-wrap:wrap;font-size:12.5px;margin:8px 0 10px;
          padding:8px 10px;background:var(--panel);border:1px solid var(--line);border-radius:6px}
.lpc-leg{margin:8px 0}
.lpc-leg-hd{font-size:11px;text-transform:uppercase;letter-spacing:.05em;font-weight:700;
            color:var(--accent);margin:0 0 4px}
.lpc-file{border:1px solid var(--line);border-radius:6px;margin:4px 0;background:var(--bg)}
.lpc-file>summary{cursor:pointer;padding:6px 10px;font-size:12px;list-style-position:inside}
.lpc-file>summary .lpc-copy{float:right}   /* the per-message ⧉ Copy button sits at the header's right edge */
.lpc-empty-leg{margin:0;padding:6px 10px;font-size:12px}
.lpc-json{margin:0;padding:8px 10px;font:12px/1.45 ui-monospace,Menlo,Consolas,monospace;
          white-space:pre;overflow-x:auto;max-height:340px;border-top:1px solid var(--line)}

/* --- Matrix change-request review card ------------------------------------
   Reported: "the pop up is somewhere in the bottom of the screen instead of
   a nice pop up in the middle or probably better right where the field is."

   It was not positioned at all. `#reqcard` is a bare <div> after the table
   and there was NO .reqcard rule anywhere, so the card rendered as an
   ordinary block wherever its mount happened to sit -- the bottom corner.
   The mount's own comment in matrix.html says this path is "not covered by
   any test in this plan, since every test here asserts on rendered HTML
   strings rather than live htmx/DOM behaviour", which is exactly how a
   feature ships looking like that.

   position:fixed, placed against the clicked cell by matrix.html's script.
   Fixed rather than absolute so the coordinates are viewport coordinates --
   the grid scrolls in its own container, and an absolutely-positioned card
   would need the offset parent's scroll folded in on every axis.

   px, not rem: this browser's root font is 12px, so a rem here renders a
   quarter smaller than it reads (reference_browser_root_font_12px).

   *** THE BOX IS ON THE PERMANENT MOUNT, NOT ON THE SWAPPED-IN FRAGMENT. ***
   It used to be `.reqcard`, the root element of _card.html -- i.e. the very
   node htmx replaces on every swap -- and that is what made three separate
   attempts at the placement script fail in the browser while every string
   assertion passed. htmx 2.0.4's `attributesToSettle` is
   ["class","style","width","height"], and handleAttributes() gives a
   swapped-in element that shares an id with the element it replaces the OLD
   element's attributes for the duration of the swap, restoring the new ones
   only at SETTLE (20ms later). So inside htmx:afterSwap the incoming card
   carried the bare mount's EMPTY class -- the script's "is a card open" test
   (`classList.contains('reqcard')`) was false, it returned early, and a
   position:fixed element with no coordinates paints at its static position:
   the bottom of the page. And had it not returned early, the settle task
   would then have wiped the inline left/top it had just written, because
   `style` is in that same list. Measured live, not reasoned about.

   Putting the box on a node htmx never touches removes both failure modes and
   the earlier one (a detached fragment root measures as all zeros). The mount
   is empty except while a card is open, so it hides itself when empty rather
   than painting an empty box. */
#reqcard-mount{position:fixed;z-index:60;min-width:250px;max-width:330px;
         background:var(--panel);border:1px solid var(--line);border-radius:10px;
         box-shadow:0 10px 30px rgba(0,0,0,.28);padding:12px 14px;font-size:12.5px}
#reqcard-mount:empty{display:none}
/* A DECIDED card lands in this same mount (the reject route answers with
   _req_decided.html), and that fragment is shared with the approvals page,
   where `.req` is a list item with a box of its own. Inside the mount the box
   is already drawn, so the fragment must not draw a second one. */
#reqcard-mount .req{border:0;padding:0 16px 0 0;margin:0;background:none}  /* room for the ✕ */

/* Decided card -- rendered by _req_decided.html on BOTH the approvals page
   and the review card on /matrix, which is why these live here rather than
   in approvals.html's own <style> where they started: on /matrix the card
   had no .dec rules at all and a rejection came out as bare body text.
   px, not rem: this browser's root font is 12px, so a rem here
   renders a quarter smaller than it reads in the stylesheet. */
.dec{font-size:12.5px;line-height:1.5}
.dec.syncing{color:var(--muted)}
.dec.done strong{color:#1a7f45}
.dec .decnote{margin-top:4px;color:var(--muted);font-style:italic}
.dec.failed{color:#8a5a00}
.dec.failed strong{color:#b42318}
.dec .decerr{font:12px/1.5 ui-monospace,Menlo,Consolas,monospace;word-break:break-all;margin:4px 0 6px}
.dec.failed p{margin:0 0 8px}
.req.decided{border-style:dashed}
/* .htmx-indicator's own rule is display:none / display:inline, so this sets
   the SHOWN state only -- putting display in the base rule would leave the
   indicator permanently visible. */
.dec-working{font-size:12.5px;font-weight:600;color:var(--accent);
             border:1px solid var(--accent);border-radius:6px;padding:4px 9px}
.htmx-request.dec-working,.htmx-request .dec-working{display:inline-flex;align-items:center}
.reqcard-h{color:var(--muted);font-size:11.5px;margin-bottom:8px;padding-right:18px}
.reqcard-ba{display:flex;align-items:center;gap:10px;margin-bottom:10px;font-size:15px}
.reqcard-ba .arrow{color:var(--muted)}
/* ★ The glyph sat in the TOP-LEFT of its box. `.cell` (matrix.html) is written
   for the grid's <button>s -- `width:28px;height:26px` plus a button's own
   centred content -- and these are <span>s, which are inline: width/height do
   not apply to a non-replaced inline box at all, and nothing centres the text.
   inline-flex makes the same rule mean the same thing on both. */
.reqcard-ba .cell{pointer-events:none;display:inline-flex;align-items:center;justify-content:center}
.reqcard-stale{color:#8a5a00;background:#fff4d6;border:1px solid #e6b23a;
               border-radius:8px;padding:6px 9px;font-size:11.5px;margin-bottom:10px;line-height:1.45}
.reqcard-act{display:flex;gap:6px;align-items:center;margin:6px 0 0}
.reqcard-act input{flex:1;min-width:0;font:inherit;font-size:12px;padding:5px 8px;
                   border:1px solid var(--line);border-radius:6px;
                   background:var(--bg);color:var(--ink)}
.reqcard-act .ok{color:#1a7f45;font-weight:600}
.reqcard-act .no{color:#b42318;font-weight:600}
/* A card you cannot dismiss is worse than one in the corner: the corner one
   at least did not cover the grid. */
.reqcard-x{position:absolute;top:6px;right:8px;border:0;background:none;
           color:var(--muted);font-size:15px;line-height:1;cursor:pointer;padding:2px 4px}
.reqcard-x:hover{color:var(--ink)}

/* =============================================================================
   MOTION -- the nine picks confirmed 2026-09-07 (round-two artifact
   e67f16d1). Everything here is additive and every animating rule sits inside
   `prefers-reduced-motion:no-preference`, authored NO-PREFERENCE-FIRST: a
   stylesheet that half-loads, or a viewer who asked for stillness, is left with
   the finished static UI rather than something stranded at opacity:0.
   Dark mode is the only theme reviewed so far; nothing below is theme-specific
   except by way of --accent.
   ============================================================================= */

/* --- M2: the mark assembles ONCE PER SESSION, as an opening curtain ---------
   The rail mark is static; #kzsplash is the only thing that assembles.

   ★★ TWO CLASSES, AND THE SPLIT IS THE POINT. `.mark-intro` (added in <head>,
   before first paint) puts the VEIL up. `.kz-go` starts the MOTION, and is added
   only once the page has stopped doing its own work -- DOMContentLoaded plus a
   frame, or a 600ms cap, whichever comes first. Reported as "the construction of
   the logo is a bit laggy, maybe cause the page is loading", and that was the
   right diagnosis: the eight paths were being re-rasterised sixty times a second
   against HTML parse, four deferred vendor scripts, and app.js measuring every
   row in the list. Covering instantly and animating a beat later costs nothing,
   because the veil is opaque either way -- there is nothing to see during the
   wait.

   ★ FOUR NUMBERS ARE THE WHOLE TIMING, and nothing else encodes it:
       --kz-part   how long one of the eight parts takes to arrive
       --kz-step   the stagger between them   (assembly = 7 x step + part)
       --kz-hold   when the wipe starts       (mark done, then a short beat)
       --kz-wipe   how long the wipe takes    (total = hold + wipe)
   Every keyframe is a plain from/to with the schedule carried by
   animation-delay, so retuning is those four values and no percentage
   arithmetic.

   ★ SVG UNIT TRAP. A CSS length on an SVG *child* resolves in USER UNITS, not
   CSS px: the mark is `viewBox 0 0 512 512`, so `translate:0 7px` on a <path>
   would render as a fraction of a device pixel. `transform-box` fixes ORIGIN
   resolution, not unit scale. The paths therefore only ever get the unitless
   `scale`/`opacity`; the one real translate rides on .kzs-mark, where px are px.

   ★ `will-change` on the paths is what makes the assembly cheap: each is then
   rasterised ONCE and composited, instead of re-rasterising complex path data
   every frame. Safe here specifically because the scale range is 0.8 -> 1.0 --
   a 20% blow-up of an already-rasterised layer is invisible, where a 0 -> 1
   animation would show. It is scoped to `.kz-go`, so the hint is dropped the
   moment the curtain is done rather than left on the page for ever.

   ★★ FAILURE MODES. This is a full-viewport opaque panel, so what happens when
   something does not run matters more than what happens when it does:
     - `pointer-events:none` always. An overlay that is briefly see-through is a
       far better failure than one that silently swallows every click.
     - `kz-bail` is an independent 6s watchdog on `#kzsplash` itself, keyed on
       `.mark-intro` and NOT on `.kz-go`. If the motion never starts the veil
       still leaves, and it touches `visibility` rather than `--kz-iris` so it
       cannot interact with the wipe. Normally it fires at 6s against a curtain
       that finished at 2.5s, i.e. never visibly.
     - the head script schedules `.kz-go` BEFORE it adds `.mark-intro`, so the
       veil can never go up without a start already queued. */
@property --kz-iris{syntax:"<length>";initial-value:0px;inherits:false}

#kzsplash{display:none}
@media (prefers-reduced-motion:no-preference){
.mark-intro #kzsplash{display:grid;place-items:center;position:fixed;inset:0;
  z-index:200;pointer-events:none;
  --kz-part:.73s;--kz-step:.1043s;--kz-hold:1.5s;--kz-wipe:1s;
  animation:kz-bail 6s step-end both}
.mark-intro .kzs-veil{position:absolute;inset:0;background:var(--bg);
  --kz-iris:0px;                      /* covering; .kz-go's wipe opens it */
  -webkit-mask:radial-gradient(circle at 50% 50%,transparent var(--kz-iris),#000 calc(var(--kz-iris) + 2px));
  mask:radial-gradient(circle at 50% 50%,transparent var(--kz-iris),#000 calc(var(--kz-iris) + 2px))}
.mark-intro.kz-go .kzs-veil{
  animation:kz-curtain var(--kz-wipe) cubic-bezier(.4,0,.2,1) var(--kz-hold) both}
.mark-intro .kzs-mark{position:relative;display:flex;flex-direction:column;
  align-items:center;gap:15px;opacity:0}
.mark-intro.kz-go .kzs-mark{
  animation:kz-mark-rise .64s cubic-bezier(.2,.7,.3,1) both,
            kz-mark-out .2s ease calc(var(--kz-hold) - .06s) both}
.mark-intro .kzs-mark .logo{width:clamp(84px,12vmin,124px);height:clamp(84px,12vmin,124px)}
.mark-intro.kz-go #kzsplash .logo path{transform-box:fill-box;transform-origin:50% 60%;
  will-change:transform,opacity;
  animation:kz-in var(--kz-part) cubic-bezier(.2,.75,.3,1) both}
/* middle out: face -> hair -> hat -> body. FIVE class selectors, because the
   rule above is (0,4,1) on its element selector -- at (0,4,0) these would lose
   and every delay would silently reset to zero, collapsing the stagger. */
.mark-intro.kz-go #kzsplash .logo .kz-skin   {animation-delay:calc(var(--kz-step) * 0)}
.mark-intro.kz-go #kzsplash .logo .kz-eyes   {animation-delay:calc(var(--kz-step) * 1)}
.mark-intro.kz-go #kzsplash .logo .kz-hair   {animation-delay:calc(var(--kz-step) * 2)}
.mark-intro.kz-go #kzsplash .logo .kz-hat    {animation-delay:calc(var(--kz-step) * 3)}
.mark-intro.kz-go #kzsplash .logo .kz-hatband{animation-delay:calc(var(--kz-step) * 4)}
.mark-intro.kz-go #kzsplash .logo .kz-shirt  {animation-delay:calc(var(--kz-step) * 5)}
.mark-intro.kz-go #kzsplash .logo .kz-patch  {animation-delay:calc(var(--kz-step) * 6)}
.mark-intro.kz-go #kzsplash .logo .kz-coat   {animation-delay:calc(var(--kz-step) * 7)}
}
.kzs-name{font-size:17px;font-weight:750;letter-spacing:.3px;color:var(--ink);opacity:0}
@media (prefers-reduced-motion:no-preference){
.mark-intro.kz-go .kzs-name{animation:kz-curtain-name .46s cubic-bezier(.2,.7,.3,1)
  calc(var(--kz-step) * 5) both}}
@keyframes kz-in{from{opacity:0;scale:.8}to{opacity:1;scale:1}}
/* 150vmax clears the corner of any viewport, which is the same thing C9 gets by
   computing a hypotenuse from an off-centre button. Here the origin is fixed at
   the middle, so a constant does it. */
@keyframes kz-curtain{from{--kz-iris:0px}to{--kz-iris:150vmax}}
@keyframes kz-mark-rise{from{translate:0 7px}to{translate:0 0}}
@keyframes kz-mark-out{from{opacity:1}to{opacity:0}}
@keyframes kz-curtain-name{from{opacity:0;translate:0 5px}to{opacity:1;translate:0 0}}
@keyframes kz-bail{from{visibility:visible}to{visibility:hidden}}

/* --- L1: halo -- an accent arc orbiting the mark while a run is in flight ----
   ★ CLIPPING, and it is real rather than hypothetical. `.navsticky` is
   `max-height` + `overflow-y:auto`, and per CSS a non-visible overflow on one
   axis computes the other to `auto` as well -- so it clips in BOTH directions
   and anything overhanging the mark is cropped. The 40px halo overhangs the
   30px mark by 5px on every side, so `.sidenav .brandlink`'s top padding goes
   2px -> 8px to buy it room inside that scroll container. Nothing else in the
   rail moves; the brand block sits 6px lower and is no longer flush to the top.

   ★ SIZING. The demo ringed a 64px mark at 92px. Here the mark is 30px and sits
   in a flex row beside .brandname, so only the SVG is wrapped (.brandmark) and
   the ring is absolute + pointer-events:none inside it -- the wrapper must not
   become a 40px flex item and shove the wordmark sideways.

   Hook: `.runstatus.busy` already exists for exactly the life of a run
   (_progress.html OOB-swaps it in, _results.html empties it again), so the halo
   needs no state of its own. Chrome-only app, so :has() on <body> is fine. */
.sidenav .brandlink{padding-top:8px}
.brandmark{position:relative;display:inline-flex;flex:none}
.kz-halo{position:absolute;inset:50% auto auto 50%;width:40px;height:40px;
  margin:-20px 0 0 -20px;border-radius:50%;pointer-events:none;opacity:0;
  background:conic-gradient(from 0deg,transparent 0 70%,var(--accent) 90%,transparent 100%);
  -webkit-mask:radial-gradient(circle,transparent 0 16px,#000 17px);
  mask:radial-gradient(circle,transparent 0 16px,#000 17px)}
@media (prefers-reduced-motion:no-preference){
  body:has(.runstatus.busy) .kz-halo{opacity:1;animation:kz-spin 1.5s linear infinite}}
@keyframes kz-spin{to{rotate:360deg}}

/* --- P1: shimmer on the rows still awaiting a leg ----------------------------
   ★ NOT `.resp:empty`. That also matches every UNFIRED case row -- ~30 rows
   that will never receive a pill would shimmer, which is a correctness defect
   wearing a style costume, in a UI whose entire job is not misreading state.
   `.row:has(.pill.wait)` is the only set provably still in flight, and the
   `.runstatus.busy` ancestor test means a finished run that left a stranded
   wait pill behind stops shimmering when the run does. */
@media (prefers-reduced-motion:no-preference){
/* `:not(.kz-flip)` so a pill wearing its old `wait` face through R1's turn does
   not count as still waiting -- without it, the last leg to resolve restarts
   the row's shimmer for 250ms just as the run finishes. */
body:has(.runstatus.busy) .row:has(.pill.wait:not(.kz-flip)){
  background-image:linear-gradient(100deg,transparent 38%,
    color-mix(in oklab,var(--accent) 12%,transparent) 50%,transparent 62%);
  background-size:220% 100%;background-repeat:no-repeat;
  animation:kz-shim 1.7s linear infinite}}
@keyframes kz-shim{from{background-position:-160% 0}to{background-position:260% 0}}

/* --- S2 unfurl + R1 flip -- both DIFF-GATED from app.js ---------------------
   Neither class is ever written by a template: app.js's kzScan adds them only
   when something actually changed. See the long comment there for why a bare
   CSS animation here would strobe every pill on the page twice a minute.
   ★ `--kz-h` is MEASURED (box.scrollHeight) rather than guessed: `.resp` is a
   5px-gap flex column of ~22px pills, so five issuers already exceed the 120px
   the recipe suggested, and a too-small end value makes the row snap open on
   the last frame instead of unfurling. The fallback covers a box whose height
   could not be read. */
@media (prefers-reduced-motion:no-preference){
.resp.kz-unfurl{overflow:hidden;animation:kz-unfurl .42s cubic-bezier(.3,.8,.3,1) both}
.pill.kz-flip{animation:kz-flip .5s ease-in-out both}}
@keyframes kz-unfurl{from{max-height:0;opacity:0}to{max-height:var(--kz-h,140px);opacity:1}}
/* ★ `step-end` on the 49% stop is NOT decoration -- without it that stop
   INTERPOLATES rotateX(90deg) -> rotateX(-90deg), and the shortest path between
   them runs straight through rotateX(0): the pill snaps back to FULL HEIGHT,
   still wearing its old face, immediately before the answer arrives. At 500ms
   the window is 5ms and you will never catch it; in slow motion it is a plain
   flash of the whole Waiting pill just past the pivot (found exactly that way).
   step-end holds it edge-on across the gap and jumps the sign instead, so the
   turn never faces you again before the flick. */
@keyframes kz-flip{0%{transform:rotateX(0)}
  49%{transform:rotateX(90deg);animation-timing-function:step-end}
  50%{transform:rotateX(-90deg)}100%{transform:rotateX(0)}}
/* R1's FRONT FACE -- what makes the answer arrive on the far side of the turn
   rather than being legible before it starts. app.js re-applies the previous
   outcome class for the first 250ms (so the colour is the real previous class,
   not a copy of it) and parks the previous outcome text in data-kz-prev; this
   pair is only what hides the new text behind it. `display:none` rather than
   `visibility:hidden` on the children: .out sits in a FIXED 118px grid column,
   so removing them cannot change the pill's width and give the change away. */
.pill.kz-oldface .out > *{display:none}
.pill.kz-oldface .out::after{content:attr(data-kz-prev);white-space:nowrap;
  min-width:0;overflow:hidden;text-overflow:ellipsis}

/* --- C1: copy tick ----------------------------------------------------------
   copy.js already swaps the label to "✓ Copied"; only the motion was missing.
   ★ The class is added on the SUCCESS branch ONLY -- flashCopyBtn also carries
   "✕ Copy failed", and a confirmation animation over a failed clipboard write
   is the same class of state-misread P1 avoids above.
   Motion only, no colour: `.cmp-band .copybtn` is deliberately white-on-accent,
   and a green tint from a later rule would win there and land dark on orange. */
@media (prefers-reduced-motion:no-preference){
.copybtn.kz-copied{animation:kz-tick .3s cubic-bezier(.2,.8,.3,1) both}}
@keyframes kz-tick{0%{opacity:.35;scale:.8}60%{opacity:1;scale:1.06}100%{opacity:1;scale:1}}

/* --- C5: toast stack entry --------------------------------------------------
   ★ THE ENTRY ANIMATION MUST NOT TOUCH `opacity`. `.qmtoast` already runs
   `qmtoastfade 7s`, which owns opacity for the toast's whole life -- and is
   deliberately based at opacity:1 so a broken animation leaves a toast that is
   VISIBLE rather than one that is present, correct and permanently invisible.
   A second animation naming opacity would win for that property and take the
   fade with it. `translate`/`scale` only, which compose with qmtoastfade's own
   `transform` instead of fighting it.
   Declared inside no-preference (rather than by neutering the keyframes) so the
   reduce path keeps the existing single-animation rule exactly as it was. */
@media (prefers-reduced-motion:no-preference){
.qmtoast{animation:kz-toast-in .3s cubic-bezier(.3,.8,.3,1) both,
                   qmtoastfade 7s ease forwards}}
@keyframes kz-toast-in{from{translate:8px 0;scale:.97}to{translate:0 0;scale:1}}

/* --- C7 (SUBSTITUTED): the fields / compare panels open from their trigger ---
   ★ There is no dropdown in Kozak to animate. Every selector on every page is a
   native <select>, whose popup is OS-rendered and cannot be styled or animated
   at all. The nearest honest home for "opens from its trigger" is the two
   htmx-injected panels that genuinely do: `.fields-mount` (▸) and
   `.compare-mount` (⇄), plus their History twins, which are the same two
   controls on the other tab.
   No diff-gate needed, unlike S2/R1: these are CLICK-rendered, not polled --
   the mount's innerHTML is emptied on close and refilled on open, so the child
   is a fresh insertion and the animation runs exactly once per open.
   ★ transform-origin is TOP RIGHT, beside the ▸/⇄ that opened it. "Opens from
   its trigger" is the entire point of the motion; a centre origin loses it. */
@media (prefers-reduced-motion:no-preference){
.fields-mount>*,.compare-mount>*,.histfields-mount>*,.histcmp-mount>*{
  transform-origin:top right;
  animation:kz-panel .26s cubic-bezier(.3,.8,.3,1) both}}
@keyframes kz-panel{from{opacity:0;scale:.97;translate:0 -5px}to{opacity:1;scale:1;translate:0 0}}

/* --- C9: circular theme wipe ------------------------------------------------
   Driven from base.html's toggleTheme(event) via startViewTransition(); these
   two declarations are the whole CSS side, and both halves matter:

   `animation:none` KILLS the default cross-fade on both snapshots. Left in, the
   old root fades out under the new one and the result is a dissolve, not a
   wipe -- the clip-path circle would be invisible underneath it.

   `mix-blend-mode:normal` overrides the UA's `plus-lighter`, which exists to
   make a cross-fade of two half-transparent snapshots add back up to 1. With a
   hard clip and no fade there is nothing to reconcile and plus-lighter just
   blows out every overlapping pixel.

   The NEW snapshot already paints above the old one, which is what makes the
   growing circle read as the new theme ARRIVING rather than the old one
   leaving. Unscoped, because this app starts exactly one view transition from
   exactly one button -- see toggleTheme's own comment on why
   htmx.config.globalViewTransitions is deliberately NOT set. */
::view-transition-old(root),::view-transition-new(root){animation:none;mix-blend-mode:normal}

/* Wire-log panel (_leg_log.html), served into BOTH the lookup console and the ⇄
   compare panel. It landed on 2026-09-18 with no styles at all. One block per log
   FILE (.wl-ev), one row per meaningful line (.wl-step): VM time, a mark, the label,
   the line verbatim underneath. Marks: ✓ ok · neutral · ! warn · ✕ no. The verdict
   banner takes its colour from the verdict KIND (ok / warn-ish / failure). */
.wl-blocked{font-size:12.5px;padding:8px 10px;border-radius:7px;background:var(--accent-soft)}
.wl-verdict{border-radius:7px;padding:8px 11px;margin:0 0 10px;background:var(--accent-soft)}
.wl-verdict .wl-hd{font-weight:650;font-size:13px}
.wl-verdict .wl-bd{font-size:12px;color:var(--muted);margin-top:2px}
.wl-verdict.wl-ok{background:rgba(87,185,126,.14)}
.wl-verdict.wl-ok .wl-hd{color:#1a7f45}
.wl-verdict.wl-transport,.wl-verdict.wl-auth,.wl-verdict.wl-timeout,.wl-verdict.wl-http-error,
.wl-verdict.wl-save-error,.wl-verdict.wl-error,.wl-verdict.wl-lp-reject{background:rgba(224,101,90,.14)}
.wl-verdict.wl-transport .wl-hd,.wl-verdict.wl-auth .wl-hd,.wl-verdict.wl-timeout .wl-hd,
.wl-verdict.wl-http-error .wl-hd,.wl-verdict.wl-save-error .wl-hd,.wl-verdict.wl-error .wl-hd,
.wl-verdict.wl-lp-reject .wl-hd{color:var(--bad)}
/* the issuer's own reasons under the headline -- their words, then sent vs allowed */
.wl-verdict .wl-vl{font-size:12.5px;margin-top:3px}
/* "Sent as" -- the whitelisted request fields (user / issuer / productType) */
.wl-sent{display:flex;flex-wrap:wrap;gap:3px 12px;align-items:baseline;font-size:12px;margin:0 0 10px}
.wl-sent .wl-kv{font-family:ui-monospace,Consolas,monospace;font-size:11.5px}
.wl-sent .wl-kv span{color:var(--muted)}
/* FinIQ's own steps, folded under the verdict unless the failure is in them */
.wl-plumb>summary{cursor:pointer;font-size:12px;font-weight:600;color:var(--muted);padding:3px 0}
.wl-plumb[open]>summary{margin-bottom:6px}
.wl-plumb>summary+.wl-ev{margin-top:0}
.wl-verdict.wl-duplicate,.wl-verdict.wl-timeout-notice,.wl-verdict.wl-no-reply{background:rgba(214,160,40,.16)}
.wl-verdict.wl-duplicate .wl-hd,.wl-verdict.wl-timeout-notice .wl-hd,.wl-verdict.wl-no-reply .wl-hd{color:#9a6a00}
.wl-ev{margin-top:12px}
.wl-ev:first-child{margin-top:0}
.wl-evh{display:flex;flex-wrap:wrap;gap:3px 10px;align-items:baseline;font-size:12px;padding-bottom:4px;border-bottom:1px solid var(--line)}
.wl-evh b{font-weight:650}
.wl-evh span{color:var(--muted);font-family:ui-monospace,Consolas,monospace;font-size:11px}
.wl-step{display:grid;grid-template-columns:84px 14px minmax(0,1fr);gap:8px;padding:4px 0;border-bottom:1px dotted var(--line);font-size:12.5px}
.wl-t{font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;padding-top:1px}
.wl-mark{text-align:center;font-weight:700;color:var(--muted)}
.wl-step.wl-ok .wl-mark{color:#1a7f45}
.wl-step.wl-warn .wl-mark{color:#b7791f}
.wl-step.wl-no .wl-mark,.wl-step.wl-no .wl-ex{color:var(--bad)}
.wl-lbl{font-weight:600}
.wl-step.wl-dot .wl-lbl{font-weight:500}
.wl-ex{display:block;font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--muted);white-space:pre-wrap;word-break:break-word}
.wl-note{display:block;font-size:11.5px;color:var(--muted);font-style:italic;margin-top:1px}
.wl-empty{font-size:12px;color:var(--muted);border:1px dashed var(--line);border-radius:6px;padding:8px 10px;margin-top:8px}
.wl-empty.wl-never{border-color:var(--bad);color:var(--ink)}
/* email leg whose day is still in the issuer's LIVE (locked) log: not a fault */
.wl-empty.wl-live{border-style:dotted}
.wl-reply{margin-top:10px}
.wl-reply .wl-ex{background:var(--panel);border-radius:6px;padding:7px 9px;margin-top:4px;max-height:260px;overflow:auto}
.wl-act{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:12px;font-size:12px}
.wl-btn{border:1px solid var(--accent);color:var(--accent);border-radius:7px;padding:3px 10px;font-weight:600;text-decoration:none}
.wl-btn:hover{background:var(--accent-soft)}
.wl-meta{color:var(--muted);font-family:ui-monospace,Consolas,monospace;font-size:11px}
@media (max-width:560px){.wl-step{grid-template-columns:64px 12px minmax(0,1fr);gap:6px}}
