/* Palette extracted from healthcaredatahub.ai's live brand site: a
   monochrome black/white/gray base with a single vivid blue accent
   (rgb(0,153,255), used 100+ times site-wide). The site also layers a soft
   white-to-pale-blue vertical gradient behind its hero content
   (`background:linear-gradient(180deg, white 55%, rgba(207,226,252,.85) 100%)`,
   confirmed in the page source) -- --bg-gradient-end reproduces that. */
:root{
  --ink:#1A1A1A; --ink-soft:#605F5F; --ink-faint:#767676;
  --panel:#FFFFFF; --cream:#F4F4F4; --line:#EEEEEE; --line-strong:#DADADA;
  --blue:#0099FF; --blue-text:#0073BF; --blue-bg:#F1F7FF; --bg-gradient-end:#CFE2FC;
  /* The darkest step of the same blue, for INK on the mid tint above -- #0073BF
     on #CFE2FC is ~4.2:1, and the badges that need it are 10.5px bold, which
     WCAG does not count as large text. Not a new hue: same ramp, one stop down. */
  --blue-deep:#08447A;
  /* There is deliberately no second CHART hue. A chart's series come from
     charts.js's ramp(), a scale anchored on --blue, so every axis in the panel
     is rendered in the same language; see seriesColour()'s comment for why the
     --series-2 vermilion that used to live here was a mistake. --good/--warn/
     --bad below are status ink for badges and never reach a mark. */
  --good:#1E8E5A; --good-bg:#E3F5EC; --warn:#B4790D; --warn-bg:#FBF0DD; --bad:#C23B3B; --bad-bg:#FBEAEA;
  --disp:'Manrope',sans-serif; --body:'Inter',sans-serif; --mono:'JetBrains Mono',monospace;
  /* Mira's mark: the three circle fills, sampled from the supplied artwork.
     Separate from --blue because they are a fixed logo palette rather than the
     UI's accent ramp -- --blue drives every chart series and control, and a
     logo must not drift when that is tuned. Duplicated in
     frontend/assets/mira-mark.svg, which cannot read a custom property: that
     file is the static mark, these tokens are the animated avatar that settles
     onto the same pose. */
  --mira-1:#4397f7; --mira-2:#6bb6f9; --mira-3:#4478a9;
  /* The artifact panel's scrollbar width, measured once by app.js. It exists so
     .ahead -- which sits OUTSIDE the scrollport -- can inset its right edge by
     the same amount .abody's scrollbar insets the cards, and the header button
     can line up with the tiles under it. There is no CSS function for this, and
     0px is the correct default: on a platform with overlay scrollbars nothing
     is inset and nothing needs compensating. */
  --sbw:0px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
/* Visible keyboard-focus ring on every interactive element -- none of the
   custom button/tab/option styles below set their own focus treatment, so
   without this, keyboard nav either shows the browser's default (often
   invisible against a colored/borderless custom control) or nothing. */
button:focus-visible, input:focus-visible, summary:focus-visible, [tabindex]:focus-visible{
  outline:2px solid var(--blue); outline-offset:2px;
}
/* Read by assistive tech, invisible on screen. Not display:none, which would
   remove it from the accessibility tree along with the pixels. */
.sr-only{position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden;
  clip:rect(0 0 0 0); white-space:nowrap; border:0}
/* A flex row so #sidebar can push #main aside rather than overlay it -- the
   chat and artifact panes genuinely shrink, so nothing is ever covered. */
body{font-family:var(--body); color:var(--ink); height:100%; overflow:hidden; position:relative;
  display:flex;
  background:linear-gradient(180deg, var(--panel) 55%, var(--bg-gradient-end) 100%); background-attachment:fixed}
/* min-width:0 so the chat/artifact grid inside can actually shrink -- a flex
   item's default min-width:auto would let #work's content set a floor and push
   the sidebar off-screen instead. */
#main{flex:1; min-width:0; height:100%}
/* Dot-grid texture confirmed in the brand site's source (a tiled SVG,
   data-framer-name="BG Pattern": 4px dots on a 48px grid, rendered at 17px,
   opacity 0.6, masked fully visible at the bottom and fading out toward the
   top -- `mask:linear-gradient(0deg, black 0%, transparent 43%)`, and 0deg
   points bottom-to-top so 0% sits at the bottom). Recreated in pure CSS
   (radial-gradient dot repeat) instead of shipping a separate SVG asset --
   an isolated ::before layer so the mask affects only the texture, not the
   real gradient or content above it. */
body::before{
  content:""; position:fixed; inset:0; z-index:-1; pointer-events:none;
  background-image:radial-gradient(circle, var(--line-strong) 1px, transparent 1px);
  background-size:16px 16px;
  opacity:.6;
  -webkit-mask-image:linear-gradient(0deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 45%);
  mask-image:linear-gradient(0deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 45%);
}

/* ---------- Sidebar (wired in frontend/app.js) ---------- */
/* Visibility is a body class, the same idiom as .working and .redivis-on
   below -- not JS-built DOM. display:none rather than a transform
   so the panel takes no space at all when closed: the agreed behaviour is
   "fully hidden", with no icon rail left behind. */
#sidebar{display:none; flex:none; width:264px; flex-direction:column; min-height:0;
  background:var(--cream); border-right:1px solid var(--line-strong)}
body.sidebar-open #sidebar{display:flex}
/* Both openers disappear once the panel is up -- the logo is showing inside it
   and closing is the panel's own job. */
body.sidebar-open .sb-open{display:none}

/* Matches #lhead's padding and logo height exactly, so the logo lands in the
   same place it left and does not jump as the panel opens. */
.sb-head{flex:none; display:flex; align-items:center; gap:10px; padding:14px 20px; border-bottom:1px solid var(--line)}
.sb-head .spacer{flex:1}
.sb-logo{border:0; background:transparent; padding:0; cursor:pointer; display:flex; align-items:center}
.sb-icon{border:0; background:transparent; color:var(--ink-faint); cursor:pointer;
  font-size:15px; line-height:1; padding:5px; border-radius:7px}
.sb-icon:hover{background:var(--panel); color:var(--ink)}

/* Rows share the .hp-row/.rv-row recipe used by the account panel below. */
.sb-nav{flex:none; padding:9px 7px; display:flex; flex-direction:column; gap:1px}
.sb-item{display:flex; align-items:center; gap:11px; width:100%; text-align:left; border:0; background:transparent;
  border-radius:9px; padding:9px 8px; cursor:pointer; font-family:var(--body); font-size:13.5px; color:var(--ink)}
.sb-item:hover{background:var(--panel)}
.sb-item:disabled{color:var(--ink-faint); cursor:default}
.sb-item:disabled:hover{background:transparent}
.sb-ico{flex:none; width:16px; text-align:center; font-size:13px; color:var(--ink-faint)}
.sb-item:hover:not(:disabled) .sb-ico{color:var(--ink)}

/* The only scrolling region -- head, nav and foot are all flex:none, so a long
   history scrolls without pushing the account control off the bottom. */
.sb-history{flex:1; min-height:0; overflow-y:auto; border-top:1px solid var(--line)}

.sb-foot{flex:none; border-top:1px solid var(--line); padding:7px}
/* flex, not block: the button now carries the Redivis mark alongside its label. */
.sb-signin{display:flex; align-items:center; justify-content:center; gap:8px; width:100%; cursor:pointer;
  border:1px solid var(--line-strong); background:var(--panel); color:var(--ink);
  border-radius:20px; padding:8px 13px; font-family:var(--body); font-size:12.5px}
.sb-signin:hover{border-color:var(--ink)}
/* A sign-in that dies after the user has already clicked "Allow" looks exactly
   like one never attempted, so the failure has to be visible on the control
   itself -- the full reason is the title, the step-by-step trace is the console. */
.sb-signin.rv-failed{color:var(--bad); border-color:var(--bad); background:var(--bad-bg)}
.rv-error{margin-top:8px; font-size:11.5px; line-height:1.35; color:var(--bad); word-break:break-word}
.rv-error[hidden]{display:none}

/* The Healthcare Datahub mark, on the controls that lead to Healthcare Datahub.
   It was REDIVIS's mark, which is the product this platform is hosted on rather
   than the product the user is signing in to -- so a button reading "Sign in
   with Healthcare Datahub" carried another company's logo. Fixed height with
   width:auto, because the source is 138x124 and a width cap would set the height
   by accident. */
/* The brand lockup: Mira's mark plus her name. Replaces the single
   assets/logo.webp wordmark that used to sit at all four of these spots.

   The name is REAL TEXT, not baked into the SVG: it stays selectable and
   accessible, scales without a second asset, and needs no font embedding --
   var(--disp) is Manrope, already loaded by index.html for every heading in
   the UI, and 800 is the weight the old raster wordmark was set in.

   Sized by the MARK's height with width:auto, the same convention the wordmark
   used, so a variant is one height and the name follows from it. */
.brand{display:flex; align-items:center; gap:8px; line-height:1}
.brand-mark{width:auto; display:block; flex:none}
.brand-name{font-family:var(--disp); font-weight:800; letter-spacing:-0.02em; color:var(--ink)}

#landing .brand{margin-bottom:34px}
#landing .brand-mark{height:40px}
#landing .brand-name{font-size:30px}

.sb-head .brand-mark,#lhead .brand-mark{height:22px}
.sb-head .brand-name,#lhead .brand-name{font-size:16px}

.dh-logo{height:14px; width:auto; flex:none; display:block}
.sb-signin .dh-logo{height:15px}

/* The whole row is the trigger, not just the avatar circle. */
.sb-profile{display:flex; align-items:center; gap:10px; width:100%; text-align:left; border:0; background:transparent;
  border-radius:9px; padding:7px 8px; cursor:pointer; font-family:var(--body)}
.sb-profile:hover{background:var(--panel)}
.sb-profile .rv-label{flex:1; font-size:13px; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}

/* The openers. On #landing this floats top-left over the centred hero, which has
   no header bar to sit in; in #lhead it is an ordinary header child. */
.sb-open{border:0; background:transparent; cursor:pointer; color:var(--ink-soft);
  padding:5px; border-radius:7px; display:flex; align-items:center; line-height:1}
.sb-open:hover{background:var(--cream); color:var(--ink)}
.sb-open-landing{position:absolute; top:14px; left:16px; font-size:17px}
/* Negative margin so the logo's optical left edge still lines up with the
   thread below, despite the button's own padding. */
.sb-open-head{margin-left:-5px}

/* ---------- Landing ---------- */
/* position:relative so .sb-open-landing anchors here rather than falling through
   to body (which is also positioned, so this is explicitness, not a fix). */
/* A grid of three PLACED rows -- hero, tiles, copyright -- separated by two
   empty fr spacers, plus a third above the hero. The 2fr/1fr/1fr weights are the
   layout: the free space lands half above the hero (exactly what the flex
   `margin:auto 0` this replaced gave it, so the hero did not move) and a quarter
   either side of the tiles, which is what centres them between the chips and the
   copyright. A flex column could not do that -- auto margins share free space
   EQUALLY, so the only way to weight it was flex-grow, which zeroes the very
   auto margins that were centring the hero. Empty fr rows have a 0 minimum, so a
   short viewport collapses them and clips at the bottom exactly as before
   (body{overflow:hidden}) rather than overflowing somewhere new. */
#landing{position:relative; height:100%; display:grid;
  grid-template-rows:2fr auto 1fr auto 1fr auto; justify-items:center;
  padding:24px; text-align:center; transition:opacity .4s}
.lhero{grid-row:2; display:flex; flex-direction:column; align-items:center; width:100%}
/* Fluid rather than a flat 38px: the tail below reserves the widest phrase's
   width, so unlike the wrapping heading this replaced, the hero cannot get
   narrower than "Explore Research Feasibility" on one line. At 38px that is
   ~500px, which overflows a phone. The vw term keeps it inside the viewport
   (measured: no horizontal scroll at 390px) and the 38px cap keeps the desktop
   size exactly as it was. */
#landing h1{font-family:var(--disp); font-weight:600; font-size:clamp(20px, 6.4vw, 38px); letter-spacing:-0.02em; line-height:1.1; max-width:640px; min-height:1.1em}
/* The typed tail grows a character at a time inside a centred flex column, so
   without a reserved width the whole line -- "Explore" included -- would
   re-centre on every keystroke. app.js measures the widest phrase once and
   writes min-width here; text-align:left keeps the tail growing rightwards. */
.hero-type{display:inline-block; text-align:left}
/* The caret is ::after rather than a sibling span so it sits immediately after
   the last typed character. A sibling would be pushed to the far edge of the
   reserved min-width above, leaving it stranded a phrase-width from the text. */
.hero-type::after{content:""; display:inline-block; width:3px; height:0.72em; vertical-align:-0.02em;
  background:var(--blue); margin-left:3px; animation:caret 1s steps(2,start) infinite}
@keyframes caret{to{visibility:hidden}}
@media (prefers-reduced-motion:reduce){.hero-type::after{animation:none}}
/* The cap sits on the input box rather than on .composer, so the chip row below
   can be wider than the box it belongs to: at 620px each catalog-built chip
   ("Explore INSPIRE Dataset for Perioperative Medicine") took a row of its own
   and three chips stacked three deep. The box itself is unchanged. */
