/* app.css - one stylesheet, mobile-first, dense.
 *
 * THE CURVES PAGE IS A PORT, NOT AN APPROXIMATION. Everything under the "CURVES"
 * banner is lifted from curve.py's PAGE <style> block (the local page on :8766),
 * rule for rule and colour for colour, because the two are meant to be the same
 * readout on two screens: a trader glancing between the phone and the desk must
 * not have to re-learn which green means bid or what a superscript amber "scr"
 * is. Where the two disagree, 8766 wins - it is the reference.
 *
 * An earlier version of this file approximated the look from memory and drifted
 * in a dozen small ways that added up: scr markers teal instead of amber, the
 * relation badge teal instead of violet, monospace everywhere instead of only on
 * numbers, fully boxed cells instead of underlined rows, striped rows instead of
 * hover, uppercase leaf headers, and a different type scale.
 *
 * WHAT IS DELIBERATELY NOT FROM 8766 - the webapp's own chrome, which has no
 * counterpart there: the auth gate, the top bar with the status/session row, the
 * page tabs, the STALE banner and feed panel, the board page, and the mobile
 * scroll architecture (body as a viewport-tall flex column, the page area as the
 * scrollport). Each is marked where it appears.
 *
 * THE WIDTH MECHANISM IS ALSO 8766's, and it is load-bearing rather than
 * cosmetic: table-layout:fixed, a zero-height sizer row per table carrying the
 * .cw-* widths, and the grid's total width written inline per block by curves.js.
 * That is what makes a collapsed column-set actually contract its table - and
 * section.product's width:max-content then pulls the whole block in behind it, so
 * the blocks to its right slide left instead of a dead blank region being left.
 */

:root {
  color-scheme: dark;

  /* 8766's palette, verbatim. */
  --bg:      #0d1117;
  --panel:   #161b22;
  --panel2:  #10151c;
  --line:    #21262d;
  --line2:   #30363d;
  --text:    #e6edf3;   /* body text on 8766 */
  --text2:   #c9d1d9;   /* group headers, raw quote text, two-way side */
  --muted:   #8b949e;
  --faint:   #6e7681;
  --bid:     #3fb950;
  --offer:   #ff7b72;
  --trade:   #79c0ff;
  --indic:   #e3b341;
  --amber:   #d29922;   /* screen marker, stale stamps, intensity-hot */
  --bad:     #f85149;
  --ok:      #3fb950;

  /* 8766 is SANS for chrome and MONO only where digits must line up. Having
     this backwards (mono everywhere) was the biggest single visual difference. */
  --sans: -apple-system, "Segoe UI", Roboto, sans-serif;
  --mono: ui-monospace, SFMono-Regular, Consolas, monospace;
}

* { box-sizing: border-box; }

/* LOAD-BEARING, not tidiness. Several elements are toggled with the `hidden`
   ATTRIBUTE (#gate / #app, the two .page sections, the banner, the feed panel,
   the notices), and `hidden` is only a UA rule - so the moment any of them is
   given a `display` by a class or id selector below, that selector wins and the
   element becomes permanently visible. This keeps the attribute authoritative
   whatever the rest of the file says. */
[hidden] { display: none !important; }

html {
  height: 100%;
  background: var(--bg);
}

body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: 12px;
  line-height: 1.35;
  /* No text auto-inflation on rotate, and no rubber-band highlight on tap - a
     price cell is a control here, not prose. */
  -webkit-text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;

  /* THE SCROLL ARCHITECTURE (webapp-specific, but the same shape as 8766's
     body / .wrap / #products). The DOCUMENT never scrolls; body is a
     viewport-tall flex column, the header is a fixed-height row at the top, and
     the page area below it fills the rest and does its own scrolling.

     Why it has to be this way: a horizontal scrollbar belongs to whatever box
     overflows, and if that box is the document then the scrollbar sits at the
     bottom of the whole DOCUMENT - ten product blocks tall - so reaching it
     means scrolling to the very bottom of the page first. Pinning the scrollport
     to the viewport height puts its horizontal scrollbar at the bottom of the
     SCREEN, always in reach, and keeps the header and tabs still while the grid
     moves under them.

     100dvh after 100vh on purpose: on mobile Safari/Chrome 100vh excludes the
     retracted URL bar, so the last ~60px of a 100vh box - exactly where the
     horizontal scrollbar lives - sits off the bottom of the screen. dvh is the
     visible height and tracks the bar. The 100vh line is the fallback. */
  height: 100vh;
  height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

button { font: inherit; color: inherit; cursor: pointer; }
input, select { font: inherit; color: inherit; }

/* ===================================================================
   THE GATE (login) - webapp chrome, no counterpart on 8766
   =================================================================== */

/* A flex child of a non-scrolling body, so it owns its own vertical scroll -
   otherwise the keyboard coming up on a phone would clip the form with no way to
   reach the button. Centred by PADDING rather than `margin: 0 auto` + max-width:
   as a flex child it stretches to the full width (which it must, to be the
   scrollport), so the 380px column comes from giving the side padding whatever
   is left over. */
.gate {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding: 32px max(16px, calc((100% - 380px) / 2)) 48px;
}

.brand { font-size: 20px; font-weight: 600; letter-spacing: .5px; margin: 0 0 20px; }
.brand .thin { color: var(--muted); font-weight: 400; }
.brand.small { font-size: 13px; margin: 0; }

.card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 14px;
  margin-bottom: 12px;
}
.card label { display: block; color: var(--muted); font-size: 11px; margin-bottom: 6px; }
.card input {
  width: 100%;
  padding: 10px;
  background: var(--panel2);
  border: 1px solid var(--line2);
  border-radius: 4px;
  margin-bottom: 10px;
}
.card input:focus { outline: 1px solid var(--trade); border-color: var(--trade); }
.card button {
  width: 100%;
  padding: 11px;
  background: #1f6feb;
  border: 0;
  border-radius: 4px;
  color: #fff;
  font-weight: 600;
}
.card button:disabled { background: var(--line2); color: var(--muted); }

.hint { color: var(--muted); font-size: 10.5px; line-height: 1.45; margin: 8px 0 0; }
.hint.tight { margin: 4px 0 8px; }
.hint code { color: var(--indic); font-family: var(--mono); }