#landing .composer{margin-top:34px; width:100%; max-width:960px}
#landing .compbox{max-width:620px; margin:0 auto}
.compbox{display:flex; align-items:center; gap:10px; background:var(--panel); border:1px solid var(--line-strong); border-radius:18px; padding:9px 9px 9px 20px; box-shadow:0 8px 30px rgba(40,60,90,.10)}
.compbox:focus-within{border-color:var(--ink); box-shadow:0 8px 34px rgba(40,60,90,.16)}
.compbox input{flex:1; border:0; outline:0; background:transparent; font-family:var(--body); font-size:16px; color:var(--ink); padding:8px 0}
.compbox input::placeholder{color:var(--ink-faint)}
.compbox input:disabled{opacity:.6}
.sendbtn{border:0; background:var(--ink); color:#fff; border-radius:13px; width:44px; height:44px; font-size:18px; cursor:pointer; flex:none; display:flex; align-items:center; justify-content:center}
.sendbtn:hover{opacity:.9}
.sendbtn:disabled{opacity:.4; cursor:not-allowed}
/* min-height is the chips' own height: the row is empty until app.js has the
   catalog it builds them from, and without it the composer above would shift up
   and back down as they arrive. TWO rows' worth (36 + the 9px gap + 36), because
   with the half-row cap below and three chips that is the steady state; the
   catalog-unreachable fallback, whose three generic chips fit on one row, is the
   one case that reserves more than it uses. */
/* align-content, not align-items: the reserved height above is free space in the
   container, and the default `stretch` hands it to the LINES -- so on the
   one-row fallback every chip grew to 81px and the pills read as boxes. Packing
   the lines to the start leaves the items stretching within their own line,
   which is what keeps a wrapped two-line chip level with its neighbour. */
#landing .egs{display:flex; gap:9px; justify-content:center; align-content:flex-start; flex-wrap:wrap; margin:18px auto 0; max-width:960px; min-height:81px}
/* max-width is the GUARANTEE that the block never exceeds two rows: at most two
   chips fit per row and app.js always renders exactly three (CHIP_SLOTS), so a
   longer display_name -- uncapped, unlike population_label -- can only make a
   chip taller, never add a third row. Text wraps inside the pill rather than
   ellipsising: the chip sends its full text as a real turn, so hiding half of it
   would be showing the user something other than what they are about to ask. */
#landing .egs button{max-width:calc(50% - 5px); border:1px solid var(--line-strong); background:var(--panel); color:var(--ink); border-radius:22px; padding:8px 15px; cursor:pointer; font-family:var(--body); font-size:13px; line-height:1.35; text-align:center}
#landing .egs button:hover{background:var(--panel); border-color:var(--ink)}

/* The shortcut tiles, then the copyright on the page's own bottom edge. Both sit
   in the dot-grid region body::before unmasks down here, which is why the tiles'
   plates are opaque --panel rather than transparent. No margin-bottom on the
   tiles: the gap below them is #landing's row 5, which has to match row 3 above
   them for the centring to hold, and a fixed margin would break that tie. */
.ltiles{grid-row:4; display:flex; justify-content:center; align-items:flex-start; flex-wrap:wrap; gap:12px 26px}
/* Borderless: the plate is the affordance, the label sits under it. The width is
   set by the longest label -- "Research Feasibility" on one line -- so every
   label shares a baseline instead of one of them wrapping and dropping. Four
   tiles are 710px including the gaps, narrower than the chip row above them, so
   the row still reads as one line; below that .ltiles wraps rather than
   shrinking a tile, which is what keeps that shared baseline on a phone. */
.ltile{display:flex; flex-direction:column; align-items:center; gap:10px; width:158px;
  border:0; background:transparent; cursor:pointer; padding:6px 4px; border-radius:12px;
  font-family:var(--body); font-size:13px; font-weight:600; color:var(--ink); line-height:1.25}
.ltile-ico{display:flex; align-items:center; justify-content:center; width:52px; height:52px;
  background:var(--panel); border:1px solid var(--line); border-radius:14px;
  box-shadow:0 2px 8px rgba(40,60,90,.06); transition:border-color .12s}
.ltile:hover .ltile-ico{border-color:var(--ink)}
/* Stroked and sized here, not on the markup -- the same currentColor convention
   as .rec-fact svg. --blue is the accent already in :root; no new token. */
.ltile svg{width:22px; height:22px; color:var(--blue);
  fill:none; stroke:currentColor; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round}
/* No divider above it: the tiles and the copyright are already separated by the
   gap, and a rule between them read as a section border on an otherwise
   line-free screen. The padding keeps that gap without drawing anything. */
.hint{grid-row:6; font-size:12px; color:var(--ink-faint); max-width:840px; margin:0 auto; padding-top:16px}

/* ---------- Working split ---------- */
/* 40% chat / 60% artifact -- the same ratio resize.js's DEFAULT_LEFT_RATIO
   applies inline. This rule is what holds before that script has written a
   width, and the floor matches its MIN_LEFT. */
#work{height:100%; display:none; grid-template-columns:minmax(320px,40%) 6px 1fr}
/* No artifact content yet -- collapse the resizer + right pane entirely so
   the chat takes the full width instead of showing an empty panel. Toggled
   by resize.js's window.setArtifactPaneVisible(), called from artifact.js's
   render(). !important beats the inline grid-template-columns resize.js
   writes while dragging/restoring. */
#work.no-artifact{grid-template-columns:1fr !important}
#work.no-artifact #resizer, #work.no-artifact #right{display:none}
body.working #landing{display:none}
body.working #work{display:grid}

/* draggable divider -- lets the user resize the artifact panel; width is
   set inline via resize.js and persisted to localStorage */
#resizer{cursor:col-resize; position:relative; touch-action:none}
#resizer::after{content:""; position:absolute; top:0; bottom:0; left:2px; width:2px; background:var(--line-strong); border-radius:2px; transition:background .1s}
#resizer:hover::after, #resizer.dragging::after{background:var(--blue); width:3px; left:1.5px}

/* left: conversation */
#left{display:flex; flex-direction:column; min-height:0}
#lhead{flex:none; display:flex; align-items:center; gap:10px; padding:14px 20px; border-bottom:1px solid var(--line)}
#lhead .spacer{flex:1}
#lhead .session-dot{width:7px;height:7px;border-radius:50%;background:var(--ink-faint)}
#lhead .session-dot.ok{background:var(--good)}
#lhead .session-dot.err{background:var(--bad)}
#thread{flex:1; overflow-y:auto; padding:22px 20px}
.tinner{max-width:560px; margin:0 auto}
.msg{margin-bottom:18px; display:flex; gap:11px; align-items:flex-start}
.msg.user{justify-content:flex-end}
.msg.user .b{background:var(--ink); color:#fff; border-radius:16px 16px 5px 16px; padding:11px 15px; font-size:14.5px; max-width:82%; white-space:pre-wrap}
/* THE AGENT'S AVATAR IS MIRA'S MARK, and while she is working it orbits.

   Was a single --blue disc with a pulsing ring. Three circles need three
   elements (there are only two pseudo-elements), so every .av in app.js emits
   three <i> children -- see the note above botBubbleEl.

   The geometry below is MEASURED, not eyeballed: the supplied recording
   decomposes into three circles of constant radius on a 1.9s loop, and the
   keyframes are that loop sampled at 10fps, rotated so it BEGINS AND ENDS on
   the logo pose. That rotation is the whole trick behind "stop at the logo" --
   the orbit passes through the resting mark once per cycle, so settling onto
   it is a continuation rather than a cut. Percentages throughout, so the mark
   is resolution- and size-independent; assets/mira-mark.svg draws the same
   rest pose from the same numbers.

   left/top rather than transform: translate() resolves against the ELEMENT's
   own box, and each circle is a different size, so three circles would need
   three different scale factors to express one shared path. left/top resolve
   against the AVATAR, which is the frame the path was measured in. Three
   26px children is not a layout cost worth optimising.

   The circles are opaque and deliberately stacked. The recording shows a
   lighter tint where two overlap mid-orbit, which no CSS blend mode
   reproduces on a white ground (screen blows out to white, multiply darkens).
   Solid fills are the honest simplification: they are EXACT at rest, which is
   the pose that has to match the logo, and only approximate in passing. */
.msg.bot .av{width:26px; height:26px; flex:none; margin-top:2px; position:relative}
.av i{position:absolute; border-radius:50%; display:block}
.av i:nth-child(1){width:47.8%; height:47.8%; left:35.7%; top:4.3%; background:var(--mira-1)}
.av i:nth-child(2){width:42.8%; height:42.8%; left:46.9%; top:53.7%; background:var(--mira-2)}
.av i:nth-child(3){width:30.4%; height:30.4%; left:10.8%; top:46.9%; background:var(--mira-3)}

/* Working: the full orbit. Linear because the path's own easing is already
   baked into the sampled frames -- a timing function on top would double it. */
.msg.thinking .av i{animation:mira-orbit-1 1.9s linear infinite}
.msg.thinking .av i:nth-child(2){animation-name:mira-orbit-2}
.msg.thinking .av i:nth-child(3){animation-name:mira-orbit-3}

/* Answering: the last few frames of that same path, eased to a stop.

   It runs on the ARRIVING bubble, not the thinking line, because the thinking
   line is REMOVED the instant the first token streams (status.end(TURN_KEY) ->
   renderStatus(null) -> unmountStatusLine), leaving no element to come to rest.
   Both avatars are 26px at the same x in the same flex row, so the handover is
   positionally seamless and this reads as the orbit decelerating into the
   mark. */
.msg.bot:not(.thinking) .av i{animation:mira-settle-1 .32s cubic-bezier(.22,1,.36,1) both}
.msg.bot:not(.thinking) .av i:nth-child(2){animation-name:mira-settle-2}
.msg.bot:not(.thinking) .av i:nth-child(3){animation-name:mira-settle-3}

/* Reduced motion gets the static mark -- never a frozen mid-orbit pose, which
   is why the rules above are dropped entirely rather than paused. The nth-child
   rules above set the rest position, so removing the animation lands exactly on
   the logo. */
@media (prefers-reduced-motion:reduce){
  .msg.thinking .av i,.msg.bot:not(.thinking) .av i{animation:none}
}

@keyframes mira-orbit-1{
  0%{left:35.7%; top:4.3%}
  5.26%{left:38.0%; top:2.0%}
  10.53%{left:37.2%; top:3.2%}
  15.79%{left:36.0%; top:5.1%}
  21.05%{left:33.7%; top:9.1%}
  26.32%{left:28.9%; top:17.8%}
  31.58%{left:18.5%; top:34.0%}
  36.84%{left:14.0%; top:41.4%}
  42.11%{left:11.2%; top:45.8%}
  47.37%{left:9.5%; top:48.6%}
  52.63%{left:9.0%; top:49.4%}
  57.89%{left:7.9%; top:50.8%}
  63.16%{left:6.6%; top:50.1%}
  68.42%{left:5.0%; top:48.2%}
  73.68%{left:4.5%; top:44.1%}
  78.95%{left:9.0%; top:35.9%}
  84.21%{left:22.6%; top:19.6%}
  89.47%{left:29.9%; top:10.0%}
  94.74%{left:33.2%; top:6.4%}
  100%{left:35.7%; top:4.3%}
}

@keyframes mira-orbit-2{
  0%{left:46.9%; top:53.7%}
  5.26%{left:47.4%; top:55.4%}
  10.53%{left:45.9%; top:55.1%}
  15.79%{left:43.4%; top:54.1%}
  21.05%{left:39.8%; top:52.9%}
  26.32%{left:36.6%; top:53.8%}
  31.58%{left:22.5%; top:12.8%}
  36.84%{left:25.4%; top:11.3%}
  42.11%{left:27.0%; top:9.8%}
  47.37%{left:28.5%; top:7.4%}
  52.63%{left:29.1%; top:6.6%}
  57.89%{left:30.1%; top:5.2%}
  63.16%{left:30.7%; top:6.2%}
  68.42%{left:31.2%; top:8.4%}
  73.68%{left:32.6%; top:11.6%}
  78.95%{left:37.8%; top:15.6%}
  84.21%{left:48.7%; top:41.9%}
  89.47%{left:45.4%; top:48.5%}
  94.74%{left:45.8%; top:50.5%}
  100%{left:46.9%; top:53.7%}
}

@keyframes mira-orbit-3{
  0%{left:10.8%; top:46.9%}
  5.26%{left:7.9%; top:45.6%}
  10.53%{left:9.3%; top:45.7%}
  15.79%{left:11.6%; top:45.8%}
  21.05%{left:14.8%; top:45.7%}
  26.32%{left:14.2%; top:45.6%}
  31.58%{left:52.6%; top:49.5%}
  36.84%{left:55.9%; top:47.9%}
  42.11%{left:58.6%; top:48.2%}
  47.37%{left:62.1%; top:48.3%}
  52.63%{left:62.9%; top:48.5%}
  57.89%{left:64.6%; top:48.7%}
  63.16%{left:64.2%; top:50.1%}
  68.42%{left:62.6%; top:52.0%}
  73.68%{left:59.1%; top:53.8%}
  78.95%{left:51.2%; top:56.2%}
  84.21%{left:22.3%; top:56.1%}
  89.47%{left:16.8%; top:48.7%}
  94.74%{left:13.6%; top:47.3%}
  100%{left:10.8%; top:46.9%}
}

@keyframes mira-settle-1{
  0%{left:22.6%; top:19.6%}
  33.33%{left:29.9%; top:10.0%}
  66.67%{left:33.2%; top:6.4%}
  100%{left:35.7%; top:4.3%}
}

@keyframes mira-settle-2{
  0%{left:48.7%; top:41.9%}
  33.33%{left:45.4%; top:48.5%}
  66.67%{left:45.8%; top:50.5%}
  100%{left:46.9%; top:53.7%}
}

@keyframes mira-settle-3{
  0%{left:22.3%; top:56.1%}
  33.33%{left:16.8%; top:48.7%}
  66.67%{left:13.6%; top:47.3%}
  100%{left:10.8%; top:46.9%}
}
.msg.bot .b{font-size:14.5px; line-height:1.55; padding-top:2px}
.msg.bot .b b{font-weight:600}
.msg.bot .b ul{margin:4px 0 4px 20px}
.msg.bot .b li{margin-bottom:3px}
/* ONE AGENT MESSAGE PER TURN: a later writer in the same turn adds a section to
   the turn's bubble (app.js turnBubble) rather than a second bubble. */
.msg.bot .b > .content:empty{display:none}
.msg.bot .b > .content + .content,.msg.bot .b > .note-action + .content{margin-top:14px}
.msg.system .b{font-size:12.5px; color:var(--ink-faint); font-style:italic; background:transparent; padding:2px 0}
.msg.error .b{font-size:13px; color:var(--bad); background:var(--bad-bg); border-radius:10px; padding:9px 12px}
.msg.bot.streaming .b::after{content:"▍"; animation:blink 1s steps(1) infinite; color:var(--blue)}
@keyframes blink{50%{opacity:0}}
@media (prefers-reduced-motion:reduce){.msg.bot.streaming .b::after{animation:none}}
/* A note's control, held until the text introducing it has typed (typewriter.js). */
.typing-hold{display:none !important}
.msg.thinking .b{color:var(--ink-faint); font-style:italic}

/* A note that carries somewhere to go -- today, the dataset's own page on
   Healthcare Datahub, offered with the introduction that opens it.

   Set off from the sentences above it by a rule and its own type size, because
   it is a different kind of thing: everything above is what the agent said, and
   this is a place to go. The caption is what the panel's buttons never had --
   user testing found people did not know where "Open in Healthcare Datahub"
   led, and a bare button here would have repeated that in a new location.

   align-items:flex-start so the pill is its own width rather than the bubble's;
   the sign-in control is deliberately the exception, since `.sb-signin` is
   width:100% wherever it appears and matching the lock card is what makes it
   recognisable as the same control. */
.note-action{margin-top:12px; padding-top:12px; border-top:1px solid var(--line);
  display:flex; flex-direction:column; align-items:flex-start; gap:9px}
.note-action:empty{display:none}
.note-action-caption{font-size:13px; line-height:1.5; color:var(--ink-soft)}
.note-action .sb-signin{align-self:stretch}
/* The catalog hand-off wears the sign-in control's shape; as an <a> it needs
   the underline and box model a <button> does not carry. */
.note-open{box-sizing:border-box; text-decoration:none}
/* THE LINKS A CHAT MESSAGE REFERRED TO, as labelled chips rather than as raw
   urls in the prose. Outline, never the solid pill: that one is reserved for
   LEAVING for Healthcare Datahub, and these go to a funder's own page. */
.note-links{display:flex; flex-wrap:wrap; gap:8px}
/* One block of a sectioned note, each carrying the link it refers to. The gap
   is what separates an entry from its own chip; the margin is what separates
   one entry from the next, so the chip reads as belonging ABOVE it rather than
   floating between two. */
.note-section{display:flex; flex-direction:column; gap:9px}
.note-section + .note-section{margin-top:16px}
.note-section .note-links{margin-bottom:2px}
.note-link{display:inline-flex; align-items:center; gap:6px; max-width:100%; text-decoration:none;
  border:1px solid var(--line-strong); border-radius:9px; padding:6px 11px; background:var(--panel);
  font-family:var(--body); font-size:12.5px; font-weight:600; color:var(--blue-text)}
.note-link:hover{border-color:var(--blue); background:var(--blue-bg)}
.note-link svg{flex:none; width:13px; height:13px; fill:none; stroke:currentColor; stroke-width:1.5;
  stroke-linecap:round; stroke-linejoin:round}

/* ---------- Chat history (frontend/history.js) ---------- */
/* Docked in the sidebar rather than a floating popover -- #sidebar carries the
   scroll and the sizing (see the sidebar section above), so these rules cover
   only the rows themselves. */
.sb-history .hp-head{font-family:var(--disp); font-weight:600; font-size:12px; letter-spacing:.04em; text-transform:uppercase;
  color:var(--ink-faint); padding:12px 15px 8px}
.sb-history .hp-list{padding:0 7px 8px}
.sb-history .hp-empty{font-size:13px; color:var(--ink-faint); padding:8px 8px 14px}
.sb-history .hp-error{color:#a33}
.sb-history .hp-retry{margin:0 8px 10px; border:0; background:none; color:var(--blue-text); font:600 12px Inter,sans-serif; cursor:pointer; padding:0}
.sb-history .hp-row{display:flex; align-items:baseline; gap:10px; width:100%; text-align:left; border:0; background:transparent;
  border-radius:9px; padding:9px 8px; cursor:pointer; font-family:var(--body)}
.sb-history .hp-row:hover{background:var(--panel)}
.sb-history .hp-title{flex:1; font-size:13.5px; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
.sb-history .hp-when{flex:none; font-size:11.5px; color:var(--ink-faint)}

/* Rename/delete. The strip is positioned OVER the right end of the row rather
   than laid out beside it: .hp-row is a <button>, so these cannot be nested
   inside it, and a sibling that appears on hover must not reflow the title when
   it does. It reads as part of the row because it carries the same background
   the row takes on hover -- hence the focus-within rule on .hp-row, which keeps
   the two matching when the strip is reached by keyboard instead. */
.sb-history .hp-item{position:relative}
.sb-history .hp-item:focus-within .hp-row{background:var(--panel)}
.sb-history .hp-tools{position:absolute; top:50%; transform:translateY(-50%); right:8px;
  display:none; align-items:center; gap:2px; padding-left:20px; border-radius:9px;
  /* Fades in over the title it covers rather than cutting it off mid-word --
     the row's text is ellipsized for its own width, not for this strip's. */
  background:linear-gradient(90deg, rgba(255,255,255,0) 0, var(--panel) 18px)}
.sb-history .hp-item:hover .hp-tools,
.sb-history .hp-item:focus-within .hp-tools,
.sb-history .hp-item.hp-confirming .hp-tools{display:flex}
/* A touch device has no hover to reveal them with, and tapping the row opens the
   chat -- so there they simply stay out. */
@media (hover: none){ .sb-history .hp-tools{display:flex} }
.sb-history .hp-ask{font-size:11.5px; color:var(--ink-soft); padding-right:2px}
.sb-history .hp-tool{flex:none; border:0; background:transparent; color:var(--ink-faint); cursor:pointer;
  font-family:var(--body); font-size:12px; line-height:1; padding:5px; border-radius:6px}
.sb-history .hp-tool:hover{background:var(--cream); color:var(--ink)}
.sb-history .hp-tool.hp-danger{color:var(--bad)}
.sb-history .hp-tool.hp-danger:hover{background:var(--bad-bg); color:var(--bad)}
/* No blue border of its own: the box is focused the moment it appears, so the
   global focus ring is already showing and two blue outlines read as an error. */
.sb-history .hp-input{width:100%; font-family:var(--body); font-size:13.5px; color:var(--ink);
  background:var(--panel); border:1px solid var(--line-strong); border-radius:9px; padding:8px}

/* Redivis account control, mounted once in the sidebar footer. The signed-in/out
   swap is a body class rather than JS-built DOM, the same idiom as
   #work.working above. */
body:not(.redivis-on) [data-redivis-avatar]{display:none}
body.redivis-on [data-redivis-signin]{display:none}
/* Account creation goes wherever a sign-in goes, and leaves with it. The
   sidebar's copy is the one that persists across a whole session, so without
   this it would still be inviting a signed-in user to make an account. */
body.redivis-on [data-redivis-signedout]{display:none}
.acct{position:relative}
/* Now a span inside the profile row rather than the trigger itself, so the whole
   row is clickable -- flex:none keeps the circle circular next to a long name. */
.rv-avatar{flex:none; width:28px; height:28px; padding:0; border-radius:50%;
  border:1px solid var(--line-strong); background:var(--cream); color:var(--ink);
  font-family:var(--disp); font-weight:700; font-size:11.5px; letter-spacing:.02em;
  display:grid; place-items:center}
.sb-profile:hover .rv-avatar{border-color:var(--ink)}
.sb-profile[aria-expanded="true"]{background:var(--panel)}
.sb-profile[aria-expanded="true"] .rv-avatar{border-color:var(--ink)}

#redivisMenu{position:fixed; z-index:40; width:264px;
  background:var(--panel); border:1px solid var(--line-strong); border-radius:14px;
  box-shadow:0 12px 40px rgba(40,60,90,.18); overflow:hidden}
#redivisMenu[hidden]{display:none}
#redivisMenu .rv-who{padding:12px 14px 10px; border-bottom:1px solid var(--line)}
#redivisMenu .rv-name{font-family:var(--disp); font-weight:700; font-size:13.5px; color:var(--ink);
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
#redivisMenu .rv-mail{font-size:11.5px; color:var(--ink-faint); margin-top:2px;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
#redivisMenu .rv-expired{font-size:11.5px; color:var(--warn); margin-top:4px}
#redivisMenu .rv-rows{padding:7px}
#redivisMenu .rv-row{display:flex; align-items:center; gap:9px; width:100%; text-align:left; border:0; background:transparent;
  border-radius:9px; padding:9px 8px; cursor:pointer;
  font-family:var(--body); font-size:13px; color:var(--ink); text-decoration:none}
/* Keeps "Sign out" (no logo) aligned with the row above it, which has one. */
#redivisMenu button.rv-row{padding-left:31px}
#redivisMenu .rv-ext{margin-left:auto; padding-left:8px; color:var(--ink-faint)}
/* The row is a fixed-width popover; let it size to the label rather than wrap
   "Open Healthcare Datahub" onto two lines. */
#redivisMenu .rv-row > span:not(.rv-ext){white-space:nowrap}
#redivisMenu .rv-row:hover{background:var(--cream)}

/* Sits above a replayed transcript. Warn-toned rather than error-toned: a
   read-only chat is a state, not a failure. */
.replay-banner{font-size:12.5px; color:var(--warn); background:var(--warn-bg); border-radius:10px; padding:9px 12px;
  margin-bottom:18px; text-align:center}

/* Clarifying questions. Genuinely chat-embedded now -- see frontend/clarify.js;
   this used to render into the artifact panel while the comment claimed
   otherwise. A bordered card rather than the old blue rule, because in the
   thread it has to read as a THING TO ACT ON sitting among prose, not as a
   pull-quote. Sized by #tinner's 560px, not by panel width. */
.clarify-card{border:1px solid var(--line-strong); border-radius:12px; background:var(--panel);
  margin:0 0 18px 37px; overflow:hidden}
.clarify-card .cc-head{display:flex; align-items:center; gap:10px; padding:11px 13px;
  border-bottom:1px solid var(--line)}
.clarify-card .cc-q{flex:1; font-size:13.5px; font-weight:600; color:var(--ink); line-height:1.35}
.clarify-card .cc-pager{display:flex; align-items:center; gap:2px; flex:none; color:var(--ink-faint)}
.clarify-card .cc-count{font-size:11.5px; white-space:nowrap}
.clarify-card .cc-pager button,.clarify-card .cc-close{border:0; background:transparent; cursor:pointer;
  color:var(--ink-faint); font-family:var(--body); font-size:13px; line-height:1; padding:3px 4px; border-radius:5px}
.clarify-card .cc-pager button:hover:not(:disabled),.clarify-card .cc-close:hover{color:var(--ink); background:var(--cream)}
.clarify-card .cc-pager button:disabled{opacity:.35; cursor:default}
.clarify-card .cc-close{flex:none; font-size:12px}

/* One row per option. The number is the affordance the screenshot leads with,
   so it keeps a fixed-width column and the labels line up under each other. */
.clarify-card .cc-opt{display:flex; align-items:center; gap:11px; width:100%; text-align:left;
  border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--ink);
  padding:10px 13px; cursor:pointer; font-family:var(--body); font-size:13px}
.clarify-card .cc-opt:hover{background:var(--blue-bg)}
.clarify-card .cc-opt:hover .cc-label{color:var(--blue-text)}
.clarify-card .cc-num{flex:none; width:15px; text-align:center; color:var(--ink-faint); font-size:11.5px}
.clarify-card .cc-label{flex:1; line-height:1.35}
/* The recommendation is one arrow. A "recommended" badge said the same thing a
   second time and crowded the option's own words on a narrow thread. */
.clarify-card .cc-go{flex:none; width:12px; text-align:right; color:var(--ink-faint); font-size:12px}
.clarify-card .cc-opt:hover .cc-go{color:var(--blue-text)}
.clarify-card .cc-else .cc-label{color:var(--ink-faint)}
.clarify-card .cc-other{display:flex; align-items:center; gap:11px; padding:10px 13px;
  border-bottom:1px solid var(--line)}
.clarify-card .cc-input{flex:1; border:0; background:transparent; color:var(--ink);
  font-family:var(--body); font-size:13px; padding:0}
.clarify-card .cc-input:focus{outline:none}
.clarify-card .cc-input::placeholder{color:var(--ink-faint)}

.clarify-card .cc-foot{display:flex; align-items:center; gap:10px; padding:9px 13px}
.clarify-card .cc-reason{flex:1; font-size:11.5px; color:var(--ink-faint); line-height:1.4}
.clarify-card .cc-skip{flex:none; margin-left:auto; border:1px solid var(--line-strong); background:transparent;
  color:var(--ink-soft); border-radius:7px; padding:4px 11px; cursor:pointer;
  font-family:var(--body); font-size:12px}
.clarify-card .cc-skip:hover{border-color:var(--ink); color:var(--ink)}

/* Answered: inert, and a record of what was chosen. Same lifecycle as .confirm
   below, which the confirmation card has used since it existed. */
.clarify-card.resolved{border-color:var(--line); background:transparent}
.clarify-card .cc-done{display:flex; gap:10px; padding:8px 13px; font-size:12.5px; line-height:1.4}
.clarify-card .cc-done+.cc-done{border-top:1px solid var(--line)}
.clarify-card .cc-done-q{flex:1; color:var(--ink-faint)}
.clarify-card .cc-done-a{flex:none; max-width:50%; color:var(--ink); font-weight:500; text-align:right}

/* confirmation gate (execute_cohort_query require_confirmation) */
.confirm{border:1px solid var(--warn); background:var(--warn-bg); border-radius:13px; padding:13px 15px; margin-top:8px}
.confirm .t{font-size:13px; font-weight:600; color:var(--warn); margin-bottom:8px}
.confirm .actions{display:flex; gap:8px}
.confirm .actions button{border:0; border-radius:20px; padding:8px 16px; font-family:var(--body); font-size:13px; font-weight:500; cursor:pointer}
.confirm .actions .approve{background:var(--ink); color:#fff}
.confirm .actions .approve:hover{opacity:.9}
.confirm .actions .reject{background:transparent; border:1px solid var(--line-strong); color:var(--ink)}
.confirm .actions .reject:hover{border-color:var(--ink)}
.confirm.resolved{background:transparent; border-color:var(--line)}
.confirm .outcome{font-size:12.5px; color:var(--ink-faint)}
.confirm .outcome.approved{color:var(--good)}
.confirm .outcome.rejected{color:var(--bad)}

#lcomposer{flex:none; padding:12px 20px 16px; border-top:1px solid var(--line)}
#lcomposer .tinner{max-width:560px}
#lcomposer .egs{display:flex; gap:8px; flex-wrap:wrap; margin-bottom:10px}
#lcomposer .egs button{border:1px solid var(--line-strong); background:var(--panel); color:var(--ink); border-radius:20px; padding:6px 12px; cursor:pointer; font-family:var(--body); font-size:12px}
#lcomposer .egs button:hover{border-color:var(--ink)}
#lcomposer .egs button:disabled{opacity:.5; cursor:not-allowed}
#lcomposer .compbox{box-shadow:none; padding:6px 6px 6px 16px}
#lcomposer .compbox input{font-size:14.5px}
#lcomposer .sendbtn{width:38px;height:38px;font-size:16px;border-radius:11px}

/* right: artifact */
/* min-width:0 as well as min-height:0, and both are load-bearing: a grid item's
   automatic minimum is its content's min-content width, so one wide child (the
   Data Preview's cohort table) grew this pane past its 1fr track and gave the
   whole page a horizontal scrollbar. Zeroing it makes the pane hold its track
   and the table scroll inside .rowtable-wrap instead. */
#right{min-height:0; min-width:0; padding:18px 18px 18px 20px; display:flex}
/* `position:relative` is the containing block for `.panel-busy`, which covers
   the whole panel while a newer search runs -- see that rule. */
.artifact{position:relative; flex:1; min-height:0; background:var(--panel); border:1px solid var(--line); border-radius:18px; display:flex; flex-direction:column; overflow:hidden; box-shadow:0 10px 40px rgba(40,60,90,.08); animation:rise .45s cubic-bezier(.2,.7,.3,1)}
@keyframes rise{from{transform:translateY(14px);opacity:0}to{transform:translateY(0);opacity:1}}
@media (prefers-reduced-motion:reduce){.artifact{animation:none}}
/* The right padding carries --sbw on top of the 20px .abody uses, because .ahead
   is flex:none and sits outside the scrollport: .abody's scrollbar is taken out
   of ITS content box and not this one, so with equal padding the header button
   overhung the cards by exactly a scrollbar. Paired with `scrollbar-gutter`
   below, which reserves that gutter whether or not the list currently overflows
   -- otherwise the two would align only on long lists. */
.ahead{flex:none; padding:16px calc(20px + var(--sbw)) 0 20px}
/* Scoped to neither .ahead nor .abody: the dataset panel's eyebrow and title
   moved INTO the scrollport (only the tab rail and the dataset actions stay
   pinned), while the recommend view's stay in the header. One rule, both
   places, so they cannot drift apart. */
.ahead .eyebrow,.abody > .eyebrow{font-family:var(--mono); font-size:10px; letter-spacing:.09em; text-transform:uppercase; color:var(--ink-faint); display:flex; align-items:center; gap:8px}
.ahead .badge{font-family:var(--mono); font-size:9.5px; background:var(--blue-bg); color:var(--blue-text); padding:3px 8px; border-radius:20px; text-transform:none; letter-spacing:0}
.ahead .badge.state-clarifying,.ahead .badge.state-review{background:var(--blue-bg);color:var(--blue-text)}
.ahead .badge.state-delivered{background:var(--good-bg);color:var(--good)}
.ahead .badge.state-infeasible{background:var(--warn-bg);color:var(--warn)}
.ahead .badge.state-stuck{background:var(--bad-bg);color:var(--bad)}
.ahead h2,.abody > .dshead h2{font-family:var(--disp); font-weight:600; font-size:20px; margin-top:6px; letter-spacing:-0.01em; line-height:1.2}
/* Title left, "Open in Healthcare Datahub" right. flex-wrap and a `1 1 auto`
   title so the button drops onto its own line at the 340px minimum pane width
   instead of squeezing the heading to one word per line -- .abody is
   overflow-y:auto, so anything that does not fit becomes a horizontal
   scrollbar. align-items:center rather than baseline: the button is a box with
   its own height and a baseline would hang it off the heading's text. */
.ahead .rechead{display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap}
/* The eyebrow and the title are ONE flex item, stacked -- the row's two items
   are still "the heading" and "the button", so the wrap behaviour above is
   unchanged. h2's own margin-top is dropped here because the eyebrow now
   provides that separation. */
.ahead .rechead .rectitle{flex:1 1 auto; min-width:0}
.ahead .rechead .rectitle h2{margin-top:2px}
/* THE solid blue pill that leaves this app for Redivis. Two buttons wear it --
   the header's "Open in Healthcare Datahub" and the cohort hand-off's "Continue
   in Redivis" -- and they share one rule rather than two that drift, since they
   are the same gesture from two places. `border:0` and `cursor:pointer` are for
   the hand-off's <button> variant (the copy-only case, when Redivis has not
   resolved a dataset url); `.rec-open` had only ever been an <a>. */
.rec-open,.handoff-btn{flex:none; display:inline-flex; align-items:center; gap:6px; text-decoration:none;
  background:var(--blue); color:#fff; border:0; border-radius:9px; padding:8px 13px;
  font-family:var(--body); font-size:12.5px; font-weight:600; white-space:nowrap; cursor:pointer}
.rec-open:hover,.handoff-btn:hover{opacity:.9}
.rec-open svg,.handoff-btn svg{flex:none; width:14px; height:14px; fill:none; stroke:currentColor; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round}
/* The wordmark standing in for the words "Healthcare Datahub" on the pill.
   `brightness(0) invert(1)` paints any transparent-ground artwork pure white,
   which is how one dark asset serves a solid --blue button without a second file
   to keep in step with it. Sized by HEIGHT with `width:auto`, because logo.webp
   is a wide two-line lockup (512x169) and a width cap would set the type size by
   accident. 18px is about the smallest height at which the second line still
   reads, and every pixel here is one the step rail beside it does not get. */
.rec-open .brandmark,.handoff-btn .brandmark,.analyze-btn .brandmark{flex:none; height:18px; width:auto; display:block;
  filter:brightness(0) invert(1)}
/* The dataset header's ONE row: the step rail on the left, the two
   dataset-level actions on the right.

   The actions sit ABOVE the title rather than beside it because they are about
   the dataset as a whole -- leave it, or open it on Healthcare Datahub -- while
   everything below the title belongs to the cohort being built inside it. Beside
   the title they also had to survive a 46-character dataset name at 340px, which
   is what the wrap here handles instead.

   The rail shared this row with a four-tab strip before it, and the row's own
   bottom border is what puts the active step's 2px underline on a continuous
   line. The rail therefore carries NO border of its own -- two would draw a
   second rule a few pixels away.

   align-items:flex-end, never baseline: .rec-open and .change-ds are boxes with
   their own height, and a baseline row hangs them off neighbouring text --
   .dshead was `align-items:baseline` and that is exactly what broke this button
   the first time it was tried there. flex-end is also what lands the active
   step's underline on the bar's own border rather than floating above it, and
   `padding-bottom` on the actions lifts them clear of the same line.

   ONE ROW IF THE WHOLE RAIL FITS, TWO IF IT DOES NOT -- never a clipped one.
   A wrapped row breaks its lines BEFORE anything grows or shrinks, using each
   item's hypothetical main size, so the rail's `auto` basis IS the rule: the
   line breaks exactly when all four steps plus both buttons no longer fit, and
   the rail then gets a full-width row of its own where every step is legible.

   A small basis was tried and is worse. `1 1 160px` keeps one row at any width
   by letting the rail shrink and scroll inside itself -- which at a 739px bar
   left a 421px rail over 497px of steps, clipping "Data Analysis" and half of
   step 1's disc. A rail whose job is showing the journey must not hide two of
   it to stay on one line.

   `.ahead-actions` wraps WITHIN ITSELF so the two buttons stack rather than
   overflow at the 340px minimum pane width, since .abody's overflow-y:auto
   would turn any excess into a horizontal scrollbar under the whole panel. */
.ahead .ahead-bar{display:flex; align-items:flex-end; justify-content:space-between; gap:12px;
  flex-wrap:wrap; border-bottom:1px solid var(--line)}
.ahead .ahead-bar .progress{flex:1 1 auto; min-width:0; margin-top:0; border-bottom:0}
.ahead .ahead-actions{flex:none; display:flex; align-items:center; justify-content:flex-end;
  flex-wrap:wrap; gap:8px; padding-bottom:9px}
/* The eyebrow and title sit BELOW the bar, so they need the gap the bar's
   border would otherwise butt straight up against. */
.ahead .ahead-bar + .eyebrow{margin-top:14px}
/* The dataset name, and Open Dataset RIGHT BESIDE IT -- packed to the start,
   never pushed to the far edge, so the link reads as part of the name.

   align-items:CENTER and not baseline. The rule above records why in the
   negative -- this row is where that mistake was first made. `wrap`, with the
   heading allowed to shrink, so a long dataset name drops the link below it
   instead of being squeezed to one word per line: .abody is overflow-y:auto, so
   anything that does not fit becomes a horizontal scrollbar across the panel. */
.ahead .dshead,.abody > .dshead{display:flex; align-items:center; justify-content:flex-start;
  gap:10px; flex-wrap:wrap}
.abody > .dshead h2{flex:0 1 auto; min-width:0}
.abody > .dshead{margin-bottom:14px}
.ahead .change-ds{flex:none; display:inline-flex; align-items:center; gap:6px; text-decoration:none;
  border:1px solid var(--line-strong); background:transparent; color:var(--ink-soft);
  font-family:var(--body); font-size:12.5px; font-weight:600; padding:8px 13px; border-radius:9px;
  cursor:pointer; white-space:nowrap}
.ahead .change-ds:hover{border-color:var(--blue); color:var(--blue-text)}
.ahead .change-ds:disabled{opacity:.5; cursor:not-allowed}
/* Same stroke recipe as .rec-open svg, so the two buttons' icons read as one
   set rather than as two icons that happen to sit together. */
.ahead .change-ds svg{flex:none; width:14px; height:14px; fill:none; stroke:currentColor; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round}
/* Open Dataset: the icon and the words, no box. Grey at rest, like Change
   Dataset's ink; a hover lays a soft --blue-bg highlight behind it and turns it
   --blue-text, as Change Dataset's hover does. The small padding is the
   highlight's own room; the negative margin cancels it at rest so the icon sits
   the row's gap from the name. */
.dshead .access-ds{flex:none; display:inline-flex; align-items:center; gap:5px; margin:6px 0 0 -6px;
  padding:3px 6px; border-radius:6px; color:var(--ink-soft); text-decoration:none;
  font-family:var(--body); font-size:13px; font-weight:600; white-space:nowrap;
  transition:background-color .12s, color .12s}
.dshead .access-ds:hover{background:var(--blue-bg); color:var(--blue-text)}
/* Same stroke recipe as .change-ds svg, so the two icons read as one set. */
.dshead .access-ds svg{flex:none; width:14px; height:14px; fill:none; stroke:currentColor; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round}
.dshead .access-ds:focus-visible{outline:2px solid var(--blue); outline-offset:1px; background:var(--blue-bg)}
/* .tabs is now the KEYWORD-FILTER CARD's own category strip (All / Diagnoses /
   Drugs / Procedures) and nothing else. The dataset panel's four-tab rail that
   this rule was written for is gone -- replaced by .progress below, which states
   a sequence where four peer tabs stated none. */
.tabs{display:flex; gap:4px; margin-top:14px; border-bottom:1px solid var(--line)}
.tabs button{border:0; background:transparent; color:var(--ink-soft); font-family:var(--body); font-size:13.5px; font-weight:500; padding:9px 13px; cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px}
.tabs button.on{color:var(--ink); border-bottom-color:var(--blue)}

/* ---------- The four-step journey ---------- */
/* Present on BOTH panel views, so it is the first thing a newcomer sees and it
   never disappears from under them.

   THE RAIL SCROLLS AND THE PANEL DOES NOT. Four steps do not fit at the 340px
   minimum pane width, and .abody is overflow-y:auto -- so anything overflowing
   horizontally anywhere inside the panel turns into a scrollbar under the WHOLE
   of it. Confining the overflow to the rail keeps that local.

   Styled through ::-webkit-scrollbar ALONE. `scrollbar-color` switches Chrome to
   the standard properties, which draw an OVERLAY bar that fades out when
   unattended -- exactly when "there are more steps to the right" needs saying.

   `overflow-y:hidden` is REQUIRED, not belt-and-braces: `overflow-x:auto` beside
   a `visible` overflow-y computes the second to `auto` as well, and the step's
   own 2px underline sits one pixel past the rail's content box -- so the rail
   drew a full vertical scrollbar down its right edge for one pixel of overflow.

   `overscroll-behavior-x:contain` so flicking the rail sideways on a trackpad
   cannot become a browser back-gesture. */
.progress{margin-top:14px; border-bottom:1px solid var(--line);
  min-width:0; max-width:100%; overflow-x:auto; overflow-y:hidden; overscroll-behavior-x:contain}
.progress::-webkit-scrollbar{height:4px}
.progress::-webkit-scrollbar-thumb{background:var(--line-strong); border-radius:99px}
.progress::-webkit-scrollbar-track{background:transparent}
.progress ol{display:flex; align-items:center; gap:2px; list-style:none; min-width:max-content}
/* THE CONNECTOR IS A PSEUDO-ELEMENT ON THE LIST ITEM, never a border on the step
   itself: the step is the click target and the connector is the space between
   two of them, so a border would put the line inside the thing you press and
   trail one off the end of the last step. */
.progress li{display:flex; align-items:center; flex:none}
.progress li + li::before{content:""; flex:none; width:10px; height:1px;
  background:var(--line-strong)}
/* position:relative is LOAD-BEARING, not tidiness. A locked step carries an
   .sr-only span, and .sr-only is `position:absolute` -- with no positioned
   ancestor its containing block is <body> (which is `position:relative`), so it
   was laid out at its static position INSIDE the horizontally-scrolled rail and
   then escaped the rail's clipping entirely. Measured: 89px of horizontal
   overflow on the whole document at the panel's 340px minimum, from a span one
   pixel wide. Caught by recommend_check.mjs's overflow assertion. */
.pstep{position:relative; display:inline-flex; align-items:center; gap:7px; border:0; background:transparent;
  font-family:var(--body); font-size:12.5px; font-weight:600; color:var(--ink-faint);
  padding:8px 6px 9px; border-bottom:2px solid transparent; margin-bottom:-1px;
  white-space:nowrap; cursor:pointer}
/* THE STEP'S NUMBER, as one fixed-size disc, on every step including the locked
   ones -- there is no glyph on this rail at all, so the disc's size never varies
   with what it is wearing. --mono for the numeral: it is a figure, and the same
   face every other count in this panel uses. */
/* THE FIRST STEP'S DISC SITS ON THE COLUMN'S OWN LEFT EDGE.
   `.pstep` carries 6px of horizontal padding so the steps have room to breathe
   between each other -- but on the first one that padding pushed the disc 6px
   right of everything stacked above and below it in the same column: the
   eyebrow, the title, the count and every card, all measured at the same x.
   Six pixels is small enough to read as a mistake rather than as a margin,
   which is exactly what makes it worth removing. The step keeps its right-hand
   padding, so the gap to step 2 is unchanged, and its underline now starts
   flush with the heading above it. */
.progress li:first-child .pstep{padding-left:0}
.pstep-mark{flex:none; display:inline-flex; align-items:center; justify-content:center;
  width:18px; height:18px; border-radius:50%; background:var(--cream); color:var(--ink-faint);
  font-family:var(--mono); font-size:10.5px; font-weight:500; line-height:1}
/* DONE keeps its NUMBER and says so only in the disc's tint -- a tinted step is
   behind you, which is a statement about where you are and not about what you
   may still do. A tick would have been the second kind of claim: it is what a
   checkout wizard puts on a stage you have signed off and will not revisit,
   where every step here stays open for the whole session and the schema is
   exactly the thing you go back to while building a cohort against it. */
.pstep-done{color:var(--ink-soft)}
.pstep-done .pstep-mark{background:var(--blue-bg); color:var(--blue-text)}
.pstep-available{color:var(--ink-soft)}
.pstep-available:hover,.pstep-done:hover{color:var(--ink)}
.pstep-available:hover .pstep-mark,.pstep-done:hover .pstep-mark{background:var(--blue-bg); color:var(--blue-text)}
/* The only filled mark and the only underline on the rail. One "you are here",
   said twice, because the underline alone is invisible at a glance in a row of
   four and the fill alone does not tie the step to the page below it. */
.pstep-current{color:var(--ink); border-bottom-color:var(--blue)}
.pstep-current .pstep-mark{background:var(--blue); color:#fff}
/* LOCKED IS A SPAN, so `cursor:not-allowed` is the whole affordance -- there is
   no :disabled to style and nothing to focus. Muted rather than hidden: a step
   you cannot reach yet still tells you the journey has one more stage in it.

   THE GREYING IS NOW THE ONLY THING THAT SAYS "NOT YET" ON SCREEN, since the
   padlock that used to say it a second time was eating the step's number. So the
   disc is dimmed EXPLICITLY here rather than inheriting `.pstep-mark`'s defaults
   by luck: a hollow ring on the page's own ground, against the filled cream disc
   every reachable step wears. Stated in code so a future change to the base rule
   cannot quietly brighten the one state that has to stay back. */
.pstep-locked{cursor:not-allowed; color:var(--ink-faint); opacity:.62}
.pstep-locked .pstep-mark{background:transparent; box-shadow:inset 0 0 0 1px var(--line-strong);
  color:var(--ink-faint)}
.abody{flex:1; overflow-y:auto; scrollbar-gutter:stable; padding:18px 20px 22px}

/* generic empty / loading / error states inside the artifact panel */
.artifact-empty{display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; text-align:center; padding:30px; color:var(--ink-faint)}
.artifact-empty .big{font-family:var(--disp); font-size:16px; color:var(--ink-soft); margin-bottom:6px}
.artifact-empty .small{font-size:12.5px; max-width:280px}
/* Step 2 for a SUPPORTING REFERENCE DATASET: what the dataset is, and that a
   cohort starts on an anchor dataset instead. Brand tokens only. */
.ref-ds{gap:10px}
.ref-ds-title{font-family:var(--disp); font-size:17px; font-weight:700; color:var(--ink)}
.ref-ds-sub{font-size:14px; color:var(--ink-faint); margin-bottom:14px}
.ref-ds-info{display:flex; gap:12px; align-items:flex-start; max-width:520px; width:100%; box-sizing:border-box; text-align:left; background:var(--blue-bg); border:1px solid var(--bg-gradient-end); border-radius:12px; padding:14px 18px}
.ref-ds-icon{flex:none; display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px; border-radius:50%; background:var(--line-strong); color:var(--ink-soft); font-size:11px; font-weight:700; font-family:var(--body); margin-top:1px}
.ref-ds-info-head{font-weight:600; font-size:13.5px; color:var(--ink-soft); margin-bottom:3px}
.ref-ds-info-body{font-size:13px; line-height:1.5; color:var(--ink-faint)}
.spinner{width:22px;height:22px;border-radius:50%;border:2.5px solid var(--line-strong);border-top-color:var(--blue);animation:spin .8s linear infinite;margin-bottom:12px}
@keyframes spin{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.spinner{animation:none}}
.artifact-error{margin:16px 20px; background:var(--bad-bg); color:var(--bad); border-radius:10px; padding:11px 14px; font-size:12.5px}

/* recommend cards */
/* A COLUMN, not a right-aligned row: the lead line reads as a caption for the
   list and the two controls sit under it, both flush left, which is what puts
   the filter field at the same x as every card below it. Right-aligning them
   made the row balance and made the field's left edge line up with nothing. */
/* STICKY against .abody, the scroll container -- the same trick `.dicttable th`
   uses, and for the same reason: the controls that shape the list must not
   scroll away from the list they shape. `.ahead` above is already fixed
   (flex:none, outside the scroll container), so pinning this row is what makes
   the whole top section stay put; scrolling used to leave the title stranded
   above cards with no count, no filter and no sort in reach.

   NOT moved into `.ahead` itself, which would have been the other way to fix it:
   the lead line is a caption FOR the list ("6 of 24 datasets match…"), computed
   from the same filtered-and-sorted pass that builds the cards, so lifting it
   out would split one computation across two render targets and make
   patchRecommendList patch two places instead of one.

   The opaque background is load-bearing, not decoration -- without it the cards
   scroll THROUGH the text. z-index keeps it above the cards' own hover borders. */
.rec-lead-row{position:sticky; top:0; z-index:2; background:var(--panel);
  display:flex; flex-direction:column; align-items:stretch; gap:10px;
  padding-top:18px; padding-bottom:12px; margin-bottom:2px}
/* The 18px above is NOT decoration -- it is .abody's own padding-top, moved onto
   this row, and the pair is what makes the pin actually cover.
   Chrome insets a sticky element's constraint rect by the SCROLL CONTAINER'S
   padding, so against an .abody with `padding:18px 20px 22px` a `top:0` row pins
   18px below the scrollport instead of at it -- leaving an 18px band that cards
   scroll visibly through. Measured: the row stuck at y=97 inside a body whose
   padding box starts at 79, and a full line of the card behind it showed in the
   gap, overlapping the count line.
   So the padding moves from the container to the row: `.abody-flush` drops it,
   the row re-adds it, and the row's background now starts at the scrollport
   edge. Anything else that can lead this body (the error banner, the
   keyword-filter card) takes the same 18px back as a margin, so only the sticky
   row is treated specially. */
.abody-flush{padding-top:0}
.abody-flush > :first-child:not(.rec-list){margin-top:18px}
/* Sits above the panel title, and names which of the two views is on screen:
   RECOMMENDATION for a ranking or a dataset the user named, CATALOG for the
   newest-first pager, which nothing scored. */
.rec-eyebrow{font-family:var(--mono); font-size:10px; letter-spacing:.09em; text-transform:uppercase;
  color:var(--ink-faint)}
/* Count and subtitle share a BASELINE on one row. They used to be one run-on
   sentence in body type, so the number a user actually scans for had no more
   weight than the instruction beside it. align-items:baseline (not center) is
   what keeps the 19px count and the 13px line sitting on the same line rather
   than the small text floating in the middle of the big one; wrap keeps the
   subtitle inside the pane at the 340px minimum instead of forcing a
   horizontal scrollbar. */
.rec-leadline{display:flex; align-items:baseline; gap:8px; flex-wrap:wrap; min-width:0}
.rec-count{font-size:19px; font-weight:600; color:var(--ink); line-height:1.2}
.rec-lead{font-size:13px; color:var(--ink-soft)}
.rec-lead-row .rec-lead{margin-bottom:0}
/* Where the dataset list goes before a search has been run, and while one is
   running. Deliberately quiet and NOT the empty-state card: nothing has been
   searched for, so "couldn't find what you were looking for" would be a
   finding we have not made. */
.rec-waiting{display:flex; align-items:center; gap:9px; padding:22px 2px;
  font-size:12.5px; color:var(--ink-faint)}
.rec-waiting .spinner{width:14px; height:14px; border-width:2px; margin:0; flex:none}
/* margin-left:0 and wrap: at the 340px minimum pane width the field and the sort
   control together exceed the body, and wrapping is what keeps that inside the
   pane -- .abody is overflow-y:auto, so overflow-x computes to auto and any
   excess becomes a horizontal scrollbar rather than being clipped. min-width:0
   is what lets the shrink reach the input, whose `size` attribute would
   otherwise floor it. */
.rec-tools{display:flex; align-items:center; gap:8px; flex-wrap:wrap; min-width:0}
.rec-catalognote{font-size:12px; color:var(--ink-faint); background:var(--cream); border-radius:8px; padding:7px 11px; margin-bottom:12px; line-height:1.4}

/* ---------- Browse filter field ---------- */
/* Always open. It was a magnifying glass that expanded on click, which saved a
   row's width at the cost of hiding the affordance behind a guess -- and the row
   it shares with the sort control exists either way, so the saving bought
   nothing. The magnifier inside is decorative now, not a button. */
.dssearch-wrap{display:flex; align-items:center; flex:1 1 200px; min-width:0; max-width:280px;
  border:1px solid var(--line-strong); border-radius:9px; background:var(--panel);
  padding-left:9px; transition:border-color 160ms ease}
.dssearch-wrap:focus-within{border-color:var(--blue)}
/* Stroked, not filled -- it has to read at 13px, where a filled glass is a blob. */
.dssearch-ico{flex:none; width:13px; height:13px; color:var(--ink-faint);
  fill:none; stroke:currentColor; stroke-width:1.6; stroke-linecap:round}
/* min-width:0 overrides the automatic minimum an input carries (its `size`
   attribute), which would otherwise stop the shrink at ~170px and push the row
   outside the pane at the 340px minimum width. */
.dssearch{flex:1 1 auto; min-width:0; box-sizing:border-box; border:0; outline:none;
  padding:7px 8px; background:transparent; color:var(--ink); font-family:var(--body); font-size:13px}
.dssearch::placeholder{color:var(--ink-faint)}
/* The wrapper already turns blue on :focus-within, so the global input
   focus-visible ring would draw a SECOND, square outline inside the box -- two
   focus indicators on one control, which is what it looked like. Suppressed
   here; the ring is not lost, it moved to the border. */
.dssearch:focus,.dssearch:focus-visible{outline:none; box-shadow:none}
.dssearch-clear{flex:none; width:22px; height:22px; margin-right:5px; display:flex; align-items:center; justify-content:center;
  padding:0; border:0; border-radius:50%; background:transparent; color:var(--ink-faint); cursor:pointer}
.dssearch-clear:hover{background:var(--cream); color:var(--ink)}
.dssearch-clear svg{width:10px; height:10px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round}
.reccard mark{background:var(--blue-bg); color:var(--blue-text); font-weight:600; border-radius:3px; padding:0 1px}
.rec-matchhint{font-family:var(--mono); font-size:9.5px; color:var(--ink-faint); margin-top:6px; text-transform:uppercase; letter-spacing:.04em}
/* A solid tinted panel rather than the dashed outline it used to be: this is a
   statement plus an offer, not a placeholder waiting to be filled. */
.rec-nomatch{border:1px solid var(--line); border-radius:12px; background:var(--cream);
  padding:16px 18px; font-size:12.5px; color:var(--ink-soft); line-height:1.5}
/* Mark and heading on one row. The mark is sized in em of nothing -- a fixed
   26px square -- and flex:none so a long heading wrapping to three lines cannot
   squash it; align-items:start keeps it against the heading's FIRST line. */
.rec-nomatch-head{display:flex; align-items:flex-start; gap:10px}
.rec-nomatch-mark{flex:none; width:26px; height:26px; border-radius:6px; margin-top:-1px}
.rec-nomatch-heading{font-family:var(--disp); font-weight:700; font-size:14.5px;
  color:var(--ink); line-height:1.35; letter-spacing:-0.01em}
/* The same card, rendered ABOVE a non-empty list rather than instead of an
   empty one: the codes are on the platform but scattered across datasets that
   do not join, so nothing fulfils the request and the closest partial matches
   follow underneath. It leads, because "none of these answers your question in
   full" has to be read before the cards it qualifies -- putting it after them
   would let a partial match be mistaken for the answer. The margin is what
   separates it from the count row it sits above. */
.rec-partial{margin:18px 0 4px}
.abody-flush > .rec-list > .rec-partial:first-child{margin-top:18px}
/* Three tones, one per job: the heading asks the question in --ink, the
   diagnosis says what happened in --ink-soft, the offer is dimmest. Everything
   under the heading row is inset to the heading's text edge (26px mark + 10px
   gap), so the card reads as one column rather than a heading with three things
   loose beneath it. */
.rec-nomatch-why{margin-top:8px; margin-left:36px; color:var(--ink-soft)}
.rec-nomatch .rec-waitlist{margin-top:8px; margin-left:36px; color:var(--ink-faint)}
.rec-nomatch-actions{display:flex; gap:8px; flex-wrap:wrap; margin-top:14px; margin-left:36px}
/* The anchor is styled alongside the buttons rather than separately: the
   waitlist CTA is a <button disabled> until WAITLIST_URL is set and an <a>
   after, and the two must be indistinguishable on screen. */
.rec-nomatch button, .rec-nomatch a.primary{border:1px solid var(--line-strong); background:transparent; color:var(--ink-soft); font-family:var(--body); font-size:12px; font-weight:500; padding:6px 13px; border-radius:9px; cursor:pointer}
.rec-nomatch a.primary{display:inline-block; text-decoration:none}
/* :not(:disabled) so the greyed-out waitlist button doesn't light up under the
   cursor as though it were live. */
.rec-nomatch button:hover:not(:disabled){border-color:var(--blue); color:var(--blue-text)}
/* The waitlist CTA is the card's one action, so it carries the brand blue and a
   plus, and it is taller and heavier than the secondary buttons beside it. */
.rec-nomatch button.primary, .rec-nomatch a.primary{display:inline-flex; align-items:center; gap:7px;
  background:var(--blue); border-color:var(--blue); color:#fff; font-size:12.5px; font-weight:700;
  padding:9px 17px; border-radius:10px}
.rec-nomatch .wl-plus{flex:none; width:11px; height:11px; fill:none; stroke:currentColor; stroke-width:2; stroke-linecap:round}
.rec-nomatch button.primary:hover:not(:disabled), .rec-nomatch a.primary:hover{opacity:.88; color:#fff}
.rec-nomatch button:disabled{opacity:.5; cursor:not-allowed}
/* Grey, not a dimmed blue: while WAITLIST_URL is empty there is no form behind
   this, and a washed-out version of the live colour still reads as the live
   control. Full opacity so the label stays legible at rest. */
.rec-nomatch button.primary:disabled{opacity:1; background:var(--line-strong);
  border-color:var(--line-strong); color:var(--ink-soft)}
/* Browse sort. A native <select>, so the option list, the keyboard handling and
   the mobile picker all come free -- the segmented pair of aria-pressed buttons
   it replaced had to reimplement all three. The "Sort by:" prefix lives in the
   option TEXT (see artifact.js's SORT_OPTIONS), because a closed select shows
   only its selected option and a separate label would need its own row.
   appearance:none + a background chevron: the platform control cannot be given
   this border-radius or font, and matching the filter field beside it is the
   whole point. padding-right leaves room for the chevron. */
.dssort{flex:0 1 auto; min-width:0; appearance:none; -webkit-appearance:none;
  border:1px solid var(--line-strong); border-radius:9px; background:var(--panel);
  color:var(--ink-soft); font-family:var(--body); font-size:12.5px; font-weight:500;
  padding:8px 28px 8px 11px; cursor:pointer; white-space:nowrap;
  background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23767676' stroke-width='1.6' stroke-linecap='round'%3E%3Cpath d='M4 6.5 L8 10.5 L12 6.5'/%3E%3C/svg%3E");
  background-repeat:no-repeat; background-position:right 8px center; background-size:12px}
.dssort:hover{border-color:var(--blue); color:var(--ink)}
.recpager{display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:4px; padding-top:14px; border-top:1px solid var(--line)}
.recpager button{border:1px solid var(--line-strong); background:transparent; color:var(--ink-soft); font-family:var(--body); font-size:12px; font-weight:500; padding:6px 13px; border-radius:9px; cursor:pointer}
.recpager button:hover:not(:disabled){border-color:var(--blue); color:var(--blue-text)}
.recpager button:disabled{opacity:.4; cursor:not-allowed}
.recpager-at{font-family:var(--mono); font-size:10.5px; color:var(--ink-faint)}
.reccard{border:1px solid var(--line); border-radius:13px; padding:15px 16px; margin-bottom:12px; transition:border-color .15s}
.reccard:hover{border-color:var(--blue)}
/* WRAPS, because the header row now carries three things and the pane's minimum
   is 340px: a long dataset name beside a share reading "68% subjects matched"
   does not fit on one line there, and .abody's overflow-y:auto would turn the
   excess into a horizontal scrollbar rather than clipping it -- the same reason
   .rec-facts wraps. */
.reccard .top{display:flex; align-items:flex-start; flex-wrap:wrap; gap:6px 10px}
.reccard .nm{font-family:var(--disp); font-weight:600; font-size:15px; flex:1}
.reccard .match{font-family:var(--mono); font-size:10px; color:var(--blue-text); background:var(--blue-bg); padding:3px 8px; border-radius:20px; white-space:nowrap}
/* The share of this dataset that holds the user's codes. See matchShareHtml.
   `align-self:center` against .top's flex-start, so it sits on the title's
   optical line rather than riding up beside a two-line name. */
.rec-share{display:inline-flex; align-items:center; flex:none; gap:8px; align-self:center;
  font-size:11.5px; color:var(--ink-soft); white-space:nowrap}
.rec-share.pending{color:var(--ink-faint)}
/* The track is --cream, NOT the cohort header's --panel: that bar sits on a
   --blue-bg tile where --panel is the contrast, and on a white card it would be
   invisible. The fill stays --blue, the one hue this panel has. */
.rec-share-bar{display:block; width:64px; height:5px; border-radius:99px;
  background:var(--cream); overflow:hidden}
.rec-share-bar span{display:block; height:100%; border-radius:99px; background:var(--blue)}
.reccard .why{font-size:12.5px; color:var(--ink-soft); margin-top:6px; line-height:1.5}
/* THE WAY FORWARD, ONE TREATMENT, AT EVERY STEP.
   Explore Dataset on a card (step 1), Build Cohort on the schema (into step 2),
   Preview Data on a delivered count (into step 3). They are the same gesture --
   "take me to the next step" -- so they read as the same control rather than as
   three buttons that happen to point the same way.
   Outline, not the solid --blue pill: that one is reserved for LEAVING this app
   for Healthcare Datahub (.rec-open / .handoff-btn), and a shared fill would
   make moving one step forward look like moving to another product. */
.reccard .use,.step-go{background:transparent; border:1px solid var(--blue); color:var(--blue-text); border-radius:9px; padding:8px 15px; font-family:var(--body); font-size:13px; font-weight:600; cursor:pointer; white-space:nowrap}
.reccard .use:hover,.step-go:hover{background:var(--blue-bg)}
.reccard .use:disabled,.step-go:disabled{opacity:.5; cursor:not-allowed; background:transparent}
/* Its own row, and it keeps the flex/gap even with one child: the row owns the
   top margin, so card spacing does not depend on what happens to sit in it.
   The rule is HERE rather than above the key variables, where it used to sit:
   there it split the card's facts in two -- chips above the line, variables
   below, both of them descriptions of the same dataset. It belongs at the one
   place the card actually changes subject, between everything the dataset IS and
   the single thing you can do about it. It is on the ACTIONS row and not on the
   key-variables block so that a card with no key variables (or one carrying a
   warning as its last content) still gets the same separator. */
.rec-actions{display:flex; align-items:center; gap:12px; margin-top:14px; padding-top:14px; border-top:1px solid var(--line)}

/* ---------- The three fact chips, and the key-variable pills ---------- */
/* Wraps, because at the 340px minimum pane width three chips do not fit on one
   line and .abody's overflow-y:auto would turn the excess into a horizontal
   scrollbar rather than clipping it. */
.rec-facts{display:flex; flex-wrap:wrap; gap:6px; margin-top:11px}
.rec-fact{display:inline-flex; align-items:center; gap:5px; background:var(--cream); color:var(--ink-soft);
  font-family:var(--body); font-size:11px; padding:4px 9px; border-radius:7px; white-space:nowrap}
/* Stroked and sized here rather than on the markup, so the icons follow the
   chip's colour -- the same currentColor convention as .dssearch-ico. */
.rec-fact svg{flex:none; width:13px; height:13px; color:var(--ink-faint);
  fill:none; stroke:currentColor; stroke-width:1.4; stroke-linecap:round; stroke-linejoin:round}
/* No rule above: the variables and the fact chips are both descriptions of the
   same dataset, so a line between them separated two things that belong
   together. The card's one divider sits above .rec-actions instead. */
.rec-keyvars{margin-top:13px}
.rec-keyvars-lbl{font-family:var(--mono); font-size:9.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--ink-faint)}
.rec-keyvars-list{display:flex; flex-wrap:wrap; align-items:center; gap:6px; margin-top:7px}
/* The same code-literal pill as .kf-code, deliberately: a column name is the
   same kind of thing as a clinical code, and two spellings of it in one panel
   would read as two different kinds of value. */
.rec-var{font-family:var(--mono); font-size:11px; background:var(--blue-bg); color:var(--blue-text);
  padding:2px 6px; border-radius:6px; white-space:nowrap}
/* No chrome: it is a count, not another variable, and a pill here would look
   like a fifth column called "+177 more". */
.rec-var-more{font-family:var(--mono); font-size:10.5px; color:var(--ink-faint)}

/* .rank-explain / .signals / .signalchip are gone with the markup that used
   them: the ranking's own weights are not shown to users. `explanation` and
   `breakdown` are untouched in the payload -- the model still grounds its prose
   in them -- so this is a display rule, not a ranking change. */
.reccard .warning-note{font-size:12px; color:var(--bad); background:var(--bad-bg); margin-top:8px; padding:6px 10px; border-radius:8px; line-height:1.4}

/* cohort */
/* flex-wrap, because the filtered headline is two numbers plus a unit ("9,792
   of 15,984 patients") beside a 46px figure, and at the 340px minimum panel
   width that overflowed the body and gave the whole panel a horizontal
   scrollbar. Wrapping puts the sub-line under the number instead. */
.bignum{display:flex; flex-wrap:wrap; align-items:flex-end; gap:4px 12px; margin-bottom:2px}
.bignum .n{font-family:var(--mono); font-weight:500; font-size:46px; letter-spacing:-0.03em; line-height:.95; color:var(--blue-text)}
.bignum .sub{flex:0 0 100%; font-size:12.5px; color:var(--ink-soft); padding-bottom:0; min-width:0}
.bignum .sub b{color:var(--ink); font-weight:600}
/* The hover preview: "110,204 → 102,099 subjects". The hypothetical is the
   PALER of the two, so the number that is true right now still leads -- a
   preview that outweighed the real count would be a number the user could read
   without noticing they were only pointing at it. The arrow is aria-hidden and
   the gap around it comes from .bignum's own, so the pair reads as one figure.
   Rewritten in place by mountDemographics, never through a re-render. */
.bignum .narrow{font-family:var(--mono); font-size:26px; line-height:1; color:var(--ink-faint); padding-bottom:6px}
.bignum .n.preview{color:var(--blue)}

/* COUNTING has no rule of its own on `.bignum` any more. The number stays on
   screen (it is still the true count of the previous selection) under the
   panel-wide grey of `.artifact.is-counting`, and `.bignum.counting` keeps only
   its `aria-busy`. Dimming the digits a second time under that grey left them
   unreadable.

   THE SPINNER IS NOT BESIDE THE NUMBER ANY MORE. It sat at the very top of the
   panel, and testers scrolled down a long filter page never saw it -- the
   controls greyed with nothing on screen saying why. It is the panel's own
   centred `.panel-busy` overlay now, visible at any scroll depth. */
/* .pending-note is gone with the banner it styled: the header's Approve button
   and the per-criterion counts each said one of its sentences, nearer to the
   thing they describe. */
.lbl{font-family:var(--mono); font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--ink-faint); margin:20px 0 10px}
.lbl:first-child{margin-top:0}
.chips{display:flex; flex-wrap:wrap; gap:7px}
.chip{display:inline-flex; align-items:center; gap:7px; font-size:12.5px; font-family:var(--mono); background:var(--blue-bg); color:var(--blue-text); padding:5px 7px 5px 11px; border-radius:8px}
.chip.excl{background:transparent; border:1px dashed var(--line-strong); color:var(--ink-soft)}
.chip .cnt{opacity:.75}

/* keyword filter cards (get_keyword_filters / get_keyword_filters_tool)

   ONE SURFACE, divided by hairlines: search and concepts, then the category
   tabs, then the list of headings, then the footer -- each band flush to the
   card's edges and separated by a single rule. There is no inner box anywhere.

   It was drawn as nested boxes first (a tinted head panel, then a separately
   bordered list floating below it) and read as two cards that happened to be
   stacked: the search bar looked like it belonged to something other than the
   rows it filters. A divider says "same thing, next part"; a border says
   "different thing". */
.kf-card{border:1px solid var(--line); border-radius:14px; margin-bottom:14px; background:var(--panel);
  overflow:hidden; box-shadow:0 1px 2px rgba(26,26,26,.04), 0 10px 28px -14px rgba(0,153,255,.28)}
.kf-card.busy .kf-groups, .kf-card.busy .kf-tabs{opacity:.55; pointer-events:none}
.kf-sub{font-size:11px; color:var(--ink-faint); margin:0 16px 10px}
/* No tint. A blue wash was tried here and removed by request: the head is where
   you put things IN, and colouring it competed with the one blue thing on the
   card that means something -- the concept pill sitting inside it. The card
   gets its top and bottom from the shadow and the footer's fill instead. */
.kf-head{padding:14px 16px}

/* The concept rail: one pill per concept, and the card's only title now. Its
   active treatment is the sort toggle's, not the category tabs' underline --
   two underlined tab rows stacked read as one confused control. */
.kf-concepts{display:flex; gap:6px; overflow-x:auto; scrollbar-width:none}
.kf-concepts::-webkit-scrollbar{display:none}
.kf-concept{display:inline-flex; align-items:center; gap:7px; flex:none; border:1px solid transparent;
  background:transparent; color:var(--blue-text); font-family:var(--body); font-size:13px; font-weight:600;
  padding:6px 12px; border-radius:20px; white-space:nowrap}
button.kf-concept{cursor:pointer}
button.kf-concept:hover{color:var(--ink)}
.kf-cn{font-family:var(--mono); font-size:10.5px; color:var(--blue-text); opacity:.65}
/* A lone concept is a label, not a control: no hover, no pointer. It keeps the
   tinted pill treatment either way -- it is still the concept in force, and the
   only thing naming what the card is about. It has no remove button, because
   removing the last one would empty the card and take the search bar with it. */
.kf-concepts.one .kf-concept{cursor:default; background:var(--blue-bg); border-color:var(--bg-gradient-end)}
/* The pills scroll; the "+" does not go with them. It sits at the end of the
   row and stays reachable however many concepts are on the card. */
.kf-pills{display:flex; gap:6px; min-width:0; overflow-x:auto; scrollbar-width:none}
.kf-pills::-webkit-scrollbar{display:none}
/* A pill and its remove button read as ONE control: the wrapper carries the
   border and the fill, and the two buttons inside are transparent. Bordering
   the tab itself would draw a pill inside a pill. */
.kf-pill{flex:none; display:inline-flex; align-items:center; border:1px solid var(--bg-gradient-end);
  background:var(--blue-bg); border-radius:20px; overflow:hidden}
.kf-pill:hover{border-color:var(--blue)}
.kf-pill.on{border-color:var(--blue)}
.kf-pill .kf-concept{border:0; background:transparent; border-radius:0; padding-right:4px}
.kf-remove{border:0; background:transparent; color:var(--blue-text); font-family:var(--body);
  font-size:14px; line-height:1; cursor:pointer; padding:6px 11px 6px 2px; align-self:stretch; opacity:.6}
.kf-remove:hover{color:var(--bad); opacity:1}
.kf-concepts{align-items:center; flex-wrap:wrap}
/* The keyword search bar. It replaced a circular "+" at the end of the pill
   rail that expanded into this same field -- same behaviour, one fewer guess,
   and the same reasoning .dssearch-wrap records for the browse filter. It is
   full-width and leads the card, so it reads as the way in rather than as an
   afterthought at the end of a row of chips. */
.kf-search{display:flex; align-items:center; gap:9px; border:1px solid var(--line-strong);
  background:var(--panel); border-radius:10px; padding:0 12px;
  box-shadow:0 1px 2px rgba(26,26,26,.04)}
.kf-search:focus-within{border-color:var(--blue); box-shadow:0 0 0 3px var(--blue-bg)}
.kf-search-ico{width:15px; height:15px; flex:none; fill:none; stroke:var(--ink-faint); stroke-width:1.6}
/* min-width:0 is what lets the field shrink inside the flex row at the 340px
   minimum pane width; without it an input's intrinsic width floors the card. */
.kf-addinput{flex:1; min-width:0; border:0; outline:none; background:transparent; color:var(--ink);
  font-family:var(--body); font-size:13px; padding:11px 0}
.kf-addinput::placeholder{color:var(--ink-faint)}
.kf-search .spinner{width:12px; height:12px; border-width:2px; margin:0; flex:none}
.kf-adderr{font-size:11px; color:var(--warn); margin:8px 0 0}
/* The pills sit UNDER the field they were typed into, so the head reads
   top-to-bottom as "ask for a concept" then "the ones you asked for". */
.kf-head .kf-concepts{margin-top:10px}
.kf-head .kf-concepts:empty{margin-top:0}

/* The tabs' own border-bottom (.tabs) is the rule between them and the list, so
   the row is padded rather than inset -- an inset row would leave the rule short
   of the card's edges and re-introduce the floating-box reading. */
.kf-tabs{margin:0; padding:0 16px; gap:16px; overflow-x:auto; scrollbar-width:none}
.kf-tabs::-webkit-scrollbar{display:none}
.kf-tabs button{font-size:13.5px; padding:9px 1px; white-space:nowrap; display:inline-flex; align-items:center; gap:7px}
/* A count PILL, not a superscript: at this size a bare grey number beside a
   13.5px label reads as part of the label. */
.kf-tabs .kf-tabn{font-family:var(--body); font-size:11px; font-weight:700; color:var(--ink-soft);
  background:var(--cream); padding:1px 7px; border-radius:6px; margin:0}
.kf-tabs button.on .kf-tabn{color:var(--blue-text); background:var(--blue-bg)}
.kf-tabs button:disabled{color:var(--ink-faint); opacity:.5; cursor:not-allowed}

/* Flush to the card's edges and scrolling inside it -- rows of one answer,
   separated by hairlines rather than by a gap and a border each. The tabs above
   and the footer below supply its top and bottom rules. */
.kf-groups{margin:0; max-height:360px; overflow-y:auto}
.kf-groups .empty{padding:16px}
/* .field sets overflow:hidden, which clips the global summary focus ring. */
.kf-card .kf-group{background:var(--panel); overflow:visible; margin:0; border:0; border-radius:0;
  border-bottom:1px solid var(--line)}
.kf-card .kf-group:last-child{border-bottom:0}
.kf-card .kf-group summary{padding:14px 16px; gap:12px}
/* The row under the pointer is the one about to be ticked -- the inset bar is
   the same blue the checkbox will take. A row that HAS been ticked keeps the
   bar and the tint permanently, so a long list shows what you have chosen
   without counting checkboxes; the tally in the footer says how many, the
   colour says where. */
.kf-card .kf-group summary:hover{background:var(--cream); box-shadow:inset 3px 0 0 var(--blue)}
.kf-card .kf-group.on > summary{background:var(--blue-bg); box-shadow:inset 3px 0 0 var(--blue)}
.kf-card .kf-group.on > summary .kf-gname{color:var(--blue-deep)}
.kf-card .kf-group summary:focus-visible{outline-offset:-2px}
.kf-gcheck{flex:0 0 auto; cursor:pointer}
.kf-gname{flex:0 1 auto; min-width:0; font-size:13.5px; font-weight:500; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
.kf-gmeta{font-family:var(--body); font-size:11.5px; color:var(--ink-faint); white-space:nowrap}
.kf-gmeta b{font-weight:600; color:var(--ink-soft)}
/* Scoped, never the shared `.field .ft`. ONE SHADE PER CATEGORY, all four steps
   of the same blue ramp plus a neutral for "other": the badge's whole job is to
   say which kind of code a heading holds, so two categories that look alike are
   the badge failing at it. Three steps of the one blue ramp -- the three domains
   the profiler indexes -- with the neutral default left in place for a category
   the backend adds later. */
.kf-card .kf-group .ft{font-family:var(--body); font-size:10.5px; font-weight:700; padding:3px 9px;
  border-radius:6px; background:var(--cream); color:var(--ink-soft)}
.kf-card .kf-group .ft[data-cat="diagnoses"]{background:var(--blue); color:#fff}
/* The palest step is drawn as a RING on white rather than as a fill: `--blue-bg`
   on a ticked row's `--blue-bg` tint is the same colour twice, and the badge
   vanished exactly on the rows the user had chosen. */
.kf-card .kf-group .ft[data-cat="drugs"]{background:var(--panel); color:var(--blue-text);
  box-shadow:inset 0 0 0 1px var(--bg-gradient-end)}
.kf-card .kf-group .ft[data-cat="procedures"]{background:var(--bg-gradient-end); color:var(--blue-deep)}
.kf-gsel{font-family:var(--body); font-size:11.5px; color:var(--ink-soft); background:var(--panel);
  border:1px solid var(--line-strong); padding:3px 9px; border-radius:7px; white-space:nowrap}
.kf-gsel b{font-weight:700; color:var(--ink)}
.kf-gsel.none{color:var(--ink-faint)}
/* Beside the name, so everything after it is pushed to the row's end. */
.kf-card .kf-group summary .kf-gsel{flex:0 0 auto; margin-right:auto}
/* The heading row EXPANDS, and a checkbox row gives no hint of it: a chevron in
   its own round hit area at the row's end, pointing down while shut and up once
   open, darkening with the row's hover so the whole row reads as the control. */
.kf-chev{flex:0 0 auto; width:24px; height:24px; border-radius:50%; position:relative;
  background:var(--cream); transition:background .15s, transform .15s}
.kf-chev::before{content:""; position:absolute; left:50%; top:50%; width:6px; height:6px;
  border-right:1.75px solid var(--ink-soft); border-bottom:1.75px solid var(--ink-soft);
  transform:translate(-50%, -70%) rotate(45deg)}
.kf-card .kf-group summary:hover .kf-chev{background:var(--blue-bg)}
.kf-card .kf-group summary:hover .kf-chev::before{border-color:var(--blue)}
.kf-card .kf-group.on > summary .kf-chev{background:var(--panel)}
.kf-card .kf-group[open] > summary .kf-chev{transform:rotate(180deg)}
@media (prefers-reduced-motion: reduce){ .kf-chev{transition:none} }
/* Indented to the width of the heading's own checkbox, so an open group reads
   as CHILDREN of the row above rather than as more rows of the list -- the two
   are otherwise separated by the same hairline as everything else. */
.kf-card .kf-group .fb{padding:0 16px 12px 46px; background:var(--cream)}
.kf-codes{display:flex; flex-direction:column; gap:2px; padding-top:8px}
.kf-via{font-size:10.5px; color:var(--ink-faint); font-style:italic; padding:6px 0 2px}

/* One checkbox recipe for the heading rows and the code rows, because they are
   the same decision at two depths. */
.kf-card input.kf-gcheck, .kf-card input.kf-check{appearance:none; -webkit-appearance:none; width:17px; height:17px;
  border:1.5px solid var(--line-strong); border-radius:5px; background:var(--panel); position:relative}
.kf-card input.kf-gcheck:checked, .kf-card input.kf-check:checked{background:var(--blue); border-color:var(--blue)}
.kf-card input.kf-gcheck:checked::after, .kf-card input.kf-check:checked::after{content:""; position:absolute;
  left:5px; top:1.5px; width:4px; height:9px; border:solid #fff; border-width:0 2px 2px 0; transform:rotate(43deg)}
.kf-card input.kf-gcheck:indeterminate{background:var(--blue); border-color:var(--blue)}
.kf-card input.kf-gcheck:indeterminate::after{content:""; position:absolute; left:3px; top:6.5px; width:9px;
  height:2px; background:#fff}
.kf-card input.kf-gcheck:hover, .kf-card input.kf-check:hover{border-color:var(--blue)}

.kf-row{display:flex; align-items:center; gap:8px; font-size:12px; padding:4px 0; cursor:pointer}
.kf-row input.kf-check{flex:0 0 auto; cursor:pointer}
.kf-code{font-family:var(--mono); font-size:11px; color:var(--blue-text); background:var(--blue-bg); padding:2px 6px; border-radius:6px; white-space:nowrap}
.kf-sys{font-family:var(--mono); font-size:9.5px; color:var(--ink-faint); white-space:nowrap}
.kf-desc{flex:1; color:var(--ink-soft)}
.kf-count{font-family:var(--mono); font-size:10.5px; color:var(--ink-faint); white-space:nowrap}
.kf-datasets{font-family:var(--mono); font-size:9.5px; color:var(--ink-faint); white-space:nowrap}
.kf-row.off .kf-code{background:transparent; border:1px dashed var(--line-strong); color:var(--ink-faint)}
/* Unticked is the STARTING state (every code arrives unticked), so it must not
   read as removed: quieter ink, never a strike-through, which made a fresh card
   look like every code had already been rejected. */
.kf-row.off .kf-desc{color:var(--ink-faint)}

.kf-foot{display:flex; align-items:center; gap:9px; flex-wrap:wrap; margin:0; padding:14px 16px;
  border-top:1px solid var(--line); background:var(--cream)}
.kf-foot-ico{width:15px; height:15px; flex:none; fill:none; stroke:var(--ink-faint); stroke-width:1.4;
  stroke-linecap:round}
.kf-summary{flex:1; min-width:150px; font-size:12.5px; color:var(--ink-soft)}
.kf-actions{display:flex; align-items:center; gap:14px; flex:none}
.kf-apply{border:1px solid var(--line-strong); background:var(--panel); color:var(--ink); border-radius:9px; padding:6px 12px; font-family:var(--body); font-size:12px; font-weight:500; cursor:pointer}
.kf-apply:hover{border-color:var(--blue); color:var(--blue-text)}
.kf-apply:disabled{opacity:.5; cursor:not-allowed; border-color:var(--line)}
.kf-reset{border:0; background:transparent; color:var(--ink-soft); font-family:var(--body); font-size:13px;
  font-weight:500; text-decoration:underline; text-underline-offset:3px; padding:0; cursor:pointer}
.kf-reset:hover:not(:disabled){color:var(--ink)}
.kf-reset:disabled{opacity:.4; cursor:not-allowed}
/* The card's one commit, so it carries the brand blue while Reset beside it
   stays a quiet text link -- the same weighting the waitlist CTA gets against
   the buttons next to it. Grey rather than a dimmed blue when disabled, for
   that card's reason too: a washed-out version of the live colour still reads
   as the live control, and here it is disabled precisely because pressing it
   would do nothing yet. */
/* SIZED LIKE THE HEADER'S "Open in Healthcare Datahub" pill (.rec-open): the
   same type, radius and padding, and the same height -- that pill's 18px
   wordmark sets its height, so a text-only button needs the min-height to
   match it. */
.kf-search-btn{display:inline-flex; align-items:center; border:0; background:var(--blue); color:#fff;
  font-family:var(--body); font-size:12.5px; font-weight:600; padding:8px 13px; min-height:34px;
  border-radius:9px; white-space:nowrap; cursor:pointer}
.kf-search-btn:hover:not(:disabled){opacity:.88}
.kf-search-btn:disabled{cursor:not-allowed; background:var(--line-strong); color:var(--ink-soft)}

.kf-status{display:flex; align-items:center; gap:7px; font-size:11.5px; margin:0 16px 12px; padding:7px 9px; border-radius:8px}
/* Quiet: these appear on their own every time a tick settles, not once when a
   button is pressed, so they must not read as an alert. No background at all.
   There is deliberately NO amber state left on this card -- the two that
   existed (`warn`, and an `ok` beside it on a cohort card that no longer
   exists) turned a footnote about the REQUEST into a warning about the DATA,
   and the one that fired in practice was the reranker narrating its own
   conservatism. A real failure is still loud: `err` below. */
.kf-status.busy, .kf-status.note{color:var(--ink-faint); background:transparent; padding-left:0}
.kf-status.err{color:var(--bad); background:var(--bad-bg)}
.kf-status .spinner{width:13px; height:13px; border-width:2px; margin:0; flex:none}
.kf-status button{border:0; background:transparent; color:inherit; font:inherit; text-decoration:underline; cursor:pointer; padding:0}
.empty{font-size:12.5px; color:var(--ink-faint); font-style:italic}
.crit-list{display:flex; flex-direction:column; gap:8px}
.crit{border:1px solid var(--line); border-radius:11px; padding:10px 12px}
.crit .head{display:flex; justify-content:space-between; align-items:center; gap:8px}
.crit .desc{font-weight:500; font-size:13px}
.crit .count{font-family:var(--mono); font-size:11.5px; color:var(--blue-text); white-space:nowrap}
.crit .count.missing{color:var(--ink-faint)}
.crit .field{font-family:var(--mono); font-size:10.5px; color:var(--ink-faint); margin-top:4px}
.crit .codes{margin-top:6px; display:flex; flex-direction:column; gap:3px}
.crit .code{font-family:var(--mono); font-size:10.5px; color:var(--ink-soft); display:flex; justify-content:space-between; gap:8px}
.assume{margin-top:20px; font-size:12px; color:var(--ink-soft); background:var(--cream); border-radius:11px; padding:12px 14px; line-height:1.55}
.assume b{color:var(--ink)}
.assume-row{margin-bottom:4px}
.assume-row.needs-confirmation{color:var(--warn)}
.cw-kv{font-size:12px; margin:4px 0}
.cw-kv span:first-child{color:var(--ink-faint); margin-right:6px}
/* ---------- SQL tab ----------
   ONE query, in Redivis form, with no dialect toggle. The BigQuery rendering is
   not shown here at all: it is how this app reaches its own warehouse, so its
   project and dataset ids are internal facts a researcher cannot run anywhere.
   The segmented .sql-flavour control, the .sql-note prose and the .sql-dialect
   label all went with it.

   The tab is a column: Cohort Preview, then SQL Query, with the hand-off
   pinned beneath as a button that opens the getting-started guide. */
.sqltab{display:flex; flex-direction:column; align-items:flex-start; gap:10px}
/* The two sections' headings, Cohort Preview and SQL Query. */
.sqltab-h{font-family:var(--disp); font-weight:600; font-size:14px; letter-spacing:-0.01em; margin:8px 0 0;
  display:inline-flex; align-items:center; gap:6px; color:var(--ink)}
.sqltab-h:first-child{margin-top:0}
.sqltab-h svg{flex:none; width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:1.6; stroke-linecap:round; stroke-linejoin:round}
/* A FIXED-HEIGHT SCROLLPORT, not a box that grows. A full-record query on an
   OMOP dataset is several hundred lines, and letting it set the page's height
   put the button above it and the steps below it a screen apart. max-height
   rather than height, so a two-line query is not padded out to 340px of cream.
   Both axes scroll: `overflow:auto` supersedes the `overflow-x` this had. */
/* The query card plus its corner copy icon. The icon hangs off this wrapper,
   not the scrollport, so it never scrolls away; it is inset past the box's
   own scrollbar. */
.sql-wrap{position:relative; align-self:stretch; display:flex; flex-direction:column}
.sql-wrap .sql-box{padding-right:44px}
.sql-copy{position:absolute; top:8px; right:20px; width:28px; height:28px; padding:0; display:inline-flex;
  align-items:center; justify-content:center; border:1px solid var(--line); border-radius:7px;
  background:var(--cream); color:var(--ink-soft); cursor:pointer}
.sql-copy:hover,.sql-copy:focus-visible{background:var(--blue-bg); color:var(--ink)}
.sql-copy.is-copied{color:var(--blue)}
.sql-copy svg{width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round}
.sql-box{background:var(--cream); border:1px solid var(--line); border-radius:10px; padding:10px 12px; font-family:var(--mono); font-size:11.5px; white-space:pre-wrap; word-break:break-word; overflow:auto; max-height:340px; align-self:stretch}
/* Two panels, side by side above 560px and stacked below it -- the artifact
   pane goes down to 340px wide, where two columns would be two slivers.
   Screenshots of another product's UI, so they carry a real alt: nothing else
   on screen says what the button being pointed at looks like. */
/* No Redivis query for this cohort (the translation was blocked). A LINE, not an
   `.artifact-empty` for the whole step: the rows above it are still the answer,
   and throwing a good table away over a missing `redivis_reference` is what the
   full-height empty state used to do when this step was only a query. */
.sql-blocked{font-size:12.5px; color:var(--ink-soft); line-height:1.55; align-self:stretch;
  background:var(--cream); border:1px solid var(--line); border-radius:10px; padding:11px 13px}
.sql-steps{display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:14px; align-self:stretch; margin-top:6px}
.sql-step{border:1px solid var(--line); border-radius:12px; padding:14px 15px 15px; display:flex; flex-direction:column; gap:8px}
.sql-step-head{display:flex; align-items:center; gap:9px}
.sql-step-n{flex:none; width:20px; height:20px; border-radius:50%; background:var(--blue-bg); color:var(--blue-text);
  font-family:var(--mono); font-size:11px; display:flex; align-items:center; justify-content:center}
.sql-step h4{font-family:var(--disp); font-weight:600; font-size:13px; letter-spacing:-0.01em; margin:0}
.sql-step p{font-size:12px; color:var(--ink-soft); line-height:1.55; margin:0}
.sql-step p b{color:var(--ink); font-weight:600}
.sql-step img{width:100%; height:auto; border:1px solid var(--line); border-radius:8px; margin-top:2px}
/* The delivered view no longer renders tables-and-fields, validation rows or
   limitation warnings: the analyst still returns all three, but on screen they
   sat between the count and the explanation and buried both. Their rules went
   with them, as did .dl/.stub-note for the never-wired Download extract. */
.infeasible-reason,.stuck-reason{font-size:13px; line-height:1.55}
.stuck-kv{font-size:12.5px; margin:8px 0; color:var(--ink-soft)}
.stuck-kv b{color:var(--ink)}

/* The delivered cohort's patient rows -- the TOP HALF of the Access Data step,
   above the query that produced them. One grey line under the heading says how
   many of the cohort's people are in the table. */
.preview-sub{font-size:12px; color:var(--ink-soft); line-height:1.5; margin:-6px 0 2px; align-self:stretch}
.preview-sub b{font-weight:600; color:var(--ink)}
/* The wrapper scrolls, not the page: a cohort row is as wide as the cohort has
   variables, and letting that stretch the panel would push the chat pane.

   IT SCROLLS VERTICALLY TOO, AND THAT IS WHAT MAKES THE HEADINGS STICK.
   `position:sticky` on `.rowtable th` below was here all along and was a no-op
   on the vertical axis: `overflow-x:auto` already made this element a scroll
   container on BOTH axes (an `overflow-y:visible` beside a non-visible
   `overflow-x` computes to `auto`), so the header was pinning to the top of a
   scrollport exactly as tall as its own content while the scrolling a reader
   actually does happened one level up, in `.abody`. Fifty subjects of a wide
   OMOP record therefore scrolled away from their column names. Capping the
   height gives the sticky rule a scrollport to stick inside; the horizontal
   scroll it already had is unchanged. `max-height` rather than `height`, so a
   three-row cohort still draws a three-row table.

   340px, THE SAME BOUND AS `.sql-box` DIRECTLY BELOW IT. The two cards are one
   answer split in half -- the rows, and the query that gets the rest of them --
   so a table that set its own height would push the query, the hand-off button
   and the two steps explaining it a screen apart, which is the defect `.sql-box`
   was given a fixed scrollport for in the first place. 60vh was that defect on a
   tall window. */
.rowtable-wrap{border:1px solid var(--line); border-radius:12px; overflow:auto; max-width:100%;
  max-height:340px; align-self:stretch}
/* Signed out, the whole step is behind one lock: `.locked-body` clips it at
   260px and pointer-events:none makes a scrollport unreachable, so a scrollport
   here would be one nobody can reach. The blur itself is on `.sqltab` below --
   the whole step goes soft, not the table on its own. */
.locked-body .rowtable-wrap{max-height:none; overflow-x:auto; overflow-y:hidden}
/* min-width:100% so a narrow cohort's header band still spans the container
   instead of stopping halfway across it. */
.rowtable{border-collapse:collapse; font-family:var(--mono); font-size:11.5px; white-space:nowrap; min-width:100%}
/* vertical-align:top, because a joined column's cell is a LIST: one row can be
   one line in one column and twenty in the next, and centring those would leave
   the subject's own id floating in the middle of its record. */
.rowtable th,.rowtable td{text-align:left; padding:7px 11px; border-bottom:1px solid var(--line); vertical-align:top}
.rowtable tbody tr:last-child td{border-bottom:0}
/* The opaque background and the z-index are both load-bearing now that rows
   really do travel underneath: a transparent header would show the record
   sliding through it. The rule that separates the header from the first row is
   an inset SHADOW, not the `border-bottom` two lines up -- under
   `border-collapse:collapse` a collapsed border belongs to the table's own
   border grid rather than to the cell, so it stays behind with the rows and the
   header comes unstuck from its underline the moment you scroll. */
.rowtable th{color:var(--ink-faint); font-weight:500; background:var(--cream); position:sticky; top:0;
  z-index:1; border-bottom-color:transparent; box-shadow:inset 0 -1px 0 var(--line)}
.rowtable .cell-null{color:var(--ink-faint)}
/* One value per line. The table sets white-space:nowrap so a single value never
   wraps mid-code; these are separate flow lines rather than a wrapped run, so
   they stack without giving that up. */
.rowtable .cell-multi{display:flex; flex-direction:column; gap:1px}
/* What the per-cell cap withheld, and it is an exact number rather than an
   ellipsis: "+ 412 more" and "+ 1 more" are different facts. */
.rowtable .cell-more{color:var(--ink-faint); font-size:10.5px; margin-top:2px}
/* Observed-value chip. Shared: the schema tab is the only thing rendering these
   now, but the rule stayed scoped to the old preview tab's markup for so long
   that the schema tab's own chips silently rendered as plain text. */
.pv{font-size:11.5px; background:var(--cream); color:var(--ink-soft); padding:3px 9px; border-radius:14px}
.pmeta{font-size:11.5px; color:var(--ink-faint); margin-top:4px; font-family:var(--mono)}

/* .field: the expandable bordered card. The schema tab is no longer a consumer
   (it is a table now), but the keyword-filter groups and the cohort tab's table
   labels are -- see .kf-group and .crit .field. */
.field{border:1px solid var(--line); border-radius:11px; margin-bottom:9px; overflow:hidden}
.field summary{list-style:none; cursor:pointer; padding:12px 14px; display:flex; align-items:center; gap:11px}
.field summary::-webkit-details-marker{display:none}
.field .ft{font-family:var(--mono); font-size:9.5px; color:var(--blue-text); background:var(--blue-bg); padding:2px 7px; border-radius:5px}
.field .fb{padding:0 14px 14px; border-top:1px solid var(--line)}
.pv-row{display:flex; flex-wrap:wrap; gap:5px; margin-top:6px}

/* schema tab: the data dictionary. One row per column across every table. */
.dict-head{margin-bottom:12px}
/* The notes are one sentence each, so they get one line each -- see
   schemaSummaryHtml. The label stays on the baseline of the first line, which
   is what keeps the three summary rows reading as a set. */
.cw-notes{display:flex; gap:6px; align-items:baseline}
.cw-notes>span:first-child{flex:none}
.cw-lines>div{line-height:1.5}
.cw-lines>div+div{margin-top:3px}
/* Notes ABOVE the table, never inside it: a capped column list is a fact about
   the list, not a row in it. The withheld-vocabulary-tables note used to sit
   here too and is gone -- it named eight unqueryable tables in the loudest
   colour on the tab, above the dictionary the user came for. */
.dict-note{font-size:11.5px; color:var(--warn); background:var(--warn-bg); border-radius:9px; padding:8px 11px; margin-bottom:10px; line-height:1.5}
.dict-count{font-family:var(--mono); font-size:10.5px; color:var(--ink-faint); margin-bottom:8px}

/* Scrolls itself rather than the page. min-width because six columns genuinely
   do not fit a narrow panel, and crushing Description to a few pixels is worse
   than a scrollbar on the table alone. */
.dicttable-wrap{border:1px solid var(--line); border-radius:12px; overflow-x:auto; max-width:100%}
/* fixed: what makes the clamp on .dc-desc work -- a cell can only overflow a
   width it actually has, and auto layout would size to the longest description. */
/* min-width low enough that all six columns fit the panel without scrolling --
   the two data-quality columns are the point of this table, and one parked off
   the right edge by default is one nobody reads. */
.dicttable{border-collapse:collapse; table-layout:fixed; width:100%; min-width:610px; font-size:11.5px}
.dicttable th,.dicttable td{text-align:left; padding:7px 9px; border-bottom:1px solid var(--line); vertical-align:top}
.dicttable tbody tr:last-child td{border-bottom:0}
/* Sticky against .abody, which is the scroll container -- same trick .rowtable
   th uses. Without it a 3,114-row dictionary loses its headers immediately.
   Headers WRAP (the cells do not): letting "Distinct values" take two lines in
   the header buys ~40px of table width back, which is the difference between the
   column fitting the panel and hanging off it. */
.dicttable th{font-family:var(--body); font-size:10.5px; font-weight:500; color:var(--ink-faint); background:var(--cream); position:sticky; top:0; line-height:1.35}
/* Narrower than it was: the column now holds "Primary cohort" rather than
   `s41598_020_73558_3_sepsis_survival_primary_cohort`, and the width it gives
   back goes to Description. A dataset whose names could not be matched still
   shows a BigQuery id here and wraps, which is what it did before. */
.dicttable th:nth-child(1),.dicttable td:nth-child(1){width:13%}
/* Widest of the identifier columns: OMOP field names run to `gender_concept_id`,
   and too narrow a column breaks them mid-token, which reads as a typo. */
.dicttable th:nth-child(2),.dicttable td:nth-child(2){width:22%}
.dicttable th:nth-child(3),.dicttable td:nth-child(3){width:31%}
.dicttable th:nth-child(4),.dicttable td:nth-child(4){width:12%}
.dicttable th:nth-child(5),.dicttable td:nth-child(5){width:10%}
.dicttable th:nth-child(6),.dicttable td:nth-child(6){width:12%}
/* break-word, not break-all: wrap a long identifier rather than let it widen the
   column, but never split one that fits. */
/* Sans, not mono: this is the table's NAME now, not its identifier. The
   identifier is in the title attribute and in the table list above. */
.dicttable .dc-table{color:var(--ink-soft); overflow-wrap:break-word}
/* break-word, not break-all: wrap a long identifier rather than let it widen the
   column, but never split one that fits. */
.dicttable .dc-field{font-family:var(--mono); color:var(--ink); overflow-wrap:break-word}
/* TWO lines, clamped -- not one line ellipsised, which this replaced. Row height
   still does not depend on prose length, so the table stays scannable, but two
   lines is enough for most descriptions on every card in the catalog, which
   means the common case now needs no interaction at all. */
.dicttable .dc-desc{color:var(--ink-soft); position:relative; padding-right:22px}
.dicttable .dc-text{display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; line-clamp:2; overflow:hidden; line-height:1.45}
/* The one way out of the clamp: this cell's own triangle. There was briefly a
   "Wrap descriptions" button on the count line that unclamped every row at
   once; it is gone, so the affordance is where the text it expands is. */
.dicttable td.dc-desc.expanded .dc-text{-webkit-line-clamp:unset; line-clamp:unset; display:block; overflow:visible}
/* Hidden until markOverflowingDescriptions() finds the cell actually overflows.
   A control on a description that already fits is noise on every row of a
   3,114-row table. `visibility`, not `display`, so the reserved 22px of
   padding-right does not shift when it appears. */
/* Beside the FIRST line, not below the last. At `bottom:5px` it sat under the
   final line of a two-line cell, visually detached from the text it belongs to
   and below the fold of a cropped view -- present, working, and invisible to the
   person looking for it. Anchored to the top it is in the same place on every
   row, so it scans, and it does not move when the cell grows on expand. */
.dicttable .dc-more{position:absolute; right:3px; top:4px; width:18px; height:18px; padding:0; border:0; border-radius:4px; background:none; cursor:pointer; visibility:hidden}
.dicttable td.dc-desc.clamped .dc-more{visibility:visible}
/* A solid disclosure TRIANGLE, drawn with borders -- no SVG, no emoji, so this
   file stays the only place it is defined. Points right when closed and rotates
   to point down when open, which is the disclosure convention every file tree
   and <details> uses, so it reads as "there is more here" without a label.
   --ink-soft, not --ink-faint: at 5px the faint tone is nearly invisible against
   the row, which is half of why the control read as missing. */
.dicttable .dc-more::before{content:""; display:block; width:0; height:0; margin:5px auto;
  border-left:6px solid var(--ink-soft); border-top:4px solid transparent;
  border-bottom:4px solid transparent; transition:transform 150ms ease}
.dicttable .dc-more:hover{background:var(--cream)}
.dicttable .dc-more:hover::before{border-left-color:var(--ink)}
.dicttable .dc-more[aria-expanded="true"]::before{transform:rotate(90deg)}
.dicttable .dc-type{font-family:var(--mono); font-size:10.5px; color:var(--ink-faint)}
/* Right-aligned so the two numeric columns can be compared by scanning down. */
.dicttable .dc-num{font-family:var(--mono); font-size:10.5px; color:var(--ink-soft); text-align:right; white-space:nowrap}
.dicttable .cell-null{color:var(--ink-faint)}
/* The row is no longer a control -- no pointer, no hover fill, no focus ring.
   Only the triangle is clickable, which is the whole point of the change: an
   affordance you can see, on the thing it expands. */
.dicttable tr.dict-row:hover .dc-more{background:var(--cream)}
/* The observed values, under an expanded description. */
.dicttable .pv-row{margin-top:6px}

.foot-note{font-family:var(--mono); font-size:10px; color:var(--ink-faint); text-align:center; padding:8px}

/* ---------- Sign-in lock on Data Preview / SQL ---------- */
/* The blur is applied to LEAVES ONLY -- the hidden `td`s, the `.field` rows, and
   the `.sql-box` -- never to a wrapper. `filter` makes its element a filter
   root, so blurring any ancestor that also contains the sign-in card -- or any
   wrapper carrying a ::after tooltip -- would fade that along with it. A
   child cannot undo an ancestor's filter, so there is no way to walk that back;
   the only safe rule is to never put it on an ancestor in the first place.

   The blur is COSMETIC. Every row is still in the DOM -- see the comment at
   lockedPanelHtml() in artifact.js for why that is an accepted trade today and
   where to change it if real PHI ever lands. user-select and pointer-events
   make it harder to lift by accident; they are not a control. */
.locked{display:flex; flex-direction:column}
/* Clipped as well as blurred, and the card sits BELOW rather than over it.
   Two earlier versions of this were wrong in ways only a screenshot showed:
   uncapped, the card centred over 3,000px of blurred table and sat below the
   fold; overlaid, the card's own text collided with the sharp rows it exists to
   tease. Capped + stacked keeps the readable rows readable and the card
   readable. The mask fades the cut edge so the clip does not read as a
   rendering bug -- same property the page's dot grid already uses. */
.locked-body{user-select:none; pointer-events:none; max-height:260px; overflow:hidden;
  mask-image:linear-gradient(to bottom, #000 62%, transparent 100%);
  -webkit-mask-image:linear-gradient(to bottom, #000 62%, transparent 100%)}
/* THE WHOLE DATA ANALYSIS STEP, in one rule.
   The rows and the query blurred separately while they were two tabs, and the
   lede and the two screenshots were deliberately left legible on the grounds
   that documentation behind a blur is documentation withheld. On one screen that
   stopped holding: the box above the table states how many of the cohort's
   subjects are on it, which is a fact about a table nobody can read, and a sharp
   paragraph between two blurred cards reads as a rendering fault rather than as
   a wall. The Schema tab is never locked. */
.locked-body .sqltab{filter:blur(5px)}

/* One card for Access Data. Logo, one line saying what is
   being asked for, the button, and the way to get an account -- see
   lockedPanelHtml for why the per-tab headline and paragraph are gone. */
.lockcard{display:flex; flex-direction:column; align-items:center; text-align:center;
  gap:14px; padding:22px 24px 8px}
/* The wordmark IS the identity line, so there is no heading above it. Height,
   not width, because logo.webp is a wide lockup and a width cap would shrink it
   to nothing in the narrow pane. */
.lockcard-logo{height:30px; width:auto; display:block}
.lockcard-lede{font-size:14px; color:var(--ink); max-width:40ch; line-height:1.5}
.lockcard .sb-signin{width:auto; flex:none}
/* "New to Healthcare Datahub? Create an account", wherever a sign-in is
   offered -- the panel's lock card and the chat's sign-in hand-off both emit it
   from `createAccountHtml`, so one rule dresses both. It has to exist at all
   because every sign-in here opens Redivis's OAuth page, whose own "Create one"
   is hardcoded to their generic front door and cannot be made organisation-aware
   (measured). */
.create-account{font-size:12px; color:var(--ink-faint)}
/* Underlined rather than coloured: it sits inside dimmed text as the one thing
   that is not the primary action, and a second accent colour beside the button
   would read as a second button.
   inline-block + padding, with the padding taken back off in margin, so the HIT
   AREA grows without the line moving. Measured before this: 60x14px, which is a
   fifth of the 44px guidance and small enough that a click landing a couple of
   pixels high hits the dimmed text beside it and silently does nothing --
   indistinguishable, from the user's side, from a broken link. */
.create-account a{display:inline-block; padding:8px 6px; margin:-8px -6px;
  color:var(--ink-soft); text-decoration:underline; text-underline-offset:2px; border-radius:6px}
.create-account a:hover{color:var(--ink); background:var(--cream)}

/* ---------- Cohort hand-off to Redivis ----------
   .handoff-btn is an <a> when the dataset's Redivis url is resolved and a
   <button> when it is not; both wear .rec-open, the shared blue pill declared
   with it above. There is ONE control now -- the notebook button and the
   four-step .handoff-steps list it sat under are gone, replaced by the SQL
   tab's two illustrated .sql-step panels. */
.handoff-note{font-size:11.5px; color:var(--ink-faint); line-height:1.45; max-width:62ch}
/* It renders empty until a click writes the copy's outcome into it, and an empty
   flex child still spends the column's gap. */
.handoff-note:empty{display:none}

/* The getting-started guide: a native <dialog> on <body>, outside the panel, so
   no render can destroy it. The body scrolls; the head and foot stay put. */
.guide-dialog{width:min(1100px, 92vw); max-height:90vh; margin:auto; padding:0; border:0; border-radius:14px;
  background:var(--panel); color:var(--ink); box-shadow:0 24px 60px rgba(15,23,42,.25);
  flex-direction:column; overflow:hidden}
.guide-dialog[open]{display:flex}
.guide-dialog::backdrop{background:rgba(15,23,42,.45)}
.guide-head{flex:none; display:flex; align-items:flex-start; justify-content:space-between; gap:16px;
  padding:20px 24px 16px; border-bottom:1px solid var(--line)}
.guide-head .eyebrow{font-family:var(--mono); font-size:10px; letter-spacing:.09em; text-transform:uppercase; color:var(--blue-text)}
.guide-head h2{font-family:var(--disp); font-weight:600; font-size:20px; letter-spacing:-0.01em; line-height:1.2; margin:6px 0 0}
.guide-close{flex:none; width:34px; height:34px; border:0; border-radius:9px; background:var(--cream); color:var(--ink-soft);
  display:inline-flex; align-items:center; justify-content:center; cursor:pointer}
.guide-close:hover{color:var(--ink); background:var(--line)}
.guide-close svg{width:14px; height:14px; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round}
.guide-body{flex:1 1 auto; overflow-y:auto; padding:18px 24px}
.guide-body .sql-steps{margin-top:0}
.guide-foot{flex:none; display:flex; flex-direction:column; align-items:center; gap:6px;
  padding:14px 24px 18px; border-top:1px solid var(--line); background:var(--cream)}
.guide-action .handoff-btn{padding:10px 18px; font-size:13px}
.guide-foot .handoff-note{text-align:center}

/* ---- Demographics tab ---------------------------------------------------
   Every colour here resolves to an existing token. --good/--warn/--bad are
   deliberately absent: they mean pass/warn/fail on the validation rows and the
   filter-card status line, and a green bar in a chart would read as a badge
   rather than as data. charts.js reads --blue off :root and ramps toward white
   from there, so a brand change lands in one place. */
.dhead{margin-bottom:14px}
/* THE headline object: the number, what it counts, and what it counts out of.
   Boxed in --blue-bg so it reads as one figure rather than as stacked lines, and
   so the count is visibly the subject of the panel rather than the first row of
   it.

   Its own token, no new hue -- the same --blue-bg every selected control in
   this panel already uses.

   THE NUMBER, AND ONE WAY FORWARD OUT OF IT. Approve and Cancel lived in here
   for a while, because a button that confirms a figure has to be beside the
   figure. They are in the pinned `.dstep-draft` bar now -- see `.approve-btn`
   for why that stopped being the right trade once the draft phase's count
   stopped moving. What is in here instead is Preview Data, and only on a
   DELIVERED cohort: that button confirms nothing and changes nothing, it just
   names where a finished number leads.

   `wrap` plus `1 1 220px` on the figure so the button drops onto its own line at
   the 340px minimum pane width rather than squeezing the numeral, and
   `min-width:0` over the automatic minimum the big mono glyphs carry. */
.dhead-count{background:var(--blue-bg); border-radius:14px; padding:16px 18px;
  display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:10px 16px}
.dhead-figure{flex:1 1 220px; min-width:0}
/* The share bar that used to span this tile is gone, and nothing replaced it in
   kind. It showed the cohort as a proportion of its dataset; a real cohort is
   routinely a fraction of a percent of that, so it lived pinned at its own 1.5%
   minimum width -- an unmoving stub under a ratio the two lines above already
   state in full. "4,218 subjects in cohort / of 878,644 total" is the fact.

   The slot now carries the way FORWARD from a delivered count, which is the
   question a finished number actually raises. Not `flex:0 0 100%`: the button
   sits BESIDE the figure column, where the eye already is. */
.dhead-next{flex:none; align-self:center}
/* population_note is no longer styled here. It is the provenance line -- true of
   every row, recorded nowhere in the data -- and it used to render as a blue box
   at the top of this tab, which put a paragraph of dataset description between
   the user and the charts. It is now a `cw-kv` row on the Schema tab beside Data
   model and Vocabularies, which is where the other card-level facts live. */

/* AT MOST TWO PER ROW, and every card the same size.
   The profile is a fixed sequence of forms -- map, age, sex, then the rest --
   and the pairing is part of how it reads: two cards of equal weight per row,
   each wide enough for a legend of "label  count  share". A third column
   appeared at wide panel widths and squeezed all three.

   `max(320px, calc(50% - 7px))` is what caps it at two WITHOUT a container
   query. A container query cannot ask about the element it is declared on, so
   `container-type` here plus `@container` on `.dcharts` matches nothing -- the
   percentage does the same job in one line, because three tracks of 50% cannot
   fit however wide the panel gets. The 320px floor is what collapses it to one
   column at the 340px panel minimum, where two 150px cards are two unreadable
   ones.

   align-items:STRETCH, so the two cards in a row are the same height. It was
   `start`, on the reasoning that a short card stretched to its tallest
   neighbour ends in a band of dead space -- true only while the chart stayed
   pinned to the top of the card it had outgrown. `.dchart-body` centres its
   chart in whatever height the row settles at, so the remainder is split above
   and below and reads as air. What `start` produced instead was Age and Sex --
   one row, one pair of facts about the same population -- as two cards of
   visibly different size, with the shorter one's bottom edge floating, and the
   difference set by how many age bands a dataset happens to have rather than by
   anything either card is saying. */
.dcharts{display:grid; grid-template-columns:repeat(auto-fit, minmax(max(320px, calc(50% - 7px)), 1fr));
  align-items:stretch; gap:14px}
.dprofile{margin-top:14px; padding:16px 0 4px; border-top:1px solid var(--line)}
/* The heading and the view toggle share one row, the toggle pushed right. It
   WRAPS, because at the 340px panel minimum a heading plus a two-segment control
   does not fit on one line and a clipped toggle is a control you cannot press. */
.dprofile-head{display:flex; align-items:center; justify-content:space-between; gap:10px;
  flex-wrap:wrap; margin-bottom:10px}
.dprofile-head h3{font-family:var(--disp); font-size:15px; font-weight:600; color:var(--ink)}
/* Graphs | TableOne, as a SEGMENTED CONTROL rather than a row of underlined tabs.
   The underline treatment belongs to a tab strip that switches a page's content
   between many siblings; this is a binary choice between two readings of the
   SAME content, and a tinted pill inside a bordered track is what that reads as.
   It also stops the profile's own toggle looking like the journey rail's
   underline one row above it.
   No `margin-left:auto`: `justify-content` on the row does that, and an auto
   margin would fight the wrap. */
.pview{display:inline-flex; flex:none; gap:2px; padding:2px; border:1px solid var(--line-strong);
  border-radius:10px; background:var(--panel)}
.pview button{border:0; background:transparent; border-radius:8px; cursor:pointer;
  font-family:var(--body); font-size:12.5px; font-weight:500; color:var(--ink-soft);
  padding:6px 14px; white-space:nowrap; transition:background .12s, color .12s}
.pview button:hover:not(.on){color:var(--ink)}
/* The active segment carries BOTH the tint and the weight. Tint alone at the
   same weight reads as a hover state on a control whose two halves are
   otherwise identical. */
.pview button.on{background:var(--blue-bg); color:var(--blue-text); font-weight:600}
.pview button:focus-visible{outline:2px solid var(--blue); outline-offset:1px}

/* ---------- TableOne ----------
   The cohort's baseline characteristics as a published table would print them.
   Mono for the numbers and for nothing else: a Table 1 is read by running an eye
   DOWN a column, which only lines up on figures of equal width.
   The wrapper scrolls on its own so a long variable list does not set the
   panel's height -- the same bargain `.sql-box` and `.rowtable-wrap` make. */
.t1wrap{border:1px solid var(--line); border-radius:12px; overflow:auto; max-height:520px}
.t1table{border-collapse:collapse; width:100%; font-size:12px}
.t1table th{position:sticky; top:0; z-index:1; background:var(--panel); text-align:left;
  font-size:10.5px; font-weight:600; letter-spacing:.04em; text-transform:uppercase;
  color:var(--ink-faint); padding:9px 12px; border-bottom:1px solid var(--line-strong)}
.t1table td{padding:7px 12px; border-bottom:1px solid var(--line); color:var(--ink-soft)}
.t1table tbody tr:last-child td{border-bottom:0}
.t1-var{min-width:0; word-break:break-word}
/* RIGHT-ALIGNED and monospaced, so 1,033,995 and 291 line up on their last
   digit. Numbers in a proportional face in a table are a column you cannot
   compare down. */
.t1-num{text-align:right; font-family:var(--mono); font-size:11px; white-space:nowrap}
th.t1-num{font-family:var(--body)}
/* The cohort's size leads the table and is the one row that is not a proportion
   of anything, so it carries the emphasis every row under it is read against. */
.t1table tr.t1-total td{color:var(--ink); font-weight:600; background:var(--cream)}
/* What the percentages are OF. Directly under the table because it is about the
   table, not about the section. */
.t1note{margin-top:9px; font-size:11px; color:var(--ink-faint); line-height:1.5}
/* A BORDERED CARD, not a section separated by a rule.
   The profile used to be one surface with hairlines between its charts, which
   was right when the charts were controls in a page of controls -- they read as
   part of the same filtering apparatus. On the draft phase they are the whole
   content, each a self-contained answer about one axis, and a card says that
   where a rule between two of them does not. */
.dchart{padding:14px 16px 16px; border:1px solid var(--line); border-radius:11px; background:var(--panel);
  /* A column, so the body below can claim the height the stretched card was
     given and centre its chart in it. */
  display:flex; flex-direction:column;
  /* min-width:0 so a wide SVG or a long label cannot set a floor that pushes
     the grid past the 340px minimum panel width -- .abody is overflow-y:auto,
     so the scrollbar eats inline size too. */
  min-width:0}
/* A map is wide-aspect (2.29:1 for the world, 1.5:1 for the US) and shapeMap
   deliberately has no upscale cap, so it takes the whole row. */
.dchart.wide{grid-column:1 / -1}
.dchart-head{display:flex; align-items:baseline; gap:8px; margin-bottom:10px}
.dchart-head h4{font-family:var(--disp); font-size:13px; font-weight:600; color:var(--ink)}
.dchart-note{font-family:var(--mono); font-size:9.5px; text-transform:uppercase; letter-spacing:.04em;
  color:var(--ink-faint); background:var(--cream); border-radius:5px; padding:2px 6px}
.dchart-note.warn{color:var(--warn); background:var(--warn-bg)}
/* The chart sits in the MIDDLE of whatever height the row settled at.
   `flex:1` claims the card's spare height and `align-content:center` splits it
   above and below the chart, which is what lets `.dcharts` stretch a pair to a
   common height without the shorter card's chart hanging from its top edge. A
   grid rather than a flex column: these children are ordinary blocks (an SVG, a
   legend, a note) and grid centres the whole stack while still letting each of
   them fill the width. */
.dchart-body{overflow-x:auto; flex:1; display:grid; align-content:center}
.dchart-unmapped{margin-top:8px; padding-top:8px; border-top:1px dashed var(--line-strong)}

.chart-empty{font-size:11.5px; color:var(--ink-faint); padding:6px 0}
.chart-legend{display:flex; flex-wrap:wrap; align-items:center; gap:12px; margin-top:9px;
  font-size:11px; color:var(--ink-soft)}
.chart-legend span{display:inline-flex; align-items:center; gap:5px}
.chart-legend i{width:9px; height:9px; border-radius:2px; display:inline-block}
.chart-scale{gap:7px; font-family:var(--mono); font-size:10px; color:var(--ink-faint)}
.chart-scale .scale-bar{flex:1; max-width:120px; height:7px; border-radius:4px}

/* A proportion bar's legend: the swatch carries identity, the text carries the
   numbers. Clickable for the same reason the segments are -- a 4% slice is a
   poor hit target -- so it is a <button> whenever the axis can be filtered. */
/* Capped at the bar's own maximum width (320 x MAX_UPSCALE) so the counts stay
   beside the chart they belong to instead of flying to the far edge of a wide
   card. */
.chart-keys{display:flex; flex-direction:column; gap:1px; margin-top:9px; max-width:416px}
.chart-key{display:flex; align-items:center; gap:7px; width:100%; padding:2px 0;
  border:0; background:transparent; font-family:var(--body); font-size:11.5px; color:var(--ink-soft);
  text-align:left}
button.chart-key{cursor:pointer; border-radius:5px; padding-left:4px; padding-right:4px; margin-left:-4px;
  transition:background-color .08s ease}
/* The same near-white band the SVG marks get, so pointing at a legend row and
   pointing at the segment it names look like the same gesture. It used to
   recolour the row's text blue, which is the highlight this panel replaced --
   --blue-text on white is legible, but a row that changes COLOUR reads as a
   change of state rather than as the pointer. */
button.chart-key:hover, button.chart-key:focus-visible{background:var(--blue-bg); outline:none}
@media (prefers-reduced-motion:reduce){button.chart-key{transition:none}}
.chart-key i{width:9px; height:9px; border-radius:2px; flex:none}
.chart-key-label{flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--ink)}
.chart-key b{font-family:var(--mono); font-size:10.5px; font-weight:400; color:var(--ink-soft)}
.chart-key em{font-family:var(--mono); font-size:10.5px; font-style:normal; color:var(--ink-faint); min-width:30px;
  text-align:right}
/* A donut and its legend, side by side -- the ring is a fixed 132px and the
   keys take the rest. It wraps below ~300px of card, where the two abreast
   would leave the labels a few characters wide. The legend's own `margin-top`
   is zeroed because here it sits BESIDE the chart rather than under it. */
.chart-donut{display:flex; align-items:center; gap:16px; flex-wrap:wrap}
.chart-donut .chart-keys{flex:1 1 150px; min-width:0; margin-top:0}
/* A ring segment. Butt caps, so two adjacent segments meet cleanly instead of
   overlapping by half a stroke width -- at 22px that overlap is visible as one
   colour eating into its neighbour.

   `hoverable` sets [data-hover] on the mark ITSELF here, not on a wrapping <g>:
   a segment is one path with nothing behind it, so there is no group to build.
   That is why this selector has no `>` where .chart-bar's does. */
.chart-arc{stroke-linecap:butt; transition:stroke-width .08s ease}
.chart-arc[data-hover]{stroke-width:26}
@media (prefers-reduced-motion:reduce){.chart-arc{transition:none}}
/* The unit under a histogram, and its one instruction. Not a chart title: the
   card head already says what the axis is, this says what it is measured in. */
.chart-axis-note{margin-top:5px; font-size:10.5px; color:var(--ink-faint)}
/* "Show all N" -- the bound as a door rather than a fact. Matches .dadv-more. */
.chart-more{border:0; background:transparent; color:var(--blue-text); font-family:var(--body);
  font-size:11.5px; text-decoration:underline; cursor:pointer; padding:5px 2px 0}
/* One tooltip for the whole app, parked on <body> so it is never clipped by a
   card's own overflow. It ENHANCES: every number in it is also on screen. */
.chart-tip{position:fixed; z-index:60; pointer-events:none; opacity:0; transition:opacity .1s ease;
  background:var(--ink); color:#fff; font-family:var(--body); font-size:11.5px; line-height:1.35;
  border-radius:7px; padding:6px 9px; max-width:240px; box-shadow:0 4px 14px rgba(0,0,0,.16)}
@media (prefers-reduced-motion:reduce){.chart-tip{transition:none}}

/* THE HOVER BAND.
   `.chart-scrub` is the transparent full-extent rect charts.js puts behind
   every mark: the hit area for a row whose bar is 9px of a 22px row (or zero
   px, once a filter has emptied it), and the shape this paints.

   Near-white rather than white. A white highlight on a --panel card is
   invisible, so what "white" has to mean here is the palest thing that still
   separates from the surface -- which is --blue-bg, the same tint the app uses
   for a selected surface everywhere else. It replaces recolouring the row's
   LABEL blue, which was legible at 2.99:1 only because the text was small and
   was the thing people found hard to see.

   Selection is NOT drawn here: a persistent band would be indistinguishable
   from the pointer's own. A chart that is also a control encodes its selection
   in the bar's own fill instead (blue ticked, grey not). */
.chart-scrub{transition:fill .08s ease}
/* Pointer-hover and keyboard-focus share one rule so they render identically
   and can't drift apart. A fill-only band read as too subtle to signal
   "clickable" on first encounter -- the hairline --blue stroke was already
   the keyboard-focus treatment below; this just stops it being exclusive to
   keyboard users. */
[data-hover] > .chart-scrub,
svg g[role="button"]:focus-visible > .chart-scrub{fill:var(--blue-bg); stroke:var(--blue); stroke-width:1.5}
/* The keyboard's version of the same thing. `interactive` makes every mark a
   tabbable role=button, and the UA's default focus ring is drawn around the
   group's whole bounding box -- which on a ranked row includes the label text
   and the count, boxing three things to point at one. */
svg g[role="button"]{outline:none}
svg path[role="button"]:focus-visible{outline:none; stroke:var(--blue); stroke-width:1.5}
/* A MAP has no band, because a tile and a country are their own full extent --
   there is no room behind them to light up. They take the same accent as an
   outline instead. The country's stroke-width is left alone: it is set in
   PROJECTED units (charts.js's `stroke = width / 900`), so a pixel value here
   would be thicker than several countries. */
[data-hover] > .chart-tile, svg g[role="button"]:focus-visible > .chart-tile{stroke:var(--blue); stroke-width:2}
svg path[data-hover]{stroke:var(--blue)}
/* A proportion bar's segment fills its slot, so it takes a white inset stroke
   rather than a band. `paint-order` keeps the stroke inside the fill instead of
   straddling the edge, which would eat 1px of the neighbouring segment. */
[data-hover] > .chart-seg{stroke:#fff; stroke-width:2.5; paint-order:stroke fill}
/* The MARK itself -- the actual bar/wing/column a value is drawn as, not just
   the band behind it. Ranked bars, pyramid wings, grouped columns and
   histogram bars carry a plain, class-less path/rect for their fill, so
   before this only .chart-scrub reacted to hover -- and a bar that is 9px of
   a 22px row has almost no band behind it, so hovering read as nothing
   happening. This is what actually highlights the portion under the pointer.
   --blue-text, not --blue: the fill is already --blue, and a same-colour
   stroke vanishes into it -- still one hue, never a second. Inset via
   paint-order so it reads as the bar's own rim, not bleeding into whatever
   sits beside it (a neighbouring pyramid wing, a segment in a proportion
   bar). */
.chart-bar{transition:stroke-width .08s ease}
[data-hover] > .chart-bar,
svg g[role="button"]:focus-visible > .chart-bar{stroke:var(--blue-text); stroke-width:2; paint-order:stroke fill}
@media (prefers-reduced-motion:reduce){.chart-scrub, .chart-bar{transition:none}}

/* The ACTIVE FILTER strip: what you have chosen, directly under the number it
   produced and above the controls that change it.

   IN FLOW, never sticky. It used to be `position:sticky; bottom:0` with a
   negative bottom margin, from when the charts owned this whole tab and a long
   chart list could push the commit button below the fold. The Cohort tab
   continues past it (the review banner, the criteria, the approve row), so a
   bottom-sticky bar pinned itself over that content and the negative margin
   pulled the rest up underneath: the moment a filter chip appeared, the banner
   was sliced in half and the criteria scrolled behind an opaque strip. That
   reasoning stands -- it is not becoming sticky again.

   ABSENT when nothing is selected, rather than showing an empty-state sentence.
   It carried "none yet -- click a bar, or a place on the map, to narrow the
   view", which described an interaction that does not exist on the filtering
   step (there is no bar there yet), and the checkbox panels below it are
   self-evidently the controls. */
.dfilters{margin:0 0 14px; padding:10px 12px; border:1px solid var(--line); border-radius:11px;
  display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap}
.dchips{display:flex; flex-wrap:wrap; align-items:center; gap:6px; min-width:0}
.dchips-lead{font-size:11.5px; color:var(--ink-faint); margin-right:2px}
.dchip{border:1px solid var(--line-strong); background:var(--blue-bg); color:var(--blue-text);
  border-radius:20px; padding:4px 10px; font-family:var(--body); font-size:11.5px; cursor:pointer;
  display:inline-flex; align-items:center; gap:5px}
.dchip b{font-weight:600; color:var(--ink)}
.dchip:hover{border-color:var(--blue)}
.dclear{flex:none; border:0; background:transparent; color:var(--ink-faint); font-family:var(--body);
  font-size:11.5px; text-decoration:underline; cursor:pointer; padding:4px 2px}
.dclear:hover{color:var(--ink)}
.dpreview-status{font-size:11.5px; color:var(--ink-faint); margin-bottom:12px}
.dpreview-status.error{color:var(--bad)}

/* WHAT THE USER COMMITTED TO, directly under the count it produced -- as chips
   on the draft phase, expanded with its codes once delivered. One box in the
   same position in both, because approving a cohort does not move the question
   the box answers.

   Bordered like `.dfilters` rather than tinted like the count tile: it is a
   statement ABOUT the number, not a second number.

   The draft chips are `<span>`s and look it -- no pointer, no hover, no ×.
   Nothing on that phase filters, and a chip that looked removable would be a
   control offering to edit a definition that is being approved. That is the
   whole visual difference from `.dchip`, which is a control and carries all
   three. */
.dcriteria,.dcriteria-full{margin:0 0 14px; padding:11px 13px; border:1px solid var(--line); border-radius:11px}
/* The lead and the chips share a line and wrap onto two when the panel narrows.
   Its own element rather than `.dcriteria` itself, so temporal logic and
   assumptions can sit UNDER the row instead of becoming flex items in it. */
.dcriteria-row{display:flex; align-items:baseline; justify-content:space-between; gap:12px; flex-wrap:wrap}
.dcriteria-lead{font-size:12.5px; color:var(--ink); margin:0; flex:1 1 240px; min-width:0}
.dcriteria-chips{display:flex; flex-wrap:wrap; align-items:baseline; gap:6px; min-width:0}
.dcriteria-label{font-size:11.5px; color:var(--ink-faint); margin-right:2px}
.dcrit-chip{border:1px solid var(--line-strong); background:var(--blue-bg); color:var(--blue-text);
  border-radius:20px; padding:4px 11px; font-size:11.5px}
/* The delivered form. `.lbl`'s first-child margin reset does not reach here --
   it is preceded by the lead paragraph -- so the first heading gets its top
   margin trimmed explicitly, and so does the unlabelled list under it. */
.dcriteria-full .dcriteria-lead{margin-bottom:10px}
.dcriteria-full > .lbl:nth-of-type(1){margin-top:14px}

/* "Refine Cohort" -- the step's forward action.
   Blue rather than --ink, unlike Approve: this one asks for a DRAFT, and a
   black button reads as the commitment that Approve actually is. Its own row
   under the panels, so it is the last thing in reading order on the filtering
   step. */
/* PINNED to the bottom of the scrollport, not the end of the document.
   Refine Cohort is the action the whole filter step exists to reach, and it sat
   below the Advanced section -- on CMS that is a full screen of checkboxes, so
   narrowing the population scrolled the button that acts on it out of sight,
   and the user had to scroll back down to something they had already decided.
   Same argument that moved Approve into the count's own tile.

   `sticky` inside #abody rather than a fixed footer outside it, so it needs no
   change to renderDataset's structure and it still occupies real space at the
   end of the document -- meaning the last checkbox is reachable rather than
   sitting permanently underneath the bar. The negative margins and the matching
   padding are what let the background span #abody's 20px gutters; without them
   the content scrolls visibly through the gap on either side. */
.dstep{position:sticky; bottom:-22px; z-index:4; margin:16px -20px -22px; padding:14px 20px 22px;
  border-top:1px solid var(--line); background:var(--panel);
  display:flex; align-items:center; gap:12px; flex-wrap:wrap}
/* The DRAFT phase's pair: Back on the left, Approve on the right, with the
   whole width between them. Pinned like the others, and for the stronger
   reason -- the charts above are a full screen on any real dataset, so a bar
   that scrolled away would take both the way forward and the way back with it. */
.dstep-draft{justify-content:space-between; flex-wrap:nowrap}
/* The FILTERING phase's one action sits flush right, where the draft phase's
   forward action (Access Data) sits, so the way forward never moves side. */
.dstep-filter{justify-content:flex-end}
/* Why Refine Cohort cannot be pressed, when the screen does not otherwise say:
   TEXT beside it, because a disabled button's own `title` is suppressed in
   several browsers. Takes the free width so the button stays flush right. */
.dstep-hint{flex:1 1 220px; margin:0; font-size:12.5px; line-height:1.4; color:var(--ink-soft)}
/* Step 3's Analyze bar sits where Refine Cohort does, at the bottom of the
   scrollport, even when the page is shorter than the pane: the body becomes a
   column, the two cards share whatever height is left, and the bar takes the
   remainder's end. Below the cards' minimum the body scrolls and the bar pins. */
/* Step 1's Preview Data bar, likewise pinned at the pane's bottom even under a
   short schema. */
.abody:has(> .dstep-schema){display:flex; flex-direction:column}
.abody:has(> .dstep-schema) > .dstep-schema{margin-top:auto}
.abody:has(> .dstep-analyze){display:flex; flex-direction:column}
.abody:has(> .dstep-analyze) > .sqltab{flex:1 1 0; padding-bottom:16px}
.abody:has(> .dstep-analyze) > .dstep-analyze{margin-top:auto}
.abody:has(> .dstep-analyze) .rowtable-wrap,
.abody:has(> .dstep-analyze) .sql-box{flex:1 1 0; min-height:220px; max-height:none}
.abody:has(> .dstep-analyze) .sql-wrap{flex:1 1 0; min-height:220px}
.back-to-filtering{flex:none; border:1px solid var(--line-strong); background:transparent; color:var(--ink);
  border-radius:10px; padding:10px 16px; font-family:var(--body); font-size:13px; font-weight:500;
  cursor:pointer; display:inline-flex; align-items:center; gap:7px}
.back-to-filtering:hover:not(:disabled){border-color:var(--blue); color:var(--blue-text)}
.back-to-filtering:disabled{opacity:.45; cursor:not-allowed}
.step-next{flex:none; border:0; background:var(--blue); color:#fff; border-radius:10px;
  padding:10px 18px; font-family:var(--body); font-size:13px; font-weight:600; cursor:pointer;
  display:inline-flex; align-items:center; gap:7px}
.step-next:hover{opacity:.9}
.step-next:disabled{opacity:.45; cursor:not-allowed}
/* THE REFINE NUDGE (artifact.js NUDGE_MS): after the selection changes, the
   button swells slightly and a ring spreads off it and fades -- three times,
   1.2s each, 3.6s in all -- then nothing until the next change. It was two
   0.6s rings at first, which testers missed: too quick to catch the eye
   from the filters above. The swell is 6% and settles before the ring does, so
   nothing under the pointer shifts for long. Never a loop -- motion that starts
   on its own must stop within five seconds (WCAG 2.2.2). */
@keyframes refine-nudge{
  0%{box-shadow:0 0 0 0 color-mix(in srgb, var(--blue) 70%, transparent); transform:scale(1)}
  15%{transform:scale(1.06)}
  30%{transform:scale(1)}
  75%,100%{box-shadow:0 0 0 18px color-mix(in srgb, var(--blue) 0%, transparent); transform:scale(1)}}
/* Reduced motion: no spreading ring, a STILL one for the same window -- a cue
   that does not move. Still an animation, so it ends by itself even when no
   repaint follows to take the class off. */
@keyframes refine-nudge-still{
  from{box-shadow:0 0 0 3px color-mix(in srgb, var(--blue) 35%, transparent)}
  to{box-shadow:0 0 0 3px color-mix(in srgb, var(--blue) 35%, transparent)}}
.step-next--nudge{animation:refine-nudge 1.2s ease-out 3}
@media (prefers-reduced-motion:reduce){ .step-next--nudge{animation:refine-nudge-still 3.6s step-end 1} }
/* "Build Cohort Again" -- the delivered view's only control, and pinned like
   the other two: it stands exactly where Back to Filtering and Approve Cohort
   were a moment earlier, so the bar keeps its place as the phase changes under
   it rather than the last button jumping to the end of the document.
   Outlined rather than filled: a delivered cohort is a result, and the loudest
   thing on that screen should be the result rather than the button that throws
   it away. */
.build-again{flex:none; border:1px solid var(--line-strong); background:transparent; color:var(--ink);
  border-radius:10px; padding:10px 18px; font-family:var(--body); font-size:13px; font-weight:500;
  cursor:pointer}
.build-again:hover:not(:disabled){border-color:var(--blue); color:var(--blue-text)}
.build-again:disabled{opacity:.45; cursor:not-allowed}

/* The denominator, on the NOUN's line ("subjects in cohort · of N total") and
   never inline with the count. "1,033,995 of 1,033,995" as one phrase reads as
   a fraction the user is meant to act on; muted, after a middle dot, it reads
   as the scale of the dataset the cohort came from, which is what it is. It
   used to be a third line of its own, and testers read the stack as two
   unrelated captions. An inline span inside `.sub`, so it wraps with the noun
   at a narrow panel rather than breaking away from it. */
.dhead-total{font-family:var(--mono); font-size:11.5px; color:var(--ink-faint); white-space:nowrap}

/* `.reccard .count` is gone. The code-match figure moved into the card's header
   row as a share bar (`.rec-share` above), so there is no longer a number at the
   foot of the card to style -- and no `.count.none` either, since a count that
   could not be taken now renders nothing at all rather than a reason. */
/* "Which population am I looking at" -- shown only when the dataset holds other
   cohorts these charts do not cover. --warn, not --blue: it is a caveat about
   completeness, which is the same thing the PARTIAL badge means. */
.dsubject{margin-top:9px; font-size:12px; color:var(--warn); background:var(--warn-bg);
  border-radius:10px; padding:9px 12px; line-height:1.5}
.dsubject b{font-family:var(--mono); font-size:11.5px; font-weight:500}

/* ---- Advanced filters -------------------------------------------------
   Everything the dataset lets a user narrow on that is NOT a demographic:
   risk-factor flags, care setting, payer, vital status. Checkboxes rather
   than charts, deliberately -- a bar chart of "Hemophilia" claims to describe
   the shape of a population; a checklist says "here is what you can narrow
   by", which is what these are for.

   Built on the .field disclosure (the keyword-filter groups' idiom) so the
   marker suppression, the padding and the global summary:focus-visible ring
   all come for free. */
.dadv{margin:14px 0 0}
.dadv summary{gap:9px; font-size:13px}
/* The section's OWN summary is tinted to match .dcard-head, so Demographics and
   Additional Filters read as the panel's two top-level groups rather than as a
   card with a loose disclosure under it. Deliberately scoped with `>` to the
   top-level summary: the per-axis panels NESTED inside (Condition, Drug, ...)
   stay untinted, or the section would be a stack of identical grey bars with no
   indication which one opens the others.
   The rule under it only exists while the section is open -- a closed
   disclosure is a bar, and a bar with a line under it reads as an empty box. */
.dadv > summary{background:var(--cream)}
.dadv[open] > summary{border-bottom:1px solid var(--line)}
.dadv[open] > summary + .fb{border-top:0}
.dadv > summary > .dadv-name{font-family:var(--disp); font-size:15px}
.dadv-name{font-family:var(--disp); font-weight:600; color:var(--ink); flex:1}
.dadv-meta{font-size:11.5px; color:var(--ink-faint)}
/* An active filter can never be invisible: the section is forced open when one
   exists, and this says how many even in the instant before that lands. */
.dadv-selected{font-family:var(--mono); font-size:10.5px; color:var(--blue-text);
  background:var(--blue-bg); border-radius:5px; padding:2px 7px}
/* The triangle. Drawn rather than a glyph so it can rotate: ::-webkit-details-marker
   is suppressed by .field summary and the native marker is unstyleable anyway. */
.dadv-caret{width:0; height:0; flex:none;
  border-left:5px solid var(--ink-faint); border-top:4px solid transparent; border-bottom:4px solid transparent;
  transition:transform .12s ease}
.dadv[open] > summary .dadv-caret{transform:rotate(90deg)}
@media (prefers-reduced-motion:reduce){.dadv-caret{transition:none}}
/* Stacked, not wrapped into columns.
   The advanced axes are now disclosures of their own, and a row of collapsed
   disclosures side by side reads as a set of unrelated cards rather than as a
   list of things you can open. It also made "Condition" and "Drug" sit at
   different heights the moment one of them was expanded. */
.dadv-body{display:flex; flex-direction:column; gap:8px; padding-top:12px}
.dadv-values{display:flex; flex-direction:column; gap:1px}
.dadv-row{display:flex; align-items:center; gap:8px; padding:3px 0; cursor:pointer;
  font-size:12.5px; color:var(--ink)}
.dadv-row:hover{color:var(--blue-text)}
.dadv-row input{margin:0; accent-color:var(--blue); cursor:pointer}
/* Not-recorded is a real bucket, not a category -- same faint italic the bars
   give it, so the two renderings agree about what it is. */
.dadv-row.missing{color:var(--ink-faint); font-style:italic}
.dadv-row:has(input:disabled){cursor:default; color:var(--ink-faint)}
.dadv-row:has(input:disabled) input{cursor:default}
.dadv-label{flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
.dadv-count{font-family:var(--mono); font-size:10.5px; color:var(--ink-soft)}
/* "Show all N", not "+N more not shown": the hidden value may be the one being
   filtered for, so on a FILTER the remainder has to be reachable. */
.dadv-more{border:0; background:transparent; color:var(--blue-text); font-family:var(--body);
  font-size:11.5px; text-decoration:underline; cursor:pointer; padding:5px 2px 0}

/* ===========================================================================
   RELATED DATASETS
   ===========================================================================
   The companions of the plan this dataset was opened from: datasets holding
   something the anchor lacks, with NO validated join to it. Built on `.dadv`
   so it reads as a sibling of Additional Filters -- same tinted summary, same
   caret, same focus ring -- because at filtering time it is one more thing to
   choose from.

   It carries NO count and NO measured numbers, deliberately. The `.rec-link`
   markup on the recommendation card states containment percentages and shared
   value counts because those describe a link that was MEASURED; there is none
   here, and borrowing that vocabulary would assert one. */
.drel-lead{font-size:12px; line-height:1.5; color:var(--ink-soft); padding-top:12px}
.drel-list{display:flex; flex-direction:column; gap:8px; padding-top:10px}
/* A left rule in --blue rather than a full border: the row is a description, not
   a box to fill in, and the accent is what ties it to the switch on its right. */
.drel-item{display:flex; align-items:flex-start; gap:12px; padding:10px 12px;
  border:1px solid var(--line); border-left:3px solid var(--line-strong); border-radius:9px}
.drel-item.on{border-left-color:var(--blue); background:var(--blue-bg)}
.drel-body{flex:1; min-width:0}
.drel-head{display:flex; align-items:center; gap:6px; min-width:0}
/* Linked and unlinked share the class and the weight -- only the underline and
   the pointer differ -- so a name that Redivis has not resolved yet does not
   change size when it does. */
.drel-name{font-family:var(--disp); font-size:13px; font-weight:600; color:var(--blue-text);
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap}
a.drel-name{text-decoration:underline}
a.drel-name:hover{color:var(--blue)}
.drel-why{font-size:11.5px; line-height:1.45; color:var(--ink-soft); margin-top:3px}
/* A REAL checkbox under a drawn track, visually hidden rather than removed, so
   the control keeps its own keyboard behaviour and its own announcement instead
   of a div claiming a role it does not have. */
.drel-switch{position:relative; flex:none; width:34px; height:20px; cursor:pointer}
.drel-switch input{position:absolute; inset:0; width:100%; height:100%; margin:0; opacity:0; cursor:pointer}
.drel-track{display:block; width:100%; height:100%; border-radius:99px;
  background:var(--line-strong); transition:background .15s ease}
.drel-track::after{content:""; position:absolute; top:2px; left:2px; width:16px; height:16px;
  border-radius:50%; background:var(--panel); transition:transform .15s ease}
.drel-switch input:checked ~ .drel-track{background:var(--blue)}
.drel-switch input:checked ~ .drel-track::after{transform:translateX(14px)}
.drel-switch input:focus-visible ~ .drel-track{outline:2px solid var(--blue); outline-offset:2px}
.drel-switch input:disabled{cursor:default}
.drel-switch input:disabled ~ .drel-track{opacity:.55}
@media (prefers-reduced-motion:reduce){
  .drel-track, .drel-track::after{transition:none}
}

/* One ADVANCED filter axis, as a collapsible checkbox panel.
   The same .field disclosure the Advanced section itself is built on, so the
   marker suppression, the padding and the summary focus ring all come for free
   -- and so the nested Condition/Drug/Device panels INSIDE Advanced are visibly
   the same kind of control as the section that holds them.
   The `.dfpanels` stack that used to put the DEMOGRAPHIC axes in this same form
   below the charts is gone with the draft phase's controls: demographics are
   the `.dcard` on the filtering page, and the draft phase filters nothing. */
.dfpanel > summary{gap:9px; font-size:13px}
.dfpanel > summary .dadv-name{flex:1}
.dfpanel[open] > summary .dadv-caret{transform:rotate(90deg)}
/* Count of ticked values, in the summary, so a collapsed panel still says it is
   doing something. Same treatment as .dadv-selected one level up -- the rule
   that an active filter can never be invisible applies at both levels. */
.dfpanel-active{font-family:var(--mono); font-size:10.5px; color:var(--blue-text);
  background:var(--blue-bg); border-radius:5px; padding:2px 7px}
.dfpanel > .fb{padding-top:10px}
/* Long panels scroll rather than growing without bound: CMS's Condition axis is
   100 values, and "Show all" on it would otherwise push the Refine button a
   full screen down. max-height, not a hard height, so a five-value panel stays
   five values tall. */
.dfpanel .dadv-values{max-height:280px; overflow-y:auto}
.dfpanel-foot:empty{display:none}
/* Says what is drawn against what exists, when a complete facet is larger than
   MAX_RENDERED_ROWS. Not a warning colour: nothing is missing, it is one search
   away -- see the constant's comment in demographics.js. */
.dadv-note{font-size:11px; color:var(--ink-faint); padding:6px 2px 0; line-height:1.45}
/* Client-side only: it filters the rows already loaded and never fetches. Shown
   only above PANEL_SEARCH_THRESHOLD values (demographics.js), because below it
   "Show all N" already reaches everything in one click. */
.dfpanel-search{width:100%; box-sizing:border-box; margin-bottom:8px;
  border:1px solid var(--line-strong); border-radius:8px; padding:7px 10px;
  font-family:var(--body); font-size:12.5px; color:var(--ink); background:var(--cream)}
.dfpanel-search:focus{outline:none; border-color:var(--blue); background:var(--panel)}
.dfpanel-search::placeholder{color:var(--ink-faint)}

/* ===========================================================================
   The Demographics card -- the filter step's primary control
   ===========================================================================
   One bordered card holding every demographic axis, each drawn as the control
   its data calls for. See demographics.js's renderDemographicsCard for why they
   are no longer all the same checkbox list.

   The HEAD is tinted with --cream, and .dadv's summary is tinted to match: the
   two are the panel's two top-level groups ("who these people are" and
   "everything else you can narrow by"), and a shared header surface is what
   makes them read as siblings rather than as a card and a stray disclosure. */
/* NO `overflow:hidden`, which is what a bordered card with a tinted header
   wants and what this had. It clipped the geography dropdown to the two rows
   that happened to fit above the card's bottom edge -- an absolutely positioned
   menu cannot escape a clipping ancestor. The header keeps the corners instead,
   by rounding its own top two. */
.dcard{border:1px solid var(--line); border-radius:11px; margin-bottom:9px}
.dcard-head{background:var(--cream); border-bottom:1px solid var(--line); padding:12px 16px;
  border-radius:10px 10px 0 0}
.dcard-head h3{font-family:var(--disp); font-size:15px; font-weight:600; color:var(--ink)}
/* The one thing that ever joins the heading: a statement that a carried code
   selection could not be turned into ticks. --warn rather than --bad, because
   nothing failed -- the selection IS narrowing the count above; it simply
   cannot be shown as a checkbox here. */
.dcard-note{margin-top:6px; font-size:11.5px; line-height:1.45; color:var(--warn)}
.dcard-body{padding:2px 16px 4px}

/* One axis. The rule between sections is what keeps four different control
   types from reading as one undifferentiated form; :last-child drops it so the
   card does not end in a line above its own border. */
.dcard-section{padding:14px 0; border-bottom:1px solid var(--line); min-width:0}
.dcard-section:last-child{border-bottom:0}
.dcard-grid > .dcard-section:last-child{border-bottom:1px solid var(--line)}
.dcard-label{display:flex; align-items:baseline; gap:8px; margin-bottom:9px}
/* Uppercase and SMALL, but not quiet. These name the thing a user is about to
   filter on, so they have to be findable when scanning the card -- and at
   `--mono` 10px in `--ink-soft` they were neither: a mono face has no bold to
   fall back on at that size, and the grey put them behind the values they
   label. `--body` (Inter) holds 700 legibly at 11px, and `--ink` puts them in
   front. Still uppercase and letterspaced rather than a heading size, because
   six headings down one card would compete with the card's own title. */
.dcard-label h4{font-family:var(--body); font-size:11px; font-weight:700; letter-spacing:.07em;
  text-transform:uppercase; color:var(--ink)}
.dcard-active{margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--blue-text)}
/* The head's right-hand controls -- Select all, and the not-recorded decision.
   `margin-left:auto` on the GROUP, so they sit at the right edge together and
   line up whether one or both are present; the `.dcard-active` count above
   loses its own auto-margin when they are there, which is what keeps it beside
   the heading rather than colliding with them. */
.dcard-head-controls{margin-left:auto; display:flex; align-items:center; gap:14px; flex:none}
.dcard-label .dcard-active ~ .dcard-head-controls{margin-left:0}
.dcard-toggle{display:inline-flex; align-items:center; gap:6px; cursor:pointer;
  font-family:var(--body); font-size:11.5px; color:var(--ink-soft); white-space:nowrap}
.dcard-toggle:hover{color:var(--blue-text)}
.dcard-toggle input{margin:0; accent-color:var(--blue); cursor:pointer}
.dcard-toggle:has(input:checked){color:var(--blue-text)}
.dcard-toggle:has(input:disabled){cursor:default; color:var(--ink-faint)}
.dcard-toggle:has(input:disabled) input{cursor:default}

/* THE WHOLE CARD, while a count is in flight.
   Every control inside is genuinely `disabled` as well -- a class alone would
   look dead and still be clickable -- but one rule on the host is what stops
   the user discovering the state control by control. `pointer-events:none` is
   the belt to that braces: a <label> wrapping a disabled input still takes a
   click, and a `<details>` summary is not disable-able at all. */
.dcard-body.is-locked{opacity:.55; pointer-events:none}
.dcard-body.is-locked *{cursor:default !important}
.rslider.is-locked{opacity:.6}
/* The advanced panels dim but stay CLICKABLE, because they are <details>: a
   user has to be able to keep reading a section that is open while the count
   catches up, and a summary cannot be disabled. Their checkboxes are each
   genuinely disabled. */
.dadv-body.is-locked{opacity:.55}
.dadv-body.is-locked .dadv-row{cursor:default}
/* In an advanced panel's <summary>, where the flex row already has a stretched
   `.dadv-name` doing the spacing. */
.dfpanel > summary .dcard-head-controls{margin-left:0; gap:12px}

/* TWO PER ROW, stacking below the panel's narrow width. A grid rather than
   wrapped pairs, so an odd count leaves one full-width cell instead of a
   half-empty row. container-type is on .dcard-body: the panel is resizable and
   a viewport media query would be answering the wrong question. */
.dcard-body{container-type:inline-size}
.dcard-grid{display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:0 22px}
.dcard-grid > .dcard-section{border-bottom:1px solid var(--line)}
@container (width < 560px){ .dcard-grid{grid-template-columns:1fr} }

/* Sex, as equal-width toggle tiles. flex:1 on every tile is what makes them
   equal at any count; wrapping past four keeps six categories readable instead
   of six slivers. */
.dtabs{display:flex; flex-wrap:wrap; gap:8px}
/* `position:relative` is load-bearing, for the reason `.pstep` carries it: the
   unobserved tile's reason is an `.sr-only` span, which is `position:absolute`,
   and with no positioned ancestor its containing block is `<body>` -- one pixel
   of text escaping the panel's scrollport as document overflow. */
.dtab{position:relative; flex:1 1 160px; display:flex; align-items:center; gap:8px; min-width:0;
  border:1px solid var(--line-strong); border-radius:10px; background:var(--panel);
  padding:11px 14px; font-family:var(--body); font-size:13px; color:var(--ink); cursor:pointer}
.dtab:hover:not(:disabled){border-color:var(--blue)}
.dtab:disabled{cursor:default; color:var(--ink-faint)}
.dtab.on{border-color:var(--blue); background:var(--blue-bg); color:var(--blue-text)}
.dtab-dot{flex:none; width:8px; height:8px; border-radius:50%; background:var(--line-strong)}
.dtab.on .dtab-dot{background:var(--blue)}
.dtab-label{flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  font-weight:600; text-transform:uppercase; font-size:12px; letter-spacing:.02em}
.dtab-count{font-family:var(--mono); font-size:12px}
.dtab-share{font-family:var(--mono); font-size:10.5px; color:var(--ink-faint)}
.dtab.on .dtab-share{color:var(--blue-text)}
/* A category this dataset does not record. Dashed and muted so it reads as
   absent rather than merely unselected, and NOT pointer-events:none -- the
   title is the only thing explaining it and has to survive a hover. */
.dtab-unobserved{border-style:dashed; color:var(--ink-faint); cursor:default}
.dtab-unobserved .dtab-dot{background:transparent; box-shadow:inset 0 0 0 1px var(--line-strong)}

/* A continuous axis: histogram track above, one two-handle slider per range
   below, and the ranges in force beside them.

   `.drange-beside` is EVERY continuous axis: each is appended at the card's
   full width, so the histogram takes half and the list the other half. A body
   measurement used to be paired two-per-row inside `.dcard-grid` with its list
   stacked underneath, which left a track too narrow to read a distribution off
   -- the one thing the track is there to show. `minmax(0,1fr)` twice, so the
   chart measures its OWN column rather than overflowing it. */
.drange-wrap{display:grid; gap:0 22px; min-width:0}
.drange-beside{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
@container (width < 560px){ .drange-beside{grid-template-columns:1fr} }
.drange{min-width:0}
.drange-chart{margin-bottom:2px}
.drange-slider{padding:2px 0}
/* The un-applied slider "+ Add range" opened. Dashed, like every other control
   on this panel that states an absence. */
.drange-slider.is-draft .rslider-fill{background:var(--line-strong)}
.drange-picks{align-self:start; display:flex; flex-direction:column; gap:8px;
  border:1px solid var(--line); border-radius:10px; background:var(--cream); padding:11px 12px}
.drange-picks-head{margin:0; font-family:var(--body); font-size:11px; font-weight:700;
  text-transform:uppercase; letter-spacing:.04em; color:var(--ink-faint)}
.drange-chips{display:flex; flex-wrap:wrap; gap:6px}
.drange-none{font-family:var(--body); font-size:12px; color:var(--ink-faint)}
.drange-chip{display:inline-flex; align-items:center; gap:6px; border:1px solid var(--blue);
  border-radius:99px; background:var(--blue-bg); padding:3px 9px; font-family:var(--mono);
  font-size:11.5px; color:var(--blue-text); cursor:pointer}
.drange-chip.is-draft{border-style:dashed; border-color:var(--line-strong);
  background:transparent; color:var(--ink-faint)}
.drange-chip:disabled{cursor:default; opacity:.6}
.drange-add{align-self:start; border:none; background:none; padding:0; font-family:var(--body);
  font-size:12px; font-weight:600; color:var(--blue); cursor:pointer}
.drange-add:disabled{cursor:default; color:var(--ink-faint)}
.rslider{display:flex; align-items:center; gap:10px}
.rslider-num{flex:none; width:62px; box-sizing:border-box; border:1px solid var(--line-strong);
  border-radius:8px; padding:5px 7px; font-family:var(--mono); font-size:12px; color:var(--ink);
  background:var(--panel); text-align:center}
.rslider-num:focus{outline:none; border-color:var(--blue)}
/* The track carries the two inputs. They are stacked on top of it at zero
   height with pointer-events off, so only the thumbs are grabbable and the
   fill below stays visible through both. */
.rslider-track{position:relative; flex:1; min-width:0; height:20px; display:flex; align-items:center}
.rslider-track::before{content:""; position:absolute; left:0; right:0; height:4px;
  border-radius:99px; background:var(--line-strong)}
.rslider-fill{position:absolute; height:4px; border-radius:99px; background:var(--blue)}
.rslider-input{position:absolute; left:0; width:100%; margin:0; height:20px;
  background:none; pointer-events:none; -webkit-appearance:none; appearance:none}
.rslider-input:focus{outline:none}
.rslider-input::-webkit-slider-thumb{-webkit-appearance:none; pointer-events:auto; cursor:grab;
  width:15px; height:15px; border-radius:50%; background:var(--panel);
  border:2px solid var(--blue); box-shadow:0 1px 2px rgba(0,0,0,.18)}
.rslider-input::-moz-range-thumb{pointer-events:auto; cursor:grab;
  width:15px; height:15px; border-radius:50%; background:var(--panel);
  border:2px solid var(--blue); box-shadow:0 1px 2px rgba(0,0,0,.18)}
.rslider-input:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--blue-bg)}
.rslider-input:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--blue-bg)}
.rslider-input::-moz-range-track{background:none}

/* Geography: a sentence, the chosen places, a search, its results, and the
   three suggestions that cover the no-typing case. The results list is CLOSED
   until something is typed -- an always-open list of eight is the scrolling
   checklist this replaced. */
.dgeo-note{font-size:12px; color:var(--ink-soft); margin-bottom:9px; line-height:1.5}
.dgeo-chosen{display:flex; flex-wrap:wrap; gap:6px; margin-bottom:9px}
.dgeo-chip{border:1px solid var(--blue); background:var(--blue-bg); color:var(--blue-text);
  border-radius:20px; padding:4px 10px; font-family:var(--body); font-size:11.5px; cursor:pointer;
  display:inline-flex; align-items:center; gap:5px}
/* The dropdown. ABSOLUTE, so opening it does not push the Advanced section and
   the Refine button down the page -- a menu that reflows the document under
   the click that opened it is the reason the always-open list was so tall in
   the first place. It scrolls at 260px, which is about eight rows: enough that
   51 states are visibly a list rather than a page. */
.dgeo{position:relative}
.dgeo-results:not(:empty){position:absolute; left:0; right:0; z-index:5; margin-top:5px;
  border:1px solid var(--line-strong); border-radius:9px; background:var(--panel);
  box-shadow:0 8px 24px rgba(0,0,0,.10); padding:5px; max-height:260px; overflow-y:auto;
  display:flex; flex-direction:column; gap:1px}
.dgeo-results .dadv-row{padding:7px 9px; border-radius:7px}
.dgeo-results .dadv-row:hover{background:var(--cream)}
.dgeo-results .dadv-row:has(input:checked){background:var(--blue-bg)}
/* Select all, pinned at the top of the scrollport so it stays reachable after
   scrolling into the Ns. */
.dgeo-all{position:sticky; top:0; background:var(--panel); border-bottom:1px solid var(--line);
  border-radius:0 !important; margin-bottom:2px}
.dgeo-all .dadv-label{font-weight:600}
.dgeo-suggested{display:flex; flex-wrap:wrap; align-items:center; gap:6px; margin-top:10px}
.dgeo-lead{font-size:11.5px; color:var(--ink-faint); margin-right:2px}
.dgeo-suggestion{border:1px solid var(--line-strong); background:var(--panel); color:var(--ink);
  border-radius:20px; padding:4px 11px; font-family:var(--body); font-size:11.5px; cursor:pointer}
.dgeo-suggestion:hover{border-color:var(--blue); color:var(--blue-text)}
/* Roomier than the advanced panels' rows, and deliberately only here. Inside a
   collapsed disclosure a tight list is what lets a 100-value facet be scanned
   at all; out in the open card these are the primary control for Race and
   Ethnicity -- three or four rows with 3px of air between them read as one
   block of text rather than as separate things to click, and the hit target is
   the row. The horizontal padding is what gives the selected-row tint below
   something to sit in. */
.dcard-checks .dadv-values{max-height:280px; overflow-y:auto; gap:2px}
.dcard-checks .dadv-row{padding:7px 9px; border-radius:7px}
.dcard-checks .dadv-row:hover{background:var(--cream)}
/* A selected row is tinted, which the checkbox alone did not say loudly enough
   once the rows had room: at a glance the tick is 13px of blue in a list of
   identical rows. */
.dcard-checks .dadv-row:has(input:checked){background:var(--blue-bg)}
.dcard-checks .dadv-row:has(input:checked) .dadv-label{font-weight:600}

/* One card per structural sibling cohort (study_cohort, validation_cohort, ...)
   -- its own border so it reads as a separate population, not more of the
   subject's own charts. Stacked full-width rather than wrapped alongside
   .dchart: each one already contains its OWN .dcharts grid. */
.dsiblings{display:flex; flex-direction:column; gap:12px; margin-top:12px}
.dsibling{border:1px solid var(--line); border-radius:11px; padding:12px 14px}
.dsibling-head{margin-bottom:10px}
.dsibling-head h3{font-family:var(--disp); font-size:13px; font-weight:600; color:var(--ink)}
.dsibling-caption{margin:2px 0 0; font-size:11.5px; color:var(--ink-faint); line-height:1.4}
.dsibling .dcharts{margin-top:2px}


/* The cohort header's degraded states. A BAND, not a full-height empty state:
   these sit above the criteria, and a dataset that records no demographics must
   not push the definition -- the thing being worked on -- off the screen. */
.dband{display:flex; align-items:center; gap:9px; font-size:12.5px; color:var(--ink-soft);
  background:var(--cream); border-radius:10px; padding:10px 13px; margin-bottom:14px}
.dband.muted{color:var(--ink-faint)}
.dband .spinner{width:14px; height:14px; border-width:2px; margin:0}
/* The "◈ this option moves the number now" marker is gone with the panel's own
   clarifying UI. It distinguished an option whose effect landed immediately
   from one whose effect landed after the next message -- a distinction the card
   no longer has, since answering now SENDS, and the panel re-scopes in the same
   beat either way. See frontend/clarify.js. */

/* Approve Cohort -- the draft phase's commitment, in the pinned `.dstep-draft`
   bar beside Back to Filtering. It has moved twice: from under the composer to
   beneath the criteria, then into the count tile itself, and now here.

   The tile was the right answer while the draft phase could still be narrowed:
   the count moved as the user ticked, so the button had to sit on the number it
   was about to run. Nothing on the draft phase moves any more -- the count is
   the definition's, fixed from the moment it is drafted -- so what matters is
   that both ways out stay reachable from anywhere in a long profile, which is
   what a pinned bar gives and a tile at the top of the page does not.

   Filled in --blue, the same as Refine Cohort: the way forward is ONE
   treatment at every step, and a black button beside a blue one read as a
   different kind of control rather than as the next step. */
.approve-btn{flex:none; border:0; background:var(--blue); color:#fff; border-radius:10px; padding:10px 18px;
  font-family:var(--body); font-size:13px; font-weight:600; cursor:pointer;
  display:inline-flex; align-items:center; gap:7px}
.approve-btn:hover:not(:disabled){opacity:.9}
.approve-btn:disabled{opacity:.5; cursor:not-allowed}

/* ---------- A plan's supporting datasets ---------- */
/* Shown only when a recommendation needs more than one dataset, so a
   single-dataset card is byte-identical to what it always was.

   A <details> rather than an always-open block: the anchor is what the user
   chooses, and the linkage is the working behind it. Collapsed it says how many
   datasets are involved -- which is the part that changes the decision -- and
   opening it shows the measured evidence. Same reading order the panel keeps
   everywhere else: the answer, then what it is made of.

   Closed by default even though the plan is unusual, because the summary line
   already carries the fact that a second dataset is involved; expanding by
   default would push "Explore Dataset" off screen on the taller plans, which is
   the same off-screen-at-the-moment-of-deciding problem the Approve row was
   moved to fix. */
.rec-links{margin-top:13px; border:1px solid var(--line); border-radius:9px; background:var(--cream)}
.rec-links > summary{cursor:pointer; list-style:none; padding:9px 11px; font-size:12px; font-weight:600;
  color:var(--ink); display:flex; align-items:center; gap:8px}
.rec-links > summary::-webkit-details-marker{display:none}
.rec-links-hint{font-family:var(--mono); font-size:9.5px; letter-spacing:.07em; text-transform:uppercase;
  color:var(--blue-text); background:var(--blue-bg); padding:2px 7px; border-radius:20px}
.rec-link{padding:0 11px 10px; border-top:1px solid var(--line); padding-top:10px}
.rec-link-head{display:flex; flex-wrap:wrap; align-items:baseline; gap:8px}
.rec-link-name{font-family:var(--disp); font-weight:600; font-size:12.5px}
.rec-link-via{font-size:11px; color:var(--ink-faint)}
.rec-link-supplies{font-size:11.5px; color:var(--ink-soft); margin-top:4px}
/* Wraps for the same reason .rec-facts does: at the 340px minimum pane width
   three facts do not fit on one line, and .abody's overflow-y:auto would turn
   the excess into a horizontal scrollbar rather than clipping it. */
.rec-link-facts{display:flex; flex-wrap:wrap; gap:6px; margin-top:6px}
.rec-link-facts span{font-family:var(--mono); font-size:10.5px; color:var(--ink-soft);
  background:var(--paper); border:1px solid var(--line); padding:2px 7px; border-radius:6px}

/* ---------- Related datasets ---------- */
/* A COMPANION -- a dataset that is ABOUT what the anchor lacks, with no
   validated join to it -- and the one block on this card that is an INVITATION
   rather than evidence. Everything else in .reccard is grey-on-white working;
   this is tinted in the panel's one accent because what it offers is somewhere
   else to look.

   Which is also why it must not share .rec-links' markup: a measured link and a
   companion are different claims, and the two blocks looking alike is the worst
   failure this panel can have. They are never on one card in practice -- a
   companion only ever arrives on a plan whose `supporting` is empty -- but the
   separation is structural rather than incidental.

   OPEN by default, unlike .rec-links beside it. The measured disclosure is
   working shown on request; this is the answer for a question nothing else on
   the card answers, and its list is height-bounded (below) so opening it cannot
   push "Explore Dataset" off screen. */
.rec-related{margin-top:13px; border-radius:10px; background:var(--blue-bg);
  border:1px solid var(--line); border-color:color-mix(in srgb, var(--blue) 20%, transparent)}
.rec-related > summary{cursor:pointer; list-style:none; display:flex; align-items:center; gap:7px;
  padding:10px 12px}
.rec-related > summary::-webkit-details-marker{display:none}
.rec-related-icon{flex:none; width:14px; height:14px; fill:none; stroke:currentColor; stroke-width:1.4;
  stroke-linecap:round; stroke-linejoin:round}
.rec-related > summary .rec-related-icon{color:var(--blue)}
.rec-related-count{font-family:var(--disp); font-size:12.5px; font-weight:600; color:var(--blue-text)}
/* Pushed to the far edge: it is the block's control, not part of its title. */
.rec-related-toggle{margin-left:auto; font-size:11.5px; font-weight:500; color:var(--blue-text)}
/* The label is swapped by CSS rather than by JS, so the native <details> toggle
   stays the only thing that opens and closes this -- no repaint, no state to
   keep in step. */
.rec-related:not([open]) .on-open, .rec-related[open] .on-shut{display:none}
.rec-related-list{display:flex; flex-direction:column; gap:8px; padding:0 12px 12px}
/* A white tile inside the tint, so each dataset reads as its own object rather
   than as a paragraph in a list. */
.rec-related-item{background:var(--panel); border:1px solid var(--line); border-radius:8px;
  padding:9px 11px 10px}
.rec-related-head{display:flex; align-items:center; gap:6px}
.rec-related-head .rec-related-icon{color:var(--blue-text)}
/* An <a> only ever exists once the dataset's Redivis page has actually been
   resolved. The <span> fallback shares the class and takes neither the accent
   nor the underline, so a name that cannot be opened never looks clickable. */
a.rec-related-name{font-family:var(--disp); font-size:12.5px; font-weight:600; color:var(--blue-text);
  text-decoration:underline; text-underline-offset:2px}
a.rec-related-name:hover{color:var(--blue)}
span.rec-related-name{font-family:var(--disp); font-size:12.5px; font-weight:600; color:var(--ink)}
.rec-related-why{font-size:11.5px; color:var(--ink-soft); margin-top:5px; line-height:1.45}
/* More than one companion and the list scrolls in place. The height is
   deliberately NOT a whole number of tiles: a list cut mid-tile is what says
   there is more below it, which a flush edge does not. The thumb is the panel's
   accent because this is the one scrollport inside a tinted block, where the
   platform's own grey thumb disappears.

   Styled through ::-webkit-scrollbar ALONE, deliberately. Setting
   `scrollbar-width`/`scrollbar-color` switches Chrome to the standard
   properties and makes it ignore these rules -- and what it draws then is an
   OVERLAY bar that fades out when the pointer leaves, so the one affordance
   saying "there is more below" disappears exactly when nobody is touching it. */
.rec-related-list.scrolls{max-height:150px; overflow-y:auto}
.rec-related-list.scrolls::-webkit-scrollbar{width:6px}
.rec-related-list.scrolls::-webkit-scrollbar-track{background:transparent}
.rec-related-list.scrolls::-webkit-scrollbar-thumb{background:var(--blue); border-radius:3px}

/* ---- Research Grants -------------------------------------------------------

   A BUTTON IN THE ACTION CLUSTER, beside "Open in Healthcare Datahub", in every
   view. It was a tab on the rail's own row, wearing the active step's underline
   on the reasoning that switching what the body shows is one gesture. Two things
   were wrong with that. On the catalog screen the rail and the title row are
   DIFFERENT rows, so the panel's two right-hand controls sat one above the
   other with nothing tying them together; and wearing a step's underline made
   funding look like part of a journey it is deliberately not in -- the very
   thing the missing number disc was there to deny.

   So it takes `.change-ds`'s treatment instead: the panel's existing idiom for
   a secondary action standing beside the blue pill. Same padding, radius, type
   size and icon stroke, so the cluster reads as one set of controls rather than
   three that happen to be adjacent. THE SOLID BLUE PILL IS NOT AVAILABLE TO IT
   -- that is reserved for LEAVING for Healthcare Datahub.

   It is still NOT a step: no number disc, an icon instead, because the disc
   carries WHERE IN THE SEQUENCE a step falls and funding is not in the
   sequence. */
.gtab{position:relative; display:inline-flex; align-items:center; gap:7px;
  border:1px solid var(--line-strong); background:transparent; color:var(--ink-soft);
  font-family:var(--body); font-size:12.5px; font-weight:600; cursor:pointer;
  padding:8px 13px; border-radius:9px; white-space:nowrap}
.gtab:hover{border-color:var(--blue); color:var(--blue-text)}
/* OPEN is a held state, not a hover: this button toggles a view, unlike Change
   Dataset beside it which fires once. The filled ground is what says "you are
   looking at this right now" now that the underline is gone. */
.gtab-on{border-color:var(--blue); background:var(--blue-bg); color:var(--blue-text)}
.gtab-ico{flex:none; display:inline-flex}
.gtab-ico svg{width:15px; height:15px; fill:none; stroke:currentColor; stroke-width:1.4;
  stroke-linecap:round; stroke-linejoin:round}
/* The count. Same token as `.ahead .badge` beside it rather than a new one --
   this is the panel's one way of putting a number on a label. */
.gtab-badge{font-family:var(--mono); font-size:9.5px; background:var(--blue-bg); color:var(--blue-text);
  padding:2px 7px; border-radius:20px; line-height:1.5}
.gtab-on .gtab-badge{background:var(--blue); color:#fff}

/* The research-context strip: what is being searched FOR, above the results.
   A left rule rather than a filled tile, so it reads as a caption on the list
   rather than as a card competing with the cards below it. */
/* THE PANEL'S TWO STANDING FACTS, ON ONE LINE: what is being searched for on
   the left, the shortlist the user built on the right. align-items:center so
   the toggle sits on the topic's optical line rather than hanging off the top
   of a two-line strip. The left rule is drawn on the BODY rather than on the
   row, so it marks the topic and does not run under the button beside it. */
.gctx{display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;
  margin:14px 0 6px}
.gctx-body{flex:1 1 220px; min-width:0; border-left:3px solid var(--blue); padding:2px 0 2px 12px}
.gctx-terms{font-size:13.5px; color:var(--ink); font-weight:500}
.gctx-sub{font-size:11.5px; color:var(--ink-faint); margin-top:3px}
/* There is deliberately no editing control here. The research context is
   whatever the CONVERSATION established, and the chat is the only way to move
   it -- a form in the panel would be a second place to say what the research is
   about, free to disagree with the thread that produced it. */

/* A stated fact about the list, not a control: retained results, a refresh in
   flight, a shortlist that could not be read. */
.gnote{background:var(--cream); border-radius:10px; padding:9px 12px; font-size:12px;
  color:var(--ink-soft); margin:10px 0}
.gnote-warn{background:var(--warn-bg); color:var(--warn)}

/* THREE ROWS, EACH ABOUT ONE THING: the count (a fact about the RESULT SET),
   then the topic that produced it, then the controls that act on the list
   below. They were one row with the topic stranded above it, which put a 19px
   heading beside three controls and read as clutter. */
.ghead{display:flex; align-items:center; justify-content:flex-end; gap:10px 14px;
  flex-wrap:wrap; margin-top:2px}
.gcontrols{display:flex; align-items:center; justify-content:flex-end; gap:10px 14px;
  flex-wrap:wrap; margin:0 0 14px}
/* The same treatment as the catalog's own "2 Candidate Datasets" (.rec-count),
   because it is the same line doing the same job: how many things are in the
   list below. One size and weight for both, so the two views read as two views
   of one panel. */
.gcount{font-size:19px; font-weight:600; color:var(--ink); line-height:1.2;
  flex:1 1 200px; margin-right:auto}
/* "of 76 matching" beside the count, when the payload bound cut the list. Quiet
   and smaller: it qualifies the number rather than being one. */
.gcount-of{margin-left:8px; font-family:var(--body); font-size:12.5px; font-weight:400;
  color:var(--ink-faint)}
/* `flex:1 1 auto`, NOT `flex:none`. Four controls at their max-content width
   are ~750px, and a `none` basis meant the row simply refused to shrink: at a
   900px window that was 288px of overflow inside .abody, which is
   overflow-y:auto and turns any excess into a horizontal scrollbar under the
   WHOLE panel. Shrinkable plus `flex-wrap` lets the controls stack instead,
   which is what happens at the 340px minimum pane width. */
.gfilters{flex:1 1 auto; min-width:0; display:flex; align-items:center; gap:8px;
  flex-wrap:wrap; justify-content:flex-end}
/* `position:relative` is load-bearing, exactly as it is on .pstep and .dtab:
   .sr-only is position:absolute, so without a positioned ancestor its
   containing block is <body> and the label's screen-reader text escapes the
   panel's scroll container entirely. */
.gfilter{position:relative; display:inline-flex; align-items:center}
/* ONE BOX SHAPE FOR EVERY CONTROL ON THIS ROW. A `<select>` and a bordered
   `<label>` do not agree on height by default -- the select adds the UA's own
   internal padding around its text -- so the checkbox read as a smaller,
   different kind of thing beside one that matched. `height` plus `box-sizing`
   on all three is what settles it; `appearance:none` is what stops the UA
   restoring its own metrics on the select. The arrow is redrawn as a background
   image, since removing the appearance removes that too. */
.gfilter select,.gfilter-check{box-sizing:border-box; height:32px;
  border:1px solid var(--line-strong); border-radius:9px; background:var(--panel);
  font-family:var(--body); font-size:12px; color:var(--ink-soft); cursor:pointer}
.gfilter select{appearance:none; padding:0 26px 0 10px; max-width:190px;
  background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23767676' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat:no-repeat; background-position:right 10px center; background-size:9px}
.gfilter select:hover,.gfilter-check:hover{border-color:var(--blue); color:var(--blue-text)}
.gfilter-check{display:inline-flex; align-items:center; gap:7px; padding:0 11px}
.gfilter-check input{accent-color:var(--blue); cursor:pointer; margin:0}
.grant-link svg{width:13px; height:13px; fill:none; stroke:currentColor;
  stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round}

/* The empty / loading / error states. One block, three messages, and the
   difference between them is the point: `error` offers Retry because it is our
   problem, and `empty` does not because it is an answer. */
.gstate{display:flex; flex-direction:column; align-items:center; text-align:center; gap:8px;
  padding:34px 18px; color:var(--ink-soft); font-size:13px}
.gstate-sub{font-size:12px; color:var(--ink-faint); max-width:46ch}
.gstate .spinner{margin-bottom:0}
.gstate-error{color:var(--bad)}
/* A FIRST SEARCH, AND AN EMPTY PANEL, ARE THE WHOLE SCREEN.
   There is nothing else on either one to read, so they centre in what remains of
   the scrollport rather than sitting tight under the header, where a small
   spinner reads as a detail of the strip above it rather than as the panel
   working. `min-height` against the viewport rather than a fixed figure, less
   the header and the panel's own chrome, so it is centred at any pane height and
   still scrolls rather than clipping on a short one. */
/* A REFRESH GREYS THE WHOLE PANEL BODY AND CENTRES ONE SPINNER ON IT.
   The previous answer stays legible underneath, which is the honest thing for
   it to be -- it IS still the answer until the newer one lands -- and nothing
   under it takes a click that would race the request.

   THE OVERLAY IS A SIBLING OF `.abody`, NOT A CHILD, and that is the whole
   reason this works: a spinner inside a list 4,000px tall cannot be centred on
   what anyone can SEE. `absolute` centres it on the block, and `sticky` never
   moves an element ABOVE its natural position, so a spinner placed after 50
   cards simply stays after 50 cards -- which is what the first attempt did,
   leaving a greyed panel with no animation anywhere in the viewport. Outside
   the scrollport it is centred on the panel's own box, which is the thing the
   reader is looking at. `.artifact` is the containing block.

   The panel HEADER is deliberately left alone: the Research Grants tab is what
   closes this view, and dimming the one control that can leave a screen is how
   a busy state becomes a trap. */
.artifact.is-busy .abody{opacity:.35; pointer-events:none; filter:saturate(.4); transition:opacity .12s ease}
/* THE COHORT TAB'S COUNT IN FLIGHT wears the same grey and the same centred
   overlay, with one difference: the scrollport stays SCROLLABLE. A clinical
   facet's count is a real scan (~8s on CMS), and freezing the page for it
   would trap a user mid-scroll. So the scrollport itself takes the wheel while
   everything INSIDE it takes no click -- every control is genuinely `disabled`
   by `controlsLocked()` already; this covers the rest (chips, Clear All). */
.artifact.is-counting .abody{pointer-events:auto; opacity:.45}
.artifact.is-counting .abody > *{pointer-events:none}
/* ONE grey for the whole panel. The card's own `is-locked` dimming exists for a
   lock the overlay does not cover; under the overlay it would stack on the
   panel's and leave the controls barely visible. */
.artifact.is-counting .dcard-body.is-locked,.artifact.is-counting .dadv-body.is-locked,
.artifact.is-counting .rslider.is-locked{opacity:1}
/* The caption is plain text over the greyed panel, in the panel's own loading
   type -- `.artifact-empty .big`, the "Loading Schema…" / "Counting
   Population…" line -- so every wait in this panel reads in one voice. It wore
   a white pill for a while; that read as a control rather than a status. */
.artifact.is-counting .panel-busy .spinner{background:var(--panel)}
.artifact.is-counting .panel-busy > span:last-child{font-family:var(--disp); font-size:16px; color:var(--ink-soft)}
.panel-busy{position:absolute; inset:0; z-index:4; display:flex; flex-direction:column;
  align-items:center; justify-content:center; gap:14px; pointer-events:none;
  color:var(--ink-soft); font-size:14px; text-align:center}
.gstate-hero{justify-content:center; gap:16px; min-height:calc(100vh - 300px); font-size:14.5px}
.gstate-hero p{max-width:34ch; line-height:1.5}
.gstate-idle p{font-family:var(--disp); font-weight:600; font-size:16px; color:var(--ink-soft)}
/* Sized here rather than by overriding .spinner, so the chat's own indicator is
   untouched: this one is the only thing on its screen and can afford the room. */
.spinner-lg{width:38px; height:38px; border-width:3.5px}

/* The cards. `.reccard`'s treatment, deliberately -- the panel already has one
   card idiom and a funding opportunity is the same kind of object as a dataset
   recommendation: a thing described, with one way forward off it. */
.grant-list{display:flex; flex-direction:column}
.grantcard{border:1px solid var(--line); border-radius:13px; padding:15px 16px; margin-bottom:12px;
  transition:border-color .15s}
.grantcard:hover{border-color:var(--blue)}
.grant-top{display:flex; align-items:flex-start; justify-content:space-between; gap:6px 10px; flex-wrap:wrap}
.grant-funder{flex:1 1 auto; min-width:0; font-family:var(--mono); font-size:10px; letter-spacing:.07em;
  text-transform:uppercase; color:var(--ink-faint)}
/* A WORD, NEVER A PERCENTAGE. The ranking's arithmetic is on a scale nobody
   outside this repo has -- the same reason ranking.public_ranking strips every
   score at the wire -- so a "96% match" here would look measured and would not
   be. Three labels, three tints. */
/* `position:relative` is the tooltip's containing block, and load-bearing:
   without it the absolutely-positioned tip resolves against <body> and lands
   somewhere else on the page entirely. `help` for the cursor, which is what
   says "there is something to read here" before anything is hovered. */
.grant-fit{position:relative; flex:none; font-family:var(--mono); font-size:9.5px; padding:3px 8px;
  border-radius:20px; white-space:nowrap; background:var(--cream); color:var(--ink-soft);
  cursor:help; border-bottom:1px dotted currentColor}
.grant-fit[tabindex]:focus-visible{outline:2px solid var(--blue); outline-offset:2px}
/* WHY THIS IS AN ELEMENT AND NOT THE `title` ATTRIBUTE.
   That was the first attempt and it read as broken: the browser waits about a
   second before showing a native tooltip and renders it in the OS's own faint
   chrome well away from the badge, so the common report was that hovering did
   nothing at all. This appears at once, in the app's own type, anchored to the
   badge -- and on `:focus-visible` as well as `:hover`, which is what makes it
   reachable without a pointer. `aria-describedby` on the badge is what ties the
   two together for a screen reader.
   RIGHT-ANCHORED and BELOW: the badge sits in the card's top-right corner, so a
   centred or upward tip would leave the panel. `white-space:normal` because the
   badge itself is `nowrap` and a sentence must not inherit that. */
.grant-fit-tip{position:absolute; top:calc(100% + 7px); right:0; z-index:5;
  width:max-content; max-width:270px; white-space:normal; text-align:left;
  background:var(--ink); color:#fff; border-radius:9px; padding:8px 11px;
  font-family:var(--body); font-size:12px; font-weight:500; line-height:1.45;
  letter-spacing:0; text-transform:none; box-shadow:0 6px 20px rgba(20,30,50,.22);
  opacity:0; visibility:hidden; transition:opacity .12s ease}
.grant-fit:hover .grant-fit-tip,.grant-fit:focus-visible .grant-fit-tip{opacity:1; visibility:visible}
@media (prefers-reduced-motion:reduce){.grant-fit-tip{transition:none}}
.grant-fit-strong{background:var(--good-bg); color:var(--good)}
.grant-fit-good{background:var(--blue-bg); color:var(--blue-text)}
.grant-title{font-family:var(--disp); font-weight:600; font-size:15px; line-height:1.3; margin-top:6px;
  color:var(--ink)}
.grant-why{font-size:12.5px; color:var(--ink-soft); margin-top:6px; line-height:1.5}
/* ELIGIBLE APPLICANTS AS CHIPS -- the same treatment a dataset card gives its
   key variables, because it is the same kind of content: a bounded list of
   short labels from the record, where the count of what is not shown matters as
   much as what is. The unresolved questions are the CHAT's, not this block's. */
/* ONE ROW, VERTICALLY CENTRED: who may apply on the left, what it pays and when
   it closes on the right.
   `align-items:center`, not `flex-end`. The chips carry 3px of vertical padding
   and the facts do not, so bottom-aligning the two boxes left the chip TEXT
   sitting a few pixels above the money -- aligned by box, not by line, which is
   the only alignment a reader can see. Centring lines the two runs of text up.
   The ELIGIBILITY heading is NOT in this row (see the rule below): while it
   was, the thing being aligned against the money was the whole label-plus-chips
   block rather than the chips themselves.
   Wraps at a narrow pane, where stacking is the only honest alternative to a
   horizontal scrollbar under the whole panel. */
.grant-meta{display:flex; align-items:flex-start; justify-content:space-between; gap:8px 16px;
  flex-wrap:wrap; margin-top:7px}
.grant-meta .grant-elig-list{flex:1 1 240px; min-width:0; margin-top:0}
/* `flex-start` plus the CHIP'S OWN vertical padding on the facts.
   Centring was tried and is wrong once the chips wrap: it lines the money up
   with the middle of a two-row block, leaving it floating between the rows.
   Starting both at the top puts it on the FIRST chip row -- the row the eye
   begins on -- and matching the padding is what makes the two runs of TEXT
   line up rather than the two boxes, which is the only alignment a reader can
   see. */
.grant-meta .grant-facts{flex:none; margin-top:0; padding:3px 0}
.grant-elig-lbl{font-family:var(--mono); font-size:9.5px; letter-spacing:.07em;
  text-transform:uppercase; color:var(--ink-faint); margin-top:11px}
.grant-elig-list{display:flex; flex-wrap:wrap; align-items:center; gap:6px}
.grant-elig-chip{font-family:var(--body); font-size:11.5px; background:var(--blue-bg);
  color:var(--blue-text); padding:3px 9px; border-radius:7px; white-space:nowrap}
.grant-elig-more{font-family:var(--mono); font-size:10.5px; color:var(--ink-faint)}
/* THE MONEY AND THE DEADLINE, FLUSH RIGHT. They are the two facts a reader
   scans a list of opportunities FOR, so they line up down the right-hand edge
   across every card rather than starting wherever the text above them ended. */
.grant-facts{display:flex; align-items:center; justify-content:flex-end; gap:8px 14px;
  flex-wrap:wrap; margin-top:11px; font-size:12px; color:var(--ink-soft)}
.grant-award{font-weight:600; color:var(--ink)}
/* THE DEADLINE'S PROVENANCE IS IN ITS COLOUR AS WELL AS ITS WORDS.
   A FORECAST IS RED, deliberately, and it is the only red here: the date is a
   projection on a notice that has not been published, so planning around it is
   the mistake the tint is there to interrupt. `closed` has no rule because a
   past deadline is never returned at all -- see tools/grants.build_search_sql.
   `unknown` stays muted: "the export did not say" is not a warning. */
.grant-deadline-open{color:var(--good)}
.grant-deadline-forecast{color:var(--bad); font-weight:600}
.grant-deadline-unknown{color:var(--ink-faint)}
.grant-actions{display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;
  margin-top:12px; padding-top:11px; border-top:1px solid var(--line)}
.grant-link{display:inline-flex; align-items:center; gap:6px; text-decoration:none;
  font-family:var(--body); font-size:13px; font-weight:600; color:var(--blue-text)}
.grant-link:hover{text-decoration:underline}