.notice {
  border-radius: 4px;
  padding: 9px 10px;
  font-size: 11px;
  border: 1px solid var(--line2);
  background: var(--panel2);
  margin: 10px 0 0;
}
.notice.bad  { border-color: #6e2723; background: #2a1614; color: #ffb4ae; }
.notice.good { border-color: #1c4428; background: #12251a; color: #8ee39b; }
#gate-config { margin: 0 0 12px; }

/* ===================================================================
   HEADER / TABS / STALE BANNER - webapp chrome
   =================================================================== */

/* #app is the second flex column: header rows that keep their height, then the
   page area that absorbs everything left over. min-height:0 is what allows a
   flex child to be SHORTER than its content - without it the page area grows to
   fit ten product blocks, pushes the bottom of the layout past the viewport, and
   the scrollport's horizontal scrollbar goes off-screen with it. */
#app {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/* A fixed-height flex row, not position:sticky - the document no longer scrolls,
   so the header stays put by layout, which is stricter (nothing can slide under
   it) and one less stacking context. Panel background and 1px #30363d rule to
   match 8766's own <header>. */
.top {
  flex: 0 0 auto;
  position: relative;   /* only so the z-index means something */
  z-index: 20;
  background: var(--panel);
  border-bottom: 1px solid var(--line2);
  padding: max(5px, env(safe-area-inset-top)) 10px 0;
}

.topline { display: flex; align-items: center; gap: 10px; min-height: 28px; }
.topline .spacer { flex: 1; }
.who { color: var(--muted); font-size: 11px; max-width: 40vw;
       overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.status {
  display: inline-flex; align-items: center; gap: 5px;
  background: var(--bg); border: 1px solid var(--line2);
  border-radius: 999px; padding: 2px 9px 2px 7px; font-size: 11px;
  color: var(--muted);
}
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); }
.dot.live { background: var(--ok);    box-shadow: 0 0 5px rgba(63,185,80,.7); }
.dot.warn { background: var(--amber); box-shadow: 0 0 5px rgba(210,153,34,.7); }
.dot.down { background: var(--bad);   box-shadow: 0 0 5px rgba(248,81,73,.8); }

.ghost {
  background: transparent; border: 1px solid var(--line2);
  border-radius: 4px; padding: 3px 8px; font-size: 11px; color: var(--muted);
}
.ghost:hover { color: var(--text); border-color: #4d5560; }
.ghost.wide { width: 100%; padding: 10px; }

.tabs { display: flex; gap: 2px; margin-top: 4px; }
.tab {
  background: transparent; border: 0; border-bottom: 2px solid transparent;
  padding: 6px 14px; color: var(--muted); font-size: 12px;
}
.tab.on { color: var(--text); border-bottom-color: var(--trade); }

/* THE STALE BANNER. Full width, unmissable, never behind a tap. flex:0 0 auto so
   it takes its own height off the top of the page area rather than overlapping
   or being scrolled away with the grid. */
.banner {
  flex: 0 0 auto;
  margin: 0; padding: 8px 10px; font-size: 11px; line-height: 1.45;
  border-bottom: 1px solid var(--line);
}
.banner.bad { background: #2a1614; color: #ffb4ae; border-bottom-color: #6e2723; }
.banner div + div { margin-top: 4px; }

.feeds {
  flex: 0 0 auto;
  background: var(--panel); border-bottom: 1px solid var(--line);
  padding: 8px 10px; font-size: 11px;
}
.frow { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
.fk { color: var(--muted); }
.fv { color: var(--text); text-align: right; font-family: var(--mono); }

/* THE PAGE AREA. Fills what the header rows left. overflow:hidden here is on the
   CROSS-CUTTING box only - the visible .page inside it is the real scrollport,
   and hiding overflow at this level stops a momentarily-oversized child from
   producing a second, document-level scrollbar while the page switches. It cannot
   clip the pan: see the ancestor-chain note under THE HORIZONTAL SCROLL. */
main {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

/* Both pages fill the area and scroll themselves. The curves page scrolls BOTH
   ways (see #page-curves); the board only needs vertical.
   `main > .page`, NOT a bare `.page`: curves.js also puts a class="page" span in
   each product header (the block's own age), and a bare selector would hand that
   span flex sizing and an overflow of its own. */
main > .page {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  /* A sideways pan that runs out of grid must not turn into a browser
     back-swipe or a pull-to-refresh half way through reading a curve. */
  overscroll-behavior: contain;
}

#page-board { padding: 8px 0 0; }

/* ===================================================================
   CURVES - ported from curve.py PAGE (lines 2446-2851). 8766 is the reference.
   =================================================================== */

/* ===== THE HORIZONTAL SCROLL (webapp-specific; do not regress) ======
 *
 * 1. #page-curves is the SCROLLPORT. It overflows, so it owns the scrollbars -
 *    and because body's flex column gave it exactly the leftover viewport
 *    height, its horizontal scrollbar sits at the bottom of the SCREEN rather
 *    than at the bottom of ten blocks' worth of document. This is 8766's .wrap,
 *    including its 10px padding.
 * 2. #curves is the CONTENT: one long row, sized max-content, so it is as wide
 *    as all the blocks together. Something must actually be wider than the
 *    scrollport or there is nothing to scroll - that is this rule's whole job.
 *    This is 8766's #products.
 * 3. .product blocks are flex:0 0 auto + width:max-content - natural width,
 *    neither shrunk nor wrapped. This is 8766's section.product.
 *
 * AUDITED ANCESTOR CHAIN - html > body > #app > main > #page-curves > #curves >
 * .product - because one overflow:hidden above the scrollport kills the pan.
 * Two boxes in that chain set it, both safe:
 *   body   overflow:hidden - deliberate. The document is not the scroller.
 *   main   overflow:hidden - an ancestor, so worth being exact: it cannot clip
 *          the pan because #page-curves is itself a scroll container stretched
 *          to exactly main's content box (flex:1 1 auto in a column flex, so
 *          cross-axis width = main's width). The horizontal overflow is consumed
 *          by #page-curves and never propagates up for main to clip.
 * #app, #page-curves, #curves and .product set no hidden overflow. The remaining
 * overflow:hidden rules in this file are on .who, .pname, .sigstrip, .sig.rel
 * and th/td - descendants or siblings, never ancestors of the grid. */

#page-curves {
  overflow: auto;
  padding: 10px;          /* 8766's .wrap */
}

#curves {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  gap: 18px;              /* 8766's #products - blocks separated by space, not
                             by borders */
  width: max-content;
  /* Fills the scrollport when only a couple of blocks exist, so short content
     still paints edge to edge instead of leaving a dead strip. */
  min-width: 100%;
}

/* Each product is its own vertical block (grid + MOC + custom), sized to its own
   content so blocks line up side-by-side and overflow horizontally rather than
   wrap. width:max-content means the block is exactly as wide as its WIDEST TABLE
   THAT IS ACTUALLY RENDERED, with no slack - which is what makes a collapsed
   column-set, or a custom table with no rows, shrink the block instead of
   reserving the space. */
section.product { margin: 0; flex: 0 0 auto; width: max-content; }

/* width:0 + min-width:100% keeps the header from CONTRIBUTING to the block's
   max-content width (so a long product name, the quote count or the toggles can
   never stretch the block) while still laying out across the full width the
   tables settle on. */
h2.prodname {
  font-size: 13px; margin: 2px 0 5px; font-weight: 700; color: var(--text);
  display: flex; align-items: baseline; gap: 8px;
  border-bottom: 1px solid var(--line2); padding-bottom: 4px;
  width: 0; min-width: 100%;
}
/* The product name must never widen the block: the columns below decide the
   width, so a long name ellipsizes inside it. (min-width:0 lets the flex item
   shrink below its text width.) */
h2.prodname .pname {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
h2.prodname .pcount {
  font-size: 11px; font-weight: 500; color: var(--muted); flex: 0 0 auto;
  margin-left: auto; white-space: nowrap;
}
/* How long since THIS block's payload last changed. Webapp-only: 8766 reads its
   files directly and has nothing to be stale about per block. */
h2.prodname .page {
  font-size: 10px; font-weight: 500; color: var(--faint); flex: 0 0 auto;
  white-space: nowrap;
}

/* COLUMN-SET TOGGLES: one tiny button per column-set, far right of the block
   header. Deliberately quiet - they are chrome, not data - and dimmed with a "+"
   once collapsed, so the header doubles as the indicator of what is hidden. */
h2.prodname .stogs { flex: 0 0 auto; display: flex; gap: 3px; margin-left: 6px; }
button.stog {
  font: inherit; font-size: 9px; font-weight: 600; letter-spacing: .02em;
  font-family: var(--mono);
  color: var(--muted); background: var(--panel); border: 1px solid var(--line2);
  border-radius: 3px; padding: 0 3px; cursor: pointer; line-height: 1.5;
}
button.stog:hover { color: var(--text); border-color: #4d5560; }
button.stog.off { color: #565d66; background: #10141a; }

/* A plain wrapper curves.js emits to group the block's three tables. It must NOT
   scroll: a scrollport here would clip the block's own grid and stop that width
   ever reaching #curves, which is exactly the bug the horizontal-scroll fix was
   for. overflow:visible is stated because the default is load-bearing. */
.blockscroll { overflow: visible; padding: 0; }

h3 {
  font-size: 10px; text-transform: uppercase; letter-spacing: .05em;
  color: var(--muted); margin: 11px 0 3px; font-weight: 600;
}

/* ===== LAYER B STRIP (signals.json) =====================================
   ONE DENSE LINE per product block, between the header and the grid. It adds no
   rows to any table and no columns to any grid - the grid is the market and this
   is commentary on it, so it stays visibly outside.

   Sized like the header (width:0 + min-width:100%) so it can NEVER widen the
   block: a long relation badge ellipsizes inside it rather than stretching the
   grid. Renders nothing at all when signals.py was not running when the block
   was published. */
div.sigstrip {
  width: 0; min-width: 100%; margin: -2px 0 5px;
  display: flex; align-items: center; gap: 5px;
  font-size: 11px; line-height: 1.5; white-space: nowrap; overflow: hidden;
}
/* The whole strip dims when signals.json has gone stale. The numbers stay
   READABLE rather than being blanked: "this was the state two minutes ago and
   the daemon has stopped" is more useful than a gap, which reads as a quiet
   market. */
div.sigstrip.stale { opacity: .45; }

.sig {
  flex: 0 0 auto; border-radius: 3px; padding: 0 4px;
  border: 1px solid transparent; font-variant-numeric: tabular-nums;
}
/* PRESSURE: shaded by SIGN, graded by MAGNITUDE. Three grades rather than a
   continuous ramp - a continuum of tints is not distinguishable at 11px, and
   three steps map onto how the desk talks (a lean, a push, a shove).
   KEEP IN SYNC with PRESS_GRADES in fmt.js. */
.sig.press { font-weight: 700; }
.sig.press.up1 { color: #3fb950; background: rgba(63,185,80,.10); }
.sig.press.up2 { color: #56d364; background: rgba(63,185,80,.20);
                 border-color: #1f6f2b; }
.sig.press.up3 { color: #7ee787; background: rgba(63,185,80,.32);
                 border-color: #2ea043; }
.sig.press.dn1 { color: #ff7b72; background: rgba(248,81,73,.10); }
.sig.press.dn2 { color: #ff8b82; background: rgba(248,81,73,.20);
                 border-color: #8b2b25; }
.sig.press.dn3 { color: #ffa198; background: rgba(248,81,73,.32);
                 border-color: #da3633; }
/* Exactly zero, or no events in the window: no colour at all. A flat market is
   not a weak signal in either direction and must not be tinted like one. */
.sig.press.flat { color: var(--muted); background: var(--panel); }
/* Null - the feed is stale, so signals.py published no score. Struck through,
   because "we do not know" has to look different from "it is zero"; that
   distinction is the whole reason the daemon publishes null instead of 0. */
.sig.press.none { color: var(--muted); background: var(--panel);
                  text-decoration: line-through; }
/* INTENSITY: quiet by default - it is context, not a call - and tinted only once
   the tape is genuinely busier or quieter than usual for this time of day. */
.sig.intens { color: var(--muted); border-color: var(--line2); }
.sig.intens.hot { color: var(--amber); border-color: #6b4d0f; }
.sig.intens.cold { color: var(--faint); border-color: var(--line2); }
/* A PARTIAL bucket - only part way through the 30 minutes, so the ratio is
   against a full-bucket baseline and reads low by construction. Italic and
   parenthesised so it is never mistaken for a settled number. */
.sig.intens.partial { font-style: italic; }
/* THE RELATION BADGE, on one product block only (whichever product signals.json
   names - in practice Gasoil EW). Four states, four tints: inverse is the
   tradeable one (setup #1), aligned is the regime flip the desk explicitly wants
   to see, neutral is quiet, insufficient is honest. */
.sig.rel { margin-left: auto; font-weight: 700; min-width: 0;
           overflow: hidden; text-overflow: ellipsis; }
.sig.rel.inverse { color: #d2a8ff; background: rgba(163,113,247,.18);
                   border-color: #6e40c9; }
.sig.rel.aligned { color: #ffa657; background: rgba(219,109,40,.18);
                   border-color: #9e5218; }
.sig.rel.neutral { color: var(--muted); border-color: var(--line2); }
.sig.rel.insuff  { color: var(--faint); border-color: var(--line); }

/* ===== TABLES ============================================================ */

table { border-collapse: collapse; table-layout: fixed; }
th, td { padding: 2px 4px; border-bottom: 1px solid var(--line);
  font-size: 11px; line-height: 1.4;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
thead th {
  background: var(--panel); color: var(--muted); font-weight: 600;
  text-align: right; font-size: 10px; border-bottom: 1px solid var(--line2);
  position: relative;
}

/* --- COLUMN WIDTHS -------------------------------------------------------
   Every table is table-layout:fixed and carries a zero-height "sizer" row
   (tr.cols) as its FIRST row. Fixed layout reads column widths from the first
   row ONLY, and the grid's visible header is two rows deep (group row + leaf
   row), so the leaf th cells cannot carry the widths themselves - the sizer
   cells do, via the classes below.

   THIS IS ALSO WHAT MAKES THE -outr/-sprd TOGGLES CONTRACT THE BLOCK. curves.js
   renders the sizer cells AND the header/body cells for the visible column-sets
   only, and writes the table's total width inline as the sum of exactly those
   columns. So collapsing a set genuinely narrows the table, and
   section.product's width:max-content pulls the block in behind it - no dead
   blank region, and the blocks to its right slide left.

   Each width is the widest REALISTIC value at 11px monospace plus the 8px of
   cell padding. Keep each table's px width equal to the SUM of its columns, or
   fixed layout redistributes the difference and the columns stop being tight.
   The CW constants in curves.js mirror these numbers - keep them in sync. */
tr.cols th { padding: 0; height: 0; border: 0; font-size: 0; line-height: 0; }
.cw-ten  { width: 58px; }  /* Tenor: Cal27, Q1-27, MOC's Bal (M0) */
.cw-num  { width: 54px; }  /* Bid/Mark/Offer/Last: -38.25, -108.25 */
.cw-spr  { width: 82px; }  /* grid spread label: Cal27/Cal28 */
.cw-str  { width: 62px; }  /* custom Structure: condor */
.cw-ctn  { width: 82px; }  /* custom Tenor: Jul/Aug/Sep */
.cw-side { width: 54px; }  /* two_way */
.cw-time { width: 54px; }  /* 13:08 */

/* The GRID's width is written inline per block by curves.js, because only it
   knows which column-sets that block is rendering right now; the value here is
   the both-sets default for the first paint. */
table.grid   { width: 572px; }  /* 58 + 4*54 + 82 + 4*54 */
table.moc    { width: 274px; }  /* 58 + 4*54 */
table.custom { width: 468px; }  /* 62 + 82 + 4*54 + 54 + 54 */

.grouphdr th {
  text-align: center; text-transform: uppercase; font-size: 10px;
  letter-spacing: .04em; color: var(--text2);
  border-bottom: 1px solid var(--line2);
}
.grouphdr th.og { border-left: 2px solid var(--line2); }
.grouphdr th.ts { border-left: 2px solid var(--line2); }

td.tenor, th.tenor { text-align: left; font-weight: 600; color: var(--text); }
td.spreadlbl { text-align: left; color: var(--muted); font-family: var(--mono); }
td.num { text-align: right; font-family: var(--mono); }
td.sep, th.sep { border-left: 2px solid var(--line2); }
tbody tr:hover { background: var(--panel); }

.bidval   { color: var(--bid); }
.offerval { color: var(--offer); }
.tradeval { color: var(--trade); }
.blank    { color: var(--line2); }

/* MY MARK (from the pricing sheet, via reference.json). Styled apart from the
   green/red/blue broker prices - muted violet, italic, on a slightly inset tint
   - so it can never be mistaken for a live quote. Not tappable either: there are
   no underlying quotes to expand. */
td.markcell { background: #141a24; }
.markval { color: #b9a6e0; font-style: italic; }
/* A DERIVED mark: computed from other marks (a fly from its two component
   spreads, an average from its legs, a non-adjacent spread from the adjacent
   ones) because the sheet carries no value for that structure. Cooler teal and a
   small "d" so it is instantly distinguishable from a level actually typed in. */
.markval.derived { color: #6fb3c0; }
.dtag {
  font-size: 8px; vertical-align: super; margin-left: 1px;
  font-style: normal; opacity: .7;
}
/* Small tag after a row label - currently only "full" on the front-month row
   once its bare quotes are routed to Bal. */
.rownote {
  font-size: 8px; vertical-align: super; margin-left: 2px;
  font-weight: 500; color: var(--muted);
}

td.side { text-transform: uppercase; font-size: 10px; font-weight: 700; }
.side-bid { color: var(--bid); }      .side-offer { color: var(--offer); }
.side-trade { color: var(--trade); }  .side-two_way { color: var(--text2); }
.side-indic { color: var(--indic); }  .side-off { color: var(--muted); }

.empty { color: var(--muted); padding: 4px 2px; font-size: 11px; }
/* The two PAGE-level empty states need more room than a block's one-liner. */
#curves-empty, #page-board .empty { padding: 8px 10px; }

/* A price cell whose underlying quotes can be expanded. :active as well as
   :hover - a phone has no hover, and a tap needs to acknowledge itself. */
td.clickable { cursor: pointer; }
td.clickable:hover { background: #1c2433; }
td.clickable:active { background: #24304a; }

/* THROUGH MY MARK: the market has traded past where I am marked - a bid ABOVE my
   mark (green) or an offer BELOW it (red). Declared BEFORE the crossed rule so
   that on the rare cell that is both, the crossed warning wins (equal
   specificity, later rule applies). */
td.num.throughbid   { background: #10281a; box-shadow: inset 0 0 0 1px #2ea043; }
td.num.throughoffer { background: #33161a; box-shadow: inset 0 0 0 1px #f85149; }
/* crossed market (bid >= offer): FLAGGED only, values are never altered. Amber
   inset border so it stands out without fighting the bid/offer text colours. The
   first crossed cell in a group carries a small warning mark. */
td.num.crossed { background: #3a2d05; box-shadow: inset 0 0 0 1px #e3b341; }
td.num.bidval.crossed::before { content: "\26A0 "; color: #e3b341; }

/* "scr" marker on a cell the SCREEN is currently holding. Small, superscript and
   AMBER - enough to catch the eye without competing with the price, and a
   different colour from every broker/mark tint so it reads as "not a human". Its
   absence is just as meaningful: a bid with no marker is a broker's. */
.scrtag {
  font-size: 8px; vertical-align: super; margin-left: 2px;
  font-weight: 700; color: var(--amber); font-style: normal;
}

/* --- the expand stack ---
   Override the global nowrap/ellipsis so raw broker text can wrap and stay fully
   visible. Note there is no width guard on .stack: table-layout:fixed means cell
   content cannot influence the table's width, so an arbitrarily long raw message
   can no longer stretch the block (which it could before this port, when the
   tables were auto-layout). */
tr.detail td {
  background: #0b0f14; white-space: normal; overflow: visible;
  text-overflow: clip; padding: 5px 6px;
}
.stack { display: flex; flex-direction: column; gap: 4px; }
.qline { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; }
.qprices { white-space: nowrap; font-family: var(--mono); }
.chip { margin-right: 8px; }
.chip .lab { color: var(--muted); }
.chip.bid .val   { color: var(--bid); }
.chip.offer .val { color: var(--offer); }
.chip.trade .val { color: var(--trade); }
.chip.dead .val { text-decoration: line-through; opacity: .6; }
.chip.dead .lab { opacity: .6; }
/* the size behind a screen price, rendered next to the chip (B -68.75 x25) */
.qty { color: var(--muted); font-size: 10px; margin-left: 2px; }

.qside { text-transform: uppercase; font-size: 11px; font-weight: 700;
  white-space: nowrap; }
.qbroker { color: var(--text); white-space: nowrap; }
/* SCREEN lines: the broker column carries "SCREEN" and is tinted amber so it is
   instantly separable from the human names above and below it. */
.qbroker.screen { color: var(--amber); font-weight: 700; letter-spacing: .03em; }
.qmeta { color: var(--muted); font-size: 11px; white-space: nowrap; }
.qraw { color: var(--text2); white-space: pre-wrap; font-family: var(--mono); }

/* Every quote stays LISTED for history; its age is marked so it is clear how it
   relates to the live cell. "expired" (> HARD_MINUTES) is dimmed - it never
   feeds a cell. "aging" (SOFT..HARD) gets a light tag - it shows only until
   something newer prints. */
.qline.expired { opacity: .4; }
.qline.aging { opacity: .8; }

.qtag {
  text-transform: uppercase; font-size: 10px; font-weight: 700;
  letter-spacing: .04em; border-radius: 4px; padding: 0 5px;
  white-space: nowrap;
}
.qtag.aging { color: var(--amber); border: 1px solid #6b4d0f; }
.qtag.expired { color: var(--muted); border: 1px solid var(--line2); }
/* sign/scale corrected against my sheet mark; the raw text alongside is still
   exactly what the broker typed */
.qtag.fixed { color: #b9a6e0; border: 1px solid #4a3f6b; }
/* this broker's own later quote CROSSED OR LOCKED this side, so it is dead and
   no longer feeds the cell */
.qtag.retired { color: #ff9e64; border: 1px solid #6b3f1f; }
/* THE TWO CROSS-STALENESS RULES. Same struck-through chip as "retired" (the side
   is dead the same way), but each gets its own tint and wording so which rule
   fired is readable at a glance:
     scrcross  - a LIVE screen level is through this side and the quote is older
                 than SCREEN_CROSS_MINUTES. Cyan, matching the screen's own
                 colour elsewhere - the screen killed it.
     agedcross - another BROKER is crossed with this side and this is the older
                 leg, past BROKER_CROSS_MINUTES. Amber-brown, next door to the
                 crossed flag it replaces. */
.qtag.scrcross { color: #56d4dd; border: 1px solid #1f5b60; }
.qtag.agedcross { color: var(--amber); border: 1px solid #6b4d0f; }
/* informational note about how a quote was ROUTED - currently only a BalND-tenor
   MOC shown in the Bal (M0) cell. Blue, because nothing is wrong with the quote
   and no value was altered. */
.qtag.note { color: #58a6ff; border: 1px solid #1f4b7a; }
/* an OTC / block print - away from the screen, informational only */
.qtag.block { color: #d2a8ff; border: 1px solid #4a3f6b; }
/* the feed itself is stale; this line cannot populate a cell */
.qtag.screenstale { color: var(--amber); border: 1px solid #6b4d0f; }

/* ===================================================================
   BOARD - webapp-specific page (the desk's board is board.py, a separate page).
   Kept on the same type scale and palette as the ported curves grid.
   =================================================================== */

.filters {
  display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap;
  padding: 0 10px;
}
.f { display: flex; flex-direction: column; gap: 3px; flex: 1 1 40%; }
.f span { color: var(--muted); font-size: 10px; }
.f select {
  background: var(--panel2); border: 1px solid var(--line2); border-radius: 4px;
  padding: 7px 6px; font-size: 11px; width: 100%;
}
#board-note { padding: 2px 10px; color: var(--faint); }

.q { border-top: 1px solid var(--line); padding: 5px 10px; }
.q:hover { background: var(--panel); }
.q:active { background: #1c2433; }
.q.open { background: var(--panel2); }
.qtop {
  display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
  font-size: 11px;
}
.qt  { color: var(--muted); font-size: 11px; min-width: 40px;
       font-family: var(--mono); }
.qb  { color: var(--text); }
.qp  { color: var(--muted); }
.qtn { color: var(--text); font-weight: 600; }
.qst {
  color: #d2a8ff; font-size: 10px; font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; border: 1px solid #4a3f6b; border-radius: 4px;
  padding: 0 5px;
}
.qv  { margin-left: auto; font-family: var(--mono); font-weight: 700; }
.qs  { text-transform: uppercase; font-size: 10px; font-weight: 700; }
.qsz { color: var(--muted); font-size: 10px; font-family: var(--mono); }

.qdet { padding: 5px 0 2px; }
.qfoot { color: var(--faint); font-size: 10px; margin-top: 3px;
         font-family: var(--mono); }

.more { padding: 12px 10px max(24px, env(safe-area-inset-bottom)); }

/* ===================================================================
   SCROLLBARS. 8766 notes an "always-visible scrollbar" for the block region, and
   it matters here beyond taste: a horizontal scrollbar is the only affordance
   that says "there is more grid to the right". On macOS and iOS the overlay
   scrollbar fades when idle, so a first look shows a clipped grid and no hint
   that it pans.
   =================================================================== */
#page-curves {
  scrollbar-width: thin;
  scrollbar-color: var(--line2) var(--panel2);
}
#page-curves::-webkit-scrollbar { width: 11px; height: 11px; }
#page-curves::-webkit-scrollbar-track { background: var(--panel2); }
#page-curves::-webkit-scrollbar-thumb {
  background: var(--line2); border-radius: 6px;
  border: 2px solid var(--panel2);
}
#page-curves::-webkit-scrollbar-thumb:hover { background: #4d5560; }
#page-curves::-webkit-scrollbar-corner { background: var(--panel2); }

/* ===================================================================
   WIDER SCREENS. The phone layout is already the right shape - one row of blocks
   panned sideways, exactly like 8766 - so there is deliberately no #curves or
   .product override here: the block row behaves identically at every width and a
   PC simply sees more of it before scrolling. Only the webapp's own form
   controls get roomier.
   =================================================================== */
@media (min-width: 720px) {
  .filters { max-width: 640px; }
  .f { flex: 0 0 200px; }
}
