/* Custom Stylesheet for TCGA Sequon Loss Dashboard */

/* Herbarium theme: cool grey paper, olive for what you chose, straw for where you are.
 *
 * THE RULE, and it is the whole design: chrome gets the two chrome hues, data gets the
 * Okabe-Ito set below, and the two sets never trade places. If a colour is not encoding a
 * measurement, a category or a class, it may only be olive or straw. Two earlier themes
 * failed this from opposite directions. The indigo one spent cyan, purple and pink on
 * buttons and panel glows until a real signal had no contrast budget left. The greyscale
 * one that replaced it then starved the chrome so completely that nothing looked
 * touchable, and the page read as flat.
 *
 * THE SURFACES ARE COOL AND THE ACCENT IS OLIVE, and that division is the point rather than
 * an accident of taste. Every surface in this sheet used to be warm tan, which put the chrome
 * in direct competition with the one hue that is supposed to carry meaning: a tan menubar
 * over tan paper next to an olive button is three warm things arguing about which one you are
 * supposed to look at. Cooling every surface to a blue grey near LCh hue 250, while leaving
 * --primary on exactly the value it already had, hands the whole warm register back to olive
 * and straw. The accent got louder without changing by a single digit. The neutrals still
 * carry real chroma rather than going achromatic, for the reason recorded in the paragraph
 * above: a flat grey theme has already been built here once and rejected by name.
 *
 * The two chrome hues divide by STATE, which is what makes them learnable:
 *
 * - OLIVE is commitment. The tab you are on, the segment you picked, a ticked box, a focus
 *   ring, the column you sorted by. If a thing is olive, you did that.
 * - STRAW is transience. Cursor hover and keyboard walk, plus the one notice band that says
 *   how much of the table you are being shown. Straw is never in two places at once,
 *   because a cursor is only ever in one place.
 *
 * Consequences worth knowing before editing:
 *
 * - The variable NAMES are unchanged, including the ones that now hold something other than
 *   their name (--primary is olive, --cyan is sky blue, --pink is reddish purple). Renaming
 *   them would have meant touching every rule in this file plus app_v1.3.js and shell.css in
 *   the same commit. The names are historical; the comments are authoritative.
 * - --primary is the INTERACTIVE colour. All ~25 of its uses across this file and shell.css
 *   were already a button fill, an active pill, a checked box, a focus ring or an icon, and
 *   not one was body text, so repointing it from black to olive is what carries the accent
 *   site-wide. Most of the restyle happens here rather than rule by rule.
 * - Shading is TINTED, never black at low alpha, and this survived the move from warm paper
 *   to cool unchanged. `rgba(0,0,0,0.035)` over warm paper made the greyscale theme read as
 *   dirty rather than as quiet, because black alpha desaturates whatever is under it and
 *   every hover looked like a smudge. Over white it does not even manage dirty: it is flat
 *   grey, the one thing this user has already rejected by name. Hover fills use --straw-wash
 *   or --straw-tint, plates and wells use a surface token, and shadows and scrims are cast in
 *   rgba(39, 70, 89, x), the slate the surface ladder below is cut from.
 *
 *   That slate replaced rgba(74, 66, 38, x), the old paper brown, at matched luminance. A
 *   warm shadow under a cool surface is exactly the residue that makes a palette change look
 *   half finished, because a shadow is read as the surface's own dark and a brown one under
 *   a blue grey card reads as a second, dirtier light source. If this page ever looks muddy
 *   again, the shadows are the first thing to check.
 *
 * The science palette is Okabe-Ito, the eight-colour set designed to stay distinguishable
 * under all common forms of colour blindness and the de facto standard in scientific figures.
 * It is not a stylistic pick: roughly 1 in 12 men has a red-green deficiency, and this site
 * uses colour to separate loss from gain and to tell five PTM types apart, so a palette that
 * collapses under deuteranopia would destroy actual information. The gene view's structure
 * and sequence colouring already uses this palette, so these tokens match it deliberately.
 * Keep them in step: GV_PTM_STYLE in v2/shell.js is the other half of the same scheme.
 */
:root {
    /* Surfaces, as a LADDER, and the ladder now runs CONTENT WHITE / FURNITURE COOL.
     *
     * Three arrangements have been tried. The first two are recorded because the reasons
     * they failed are still the reasons this one is shaped the way it is:
     *
     * 1. The first warm revision set the page to #f9f7ef and put the menubar and the sidebar
     *    both on #f3f1e4. In relative luminance that is 0.93 against 0.90, a 3% step, under
     *    the threshold where an edge reads as an edge, so the menubar dissolved into the page
     *    it was supposed to sit on. The table header made it worse from the other side at
     *    #ebe8d7, and the canvas, the furniture and the header all landed inside one narrow
     *    mid-tan band with no bright surface anywhere. Too little RANGE, not too much warmth.
     * 2. The second put the canvas on tan #f7f7f4 and left cards and modals on #ffffff. That
     *    fixed the range but broke the thing the user actually noticed: the cohort picker was
     *    a white panel and the table behind it was tan, and the genes page did the same. One
     *    site cannot have two papers. He called that out by name.
     *
     * So: CONTENT IS WHITE AND FURNITURE IS TINTED, and the two never swap.
     *
     * The furniture was warm tan and is now cool slate. Every value below was re-derived at
     * the relative luminance of the tan it replaced, to within 0.0005, because the STEPS in
     * this ladder are the part the user reacted to and the hue is not. Recolouring by eye
     * would have moved both at once and made it impossible to tell which change he was
     * answering. Hue moved; the shape of the ladder did not.
     *
     * Two constraints besides the gaps pin the bottom of the ladder, and they pull against
     * each other, so re-tuning either of the two darkest surfaces means re-checking both.
     * --bg-sunken has to stay light enough that the olive segment filling a segmented control
     * clears 3:1 against the track behind it, which is the WCAG floor for a graphical object
     * and lands at 3.007:1. It also has to stay dark enough to keep its gap under the rail. A
     * first pass at this recolour missed by two thousandths of luminance on the low side and
     * dropped that ratio to 2.99, which is how narrow the window is.
     *
     *   content   --bg-main       #ffffff  L 1.000  canvas, every view, no exceptions
     *             --bg-card       #ffffff  L 1.000  cards, rows, modals, panels. Same white.
     *             --bg-card-hover #fbf6eb  L 0.924  row hover; straw, so it reads as pointing
     *   furniture --bg-menubar    #f0f5fa  L 0.907  top bar        (0.093 below the canvas)
     *             --bg-header     #e5eef5  L 0.844  the chrome plate that sits ON content:
     *                                               table heads, footers, chips, code wells
     *             --bg-sidebar    #cce3f4  L 0.743  left rail      (0.164 below the top bar)
     *             --bg-sunken     #b8d3e5  L 0.624  wells INSIDE furniture: segmented-control
     *                                               tracks, progress grooves (0.119 below the
     *                                               rail, so a track on the rail still reads)
     *
     * Four rules hold this together:
     *
     * 1. ONE canvas, and it is white. --bg-main and --bg-card are the same value on purpose.
     *    A card separates from the canvas by its tinted hairline and its tinted shadow, never by
     *    being a different paper, because the moment a pane gets its own paper some other
     *    pane disagrees with it. If a view needs to feel different it does that with what it
     *    puts ON the canvas.
     * 2. THE LADDER ONLY GOES ONE WAY: canvas lightest, top bar a step darker, left rail a
     *    further step darker. The top bar is never lighter than the canvas it sits over.
     * 3. CHROMA LIVES IN THE FURNITURE, AND IT RISES WITH DEPTH. Measured as CIE LCh C*, the
     *    ladder runs 3.1 on the top bar, 4.7 on the header plate, 11.6 on the rail and 13.0 in
     *    the sunken wells. The two lightest surfaces are deliberately the palest of the four,
     *    which is a reversal of how the tan ladder was tuned: they cover the largest areas on
     *    screen, and at C* 5 and above a light blue grey stops reading as cool paper and
     *    starts reading as ice blue, which is a colour with an opinion rather than a ground
     *    for one. The darker three keep their chroma, because the same cast at low luminance
     *    reads as slate, and slate is the thing that keeps this off flat grey. For scale:
     *    Tailwind's slate ramp runs about 2.5 to 7 across this luminance range and the tan
     *    ladder these replaced ran 5.9 to 15.1, so the sheet now sits between the two rather
     *    than at either end. Drain it and you rebuild the greyscale theme that was already
     *    rejected for reading flat.
     * 4. NO NEUTRAL EDGES AND NO BLACK ALPHA. --border-color and --border-color-strong carry
     *    the same slate cast as the surfaces, and a wash is --straw-wash or a surface token,
     *    never rgba(0,0,0,x). Black at low alpha desaturates whatever is under it, which is
     *    what made every hover in the greyscale theme look like a smudge, and on white it is
     *    simply grey. Cool surfaces do not make black alpha safe: an achromatic wash over a
     *    tinted ground still drags it toward the flat grey that was rejected.
     *
     * The top bar and the left rail are 0.164 apart, which is deliberate and is the second
     * thing the user called out: at 0.090 they read as one continuous L-shaped slab instead
     * of two pieces of furniture. Adjacent surfaces need roughly 5% of luminance to separate;
     * every step in the ladder above clears that with room to spare. */
    --bg-main: #ffffff;
    --bg-card: #ffffff;
    --bg-card-hover: #fbf6eb;     /* L 0.924, straw-tinted; hover, so it belongs to straw */
    --bg-menubar: #f0f5fa;        /* L 0.907, C* 3.1 */
    --bg-header: #e5eef5;         /* L 0.844, C* 4.7 */
    --bg-sidebar: #cce3f4;        /* L 0.743, C* 11.6 */
    --bg-sunken: #b8d3e5;         /* L 0.624, C* 13.0, segmented-control tracks, inset wells */

    /* The one surface that sits UNDER data rather than under furniture, so it does not
     * follow the ladder above. --bg-sunken at L 0.624 is right for a segmented-control
     * track, where nothing is drawn on top, and wrong behind the tissue bars, where seven
     * category colours are. Measured on the tan --bg-sunken this replaced, #D1CBE4 came out
     * at 1.01:1 and #FBBC17 at 1.10:1, which is not "low contrast", it is invisible. At
     * L 0.827 five of the seven clear 1.4 and the two palest are carried by the edge on the
     * fill instead. Those figures still hold because this track kept its luminance through
     * the recolour and the seven category fills were not touched at all. Keep this light: a
     * data surface spends its contrast budget on the marks, not on itself. */
    --bg-track: #e2ecf4;          /* L 0.827, behind bars and other plotted marks */
    /* Both edges carry the surface cast and both were lifted when the canvas went white: a
     * hairline that was legible on tan paper is a ghost on white. The recolour held both
     * luminances, so they are still 1.52:1 and 2.10:1 against the canvas. */
    --border-color: #c4d4e1;      /* L 0.643, hairline on a light surface */
    --border-color-strong: #a1b6c5;   /* L 0.451, an edge that has to be seen: control, input */
    --border-color-active: #6b7a41;

    /* Olive: the interactive colour. See the header note.
     *
     * Untouched by the cool recolour, and that was the instruction rather than an oversight.
     * Olive is the one hue on this site that means something about what YOU did, so it is the
     * fixed point the surfaces were moved relative to. It also does the aesthetic work: the
     * whole reason the ladder went cool is so this stops competing with the paper. */
    --primary: #6b7a41;
    --primary-light: #55632f;     /* deeper, for olive TEXT and icons on paper (6.4:1) */
    --primary-glow: rgba(107, 122, 65, 0.14);
    /* Opaque twin of --primary-glow. Anything position:sticky needs one: a translucent fill
     * lets the rows scrolling underneath show through, which on the sorted column header
     * printed the first row's number on top of the column name.
     *
     * It sits BELOW --bg-header (0.800 against 0.844), which it did not always. On the very
     * first tan header the sorted column was lighter than its neighbours and still read,
     * because the whole band was dim. Once the header plate got stronger a lighter tint
     * looked like a gap in the band, so the marked column goes down rather than up. The
     * hover twin is one further step down and is the only other olive plate on the site.
     *
     * These two kept their olive values through the cool recolour rather than being restated
     * as a tinted grey. They are the sorted column, which is a thing YOU did, and the whole
     * two-hue rule says that is olive's job. Sitting them on a cool header plate is what
     * makes them read as olive at all: against the old tan header this pair was barely a
     * different hue from the band it sat in. */
    --primary-tint: #e4ead3;          /* L 0.800, sorted column */
    --primary-tint-hover: #dbe2c5;    /* L 0.735, sorted column under the cursor */
    --secondary: #6f7060;

    /* Kept as aliases of olive: five rules and app_v1.3.js reach for --accent by name. */
    --accent: #6b7a41;                      /* olive */
    --accent-dark: #52602f;
    --accent-glow: rgba(107, 122, 65, 0.16);

    /* Straw: the transient colour, and the ONLY other hue chrome may spend.
     *
     * It is deliberately kept off text and off every dot, chip and swatch, because at a
     * glance a saturated ochre mark is confusable with --amber below, which means
     * N-glycosylation. As a 10% wash or a 1px rule it cannot be mistaken for a data point, so
     * those are the only two shapes it is allowed to take. --straw itself fails AA on
     * paper (3.0:1) and is therefore a rule colour, never a text colour.
     *
     * STRAW SURVIVED THE COOL RECOLOUR, and the audit that decided it is worth recording,
     * because "the page is too warm" is the obvious argument for deleting it and that would
     * have been the wrong call.
     *
     * All thirty uses of the four tokens below were checked across this file, shell.css and
     * export_menu.css. Every one is :hover, :focus, :focus-visible, or the .is-on class that
     * marks the keyboard walk through an autocomplete list. The single exception is one
     * always-visible pill hairline. Straw was therefore never the source of the ambient
     * warmth the user objected to: it was only ever visible under the cursor, one element at
     * a time, and what he was actually looking at was the tan surface ladder, which is now
     * cool. Deleting straw would have cost the site its entire hover vocabulary and fixed
     * nothing, and it would have broken the olive/straw pairing that the header note calls
     * the whole design.
     *
     * Two of the four were pulled back by about 25% of their CIE C*, at unchanged luminance
     * and unchanged hue: --straw-tint 11.5 to 8.4 and --bg-card-hover 7.4 to 5.9. Those are
     * the two that fill AREA, a whole table row or a whole list item, and simultaneous
     * contrast means a warm patch of a given chroma reads louder on cool ground than the
     * same patch read on the tan paper these were tuned against. The correction keeps a
     * hovered row at the strength it always LOOKED, not the strength it always measured.
     *
     * --straw and --straw-line were left exactly as they were. They are 1px rules and small
     * marks, they cover almost no area, and the point of cooling the surfaces was to give
     * the two accent hues more room, not less. Desaturating the accents alongside the ground
     * would have cancelled the change out. */
    --straw: #b8912a;
    --straw-line: #ddc98a;
    --straw-wash: rgba(184, 145, 42, 0.10);
    --straw-tint: #f7f2e2;        /* L 0.888, C* 8.4 */

    /* Science palette (Okabe-Ito). Named for what they were, valued for what they encode.
     *
     * The four sentences that used to be here claimed --amber was methylation, --cyan was
     * N-glycosylation, --purple/--pink was acetylation and --emerald was ubiquitination.
     * Checked against PTMS in app_v1.3.js and GV_PTM_STYLE in v2/shell.js, which agree
     * exactly with each other, ALL FOUR were wrong. The values are the authority:
     *
     *   --amber   #e69f00  N-glycosylation
     *   --cyan    #56b4e9  phosphorylation
     *   --purple  #cc79a7  ubiquitination     (--pink is the same value)
     *   --emerald #009e73  methylation        (and "gain" in the older badges)
     *   (none)    #f0e442  acetylation        -- no token exists for it; the two JS palettes
     *                                            carry it as a literal
     *
     * COLLISION, recorded rather than fixed: these same four tokens are also spent on
     * unrelated categorical badges. --cyan is .badge-project, --amber is .badge-missense,
     * --purple is .badge-indel, --emerald is .badge-gain. So a phosphorylation site and a
     * project badge are currently the same blue, and an N-glycosylation site and a missense
     * badge are the same orange. Nothing on screen puts a PTM colour and a badge in one
     * legend, which is why this has not bitten yet; adding such a view would require
     * splitting the badge hues off first.
     *
     * The glows are the same hues at low alpha, for chip fills that must not fight the text
     * on them. */
    --cyan: #56b4e9;                        /* sky blue */
    --cyan-glow: rgba(86, 180, 233, 0.16);
    --purple: #cc79a7;                      /* reddish purple */
    --purple-glow: rgba(204, 121, 167, 0.16);
    --pink: #cc79a7;
    --pink-glow: rgba(204, 121, 167, 0.16);
    --emerald: #009e73;                     /* bluish green */
    --amber: #e69f00;                        /* orange */

    /* Unmodified protein backbone in both 3D viewers. Was a hardcoded #5b6472 literal in
     * app_v1.3.js and again in v2/shell.js; it is chrome, not data, so it belongs here and
     * is read through varColor('--backbone'). Slate rather than a warm grey because it has
     * to sit under the science palette above without joining it. */
    --backbone: #5b6472;

    /* Loss and gain are red and green because the user asked for red and green, twice, after
     * seeing the Okabe-Ito vermillion/blue pair that used to be here. Do not "fix" this back.
     *
     * Red-green is of course the classic deficiency pair, so hue is NOT what separates these
     * two. Brightness is, and the gap is measured rather than eyeballed: relative luminance
     * 0.137 against 0.268, a 1.96x gap, which reads as 5.62:1 and 3.30:1 against white and so
     * clears the 3:1 floor for graphical objects on both sides. Deuteranopia and protanopia
     * leave luminance untouched, which is why the pair stays readable once the hue collapses.
     * A "nicer" red or a deeper green closes that gap and takes the only remaining signal with
     * it, so these two hex values are load bearing. Change them and re-measure, or not at all. */
    --loss: #c62828;                         /* red */
    --gain: #43a047;                         /* green */

    /* THE HIGHLIGHTER: "this is the residue you asked about". Deliberately not a data colour
     * and not in the Okabe-Ito rotation above, because a highlight has to win against
     * whatever it lands on: the grey 3D backbone, white paper, and any PTM fill already
     * under it. This used to be --primary, which put olive in two jobs at once and lost to
     * everything.
     *
     * Not Okabe-Ito's #f0e442 either: that yellow is already acetylation, and it is too pale
     * to read as a mark on white. --highlight-edge exists because a pure yellow shape on
     * white paper has almost no silhouette of its own, so the mark needs a border to have a
     * shape at all. Ink stays dark: no yellow this saturated can carry white text. */
    --highlight: #ffd60a;
    --highlight-edge: #b58900;
    --highlight-wash: rgba(255, 214, 10, 0.30);
    /* The see-through outline drawn around the marked residue in the 3D viewer, so it stays
     * findable when the fold buries it. Turquoise on purpose: it has to be legible against
     * the yellow it wraps AND against grey backbone AND against white, and it must not be
     * mistakable for any of the five Okabe-Ito PTM colours or for --loss/--gain. Cyan is the
     * one region of the wheel none of those occupy. Read from JS by varColor(). */
    --xray-outline: #12b8b0;
    /* Deliberately NOT re-cut cool with --text-primary, which it used to share a value with.
     * This ink only ever lands on --highlight, a saturated yellow, and ink takes its cast from
     * what it sits on. A slate ink on yellow reads as a colour clash where a warm near-black
     * reads as the yellow's own shadow. It looks like a leftover; it is not. */
    --highlight-ink: #2b2b21;

    /* Ink on paper, tinted off pure black so it sits on this paper instead of punching a
     * hole in it. --text-muted is the lightest thing here that still clears WCAG AA on the
     * two surfaces it actually lands on: 5.63:1 on the white canvas and 4.79:1 on the
     * --bg-header plate, which is where the count chips and the source-table headings live.
     * It was #6f6d5c and only made 4.45 on that plate once the plate got stronger. Do not
     * lighten it to make something look calmer; everything it is used for is a label
     * someone has to read. It does NOT clear AA on --bg-sidebar (4.25), which is why the
     * rail's own labels are --text-secondary.
     *
     * All three were re-cut cool with the surfaces, from #2b2b21 / #565446 / #6a6857, each at
     * its own luminance to within 0.0005. Ink had to move with the paper: the point of tinting
     * it off black in the first place is that it should read as the same material as what it
     * sits on, and brown-black text on a slate ground reads as a scanned page pasted onto a
     * screen. Because the luminances are unchanged, every contrast ratio quoted above and
     * throughout this file still holds and did not need re-deriving. Their chroma is held to
     * about 80% of the warm ink's: dark text carries a hue cast much more visibly than a pale
     * surface does, and ink that is obviously blue reads as a link. */
    --text-primary: #232b32;
    --text-secondary: #4b555e;
    --text-muted: #5c6974;

    /* Fonts. Outfit sets the UI; the mono is for anything POSITIONAL, which on this site
     * means hotspot ids, residue positions and the sequence grid. Those were falling
     * through to the browser default, so the leftmost column of the main table -- the first
     * thing anyone reads -- rendered in Courier while the rest of the page was a geometric
     * sans. IBM Plex Mono is the pick because its digits are unambiguous at 12px and its
     * cap height sits close enough to Outfit's that a mono id inside a sans row does not
     * jump. Weights are held to 400/500: the sequence grid needs even density. */
    --font-family: 'Outfit', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    --font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

    /* Transitions */
    --transition-fast: 0.2s ease;
    --transition-normal: 0.3s ease;
}

/* Digits in the same column must line up. Every number on this site sits in a table or a
 * metric tile and gets compared vertically, and Outfit's default proportional figures make
 * "124" and "98" different widths, which reads as a wobble down the column. */
table, .metric-value, .op-gv-n, [class*="-pct"], [class*="-count"] {
    font-variant-numeric: tabular-nums;
}

* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    background-color: var(--bg-main);
    color: var(--text-primary);
    font-family: var(--font-family);
    overflow-x: hidden;
    min-height: 100vh;
}

/* The two ambient corner glows (indigo top-left, purple bottom-right) are gone:
 * flat backgrounds only. Kept as empty display:none rules rather than deleted so the
 * `content: ''` pseudo-elements cannot resurrect if a later rule sets a background on
 * them; nothing else in the sheet targets body::before/::after. */
body::before,
body::after {
    display: none;
}

/* Dashboard Wrapper Layout */
.dashboard-wrapper {
    display: flex;
    min-height: 100vh;
}

/* Sidebar Styling */
.sidebar {
    width: 280px;
    background-color: var(--bg-sidebar);
    /* Furniture-to-content edges get the strong hairline, not the faint one. The faint rule
     * is for dividing rows inside one surface; where two different surfaces meet, the edge is
     * doing structural work and has to be visible on its own. */
    border-right: 1px solid var(--border-color-strong);
    padding: 16px 16px 8px 16px;
    display: flex;
    flex-direction: column;
    position: fixed;
    height: 100vh;
    z-index: 100;
    backdrop-filter: blur(10px);
    overflow: hidden;
}

.brand {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 12px;
}

.brand-icon {
    color: var(--primary);
    width: 32px;
    height: 32px;
}

.brand-text h1 {
    font-size: 1.25rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    color: var(--text-primary);
}

.brand-text span {
    font-size: 0.75rem;
    color: var(--text-secondary);
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

.sidebar-divider {
    height: 1px;
    background: var(--border-color);   /* was a fading gradient */
    margin-bottom: 24px;
}

.filter-section {
    display: flex;
    flex-direction: column;
    gap: 4px;
    flex-grow: 1;
}

.section-title {
    font-size: 0.875rem;
    color: var(--text-secondary);
    text-transform: uppercase;
    letter-spacing: 0.05em;
    font-weight: 600;
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 8px;
}

.section-title svg {
    width: 16px;
    height: 16px;
    color: var(--primary);
}

/* The sidebar is six unrelated controls stacked in one column: cohort, PTM type, direction,
 * mechanism, gene search, sequon templates. They were separated by 12px of nothing, all set
 * in the same weight against the same fill, so the eye had to read the labels to find where
 * one control ended and the next began, and the sidebar looked like one undifferentiated
 * panel of buttons.
 *
 * Separation is SPACE ONLY. Two other things were tried here and both were worse:
 *
 * - A white card per group. It turned a quiet rail into a stack of nine bright panels that
 *   pulled more attention than the data they filter, and the sidebar is meant to recede.
 * - A hairline rule between groups. Rejected as visual noise, which it was: nine rules in a
 *   280px column is a lot of furniture to draw a boundary the spacing already draws.
 *
 * So the label does the work. Small, tracked, muted, sitting tight above its own control and
 * clear of the one before it, it reads as a caption for the group. The gap inside a group
 * (5px) is deliberately much smaller than the gap between groups (9px plus the section's 4px)
 * because that ratio, not a line, is what tells you which label belongs to which control. */
.control-group {
    display: flex;
    flex-direction: column;
    gap: 5px;
    padding-top: 9px;
}

.control-group:first-child {
    padding-top: 0;
}

/* --text-secondary rather than --text-muted, because the rail got deeper. Muted clears AA on
 * the canvas but only reaches 3.95:1 on the new sidebar, and every one of these is a caption
 * someone has to read to know what the control below it does. */
.control-group label {
    font-size: 0.68rem;
    font-weight: 600;
    color: var(--text-secondary);
    text-transform: uppercase;
    letter-spacing: 0.07em;
}

/* Form Inputs */

/* CONTROLS ON THE RAIL ARE LIGHT PLATES. This is the answer to "the left panel buttons do
 * not separate enough from the panel", and the route chosen was colour rather than heavier
 * outlines. Outlines could only ever add a 1px edge; going to the content white puts every
 * control 0.257 of luminance above the rail, where the old sunken fill sat 0.118 BELOW it,
 * so the separation roughly doubles and it points the way the rest of the site already
 * teaches: light is the layer you touch, tinted is the furniture holding it.
 *
 * This is not the rejected "white card per group". Those were containers drawn around
 * headings, which turned the rail into a stack of bright panels. These are the controls
 * themselves, and the groups are still separated by space alone.
 *
 * `select` is the reference plate the other rail controls are written against, because it is
 * the only one of them that is a bare element selector. It used to be #search-gene, which
 * went with the sidebar gene search: the Gene column's own filter button in the hotspots
 * table already does that job, and the two of them disagreeing was the same redundancy that
 * retired the per-cancer-type Focus control. */
select {
    width: 100%;
    background-color: var(--bg-card);
    border: 1px solid var(--border-color-strong);
    border-radius: 8px;
    padding: 10px 12px;
    color: var(--text-primary);
    font-family: var(--font-family);
    font-size: 0.875rem;
    cursor: pointer;
    transition: var(--transition-fast);
}

select:focus {
    outline: none;
    border-color: var(--primary);
    box-shadow: 0 0 0 3px var(--primary-glow);
}

/* The same light plate as the select above. This control was the one on the rail that
 * did not pick up the theme, because it carried its colours inline and two of the three
 * tokens it named, --text and --border, do not exist: it fell back to the browser's default
 * ink and hairline box. Mono because what you type into it is a sequence pattern. */
#custom-sequon-templates {
    width: 100%;
    background-color: var(--bg-card);
    border: 1px solid var(--border-color-strong);
    border-radius: 8px;
    padding: 9px 12px;
    color: var(--text-primary);
    font-family: var(--font-mono);
    font-size: 0.8rem;
    line-height: 1.5;
    resize: vertical;
    box-sizing: border-box;
    transition: var(--transition-fast);
}

#custom-sequon-templates:focus {
    outline: none;
    border-color: var(--primary);
    background-color: var(--straw-tint);
    box-shadow: 0 0 0 3px var(--primary-glow);
}

/* Custom Checkboxes */
.checkbox-group {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.checkbox-label {
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 0.875rem;
    color: var(--text-secondary);
    cursor: pointer;
    user-select: none;
}

.checkbox-label input {
    display: none;
}

/* An unticked box has to look empty and still look like a control, so it gets the strong
 * hairline rather than the faint one: at 18px a 1px --border-color edge on tinted paper is
 * almost invisible, which is what made the old boxes read as decoration until you found
 * one. Ticked is olive, per the rule, and the tick itself is white. It was previously
 * drawing a --text-primary tick on a black fill, i.e. near-black on near-black, so the
 * only thing that actually signalled "ticked" was the fill going dark. */
.custom-checkbox {
    width: 17px;
    height: 17px;
    border: 1.5px solid var(--border-color-strong);
    border-radius: 4px;
    background-color: var(--bg-card);
    display: flex;
    align-items: center;
    justify-content: center;
    transition: var(--transition-fast);
    position: relative;
}

.checkbox-label:hover .custom-checkbox {
    border-color: var(--primary);
    background-color: var(--straw-tint);
}

.checkbox-label input:checked + .custom-checkbox {
    background-color: var(--primary);
    border-color: var(--primary);
}

.checkbox-label:hover input:checked + .custom-checkbox {
    background-color: var(--accent-dark);
    border-color: var(--accent-dark);
}

.checkbox-label input:focus-visible + .custom-checkbox {
    outline: 2px solid var(--primary);
    outline-offset: 2px;
}

.checkbox-label input:checked + .custom-checkbox::after {
    content: '';
    width: 4.5px;
    height: 8px;
    border: solid #ffffff;
    border-width: 0 2px 2px 0;
    transform: rotate(45deg);
    position: absolute;
    top: 2px;
}

/* Buttons */
.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 10px 16px;
    font-family: var(--font-family);
    font-size: 0.875rem;
    font-weight: 500;
    border-radius: 8px;
    border: 1px solid transparent;
    cursor: pointer;
    transition: var(--transition-fast);
}

/* Filled, not transparent. Transparent is invisible on the canvas (which is now the same
 * white) and on the sidebar it left "Reset Filters" as a caption with a hairline round it. */
.btn-secondary {
    background-color: var(--bg-card);
    border-color: var(--border-color-strong);
    color: var(--text-secondary);
}

.btn-secondary:hover {
    background-color: var(--straw-tint);
    color: var(--text-primary);
    border-color: var(--text-secondary);
}

.btn-icon {
    width: 36px;
    height: 36px;
    padding: 0;
    border-radius: 6px;
    background-color: var(--bg-header);
    border-color: var(--border-color-strong);
    color: var(--text-secondary);
}

.btn-icon:hover:not(:disabled) {
    background-color: var(--straw-tint);
    color: var(--text-primary);
    border-color: var(--primary);
}

.btn-icon:disabled {
    opacity: 0.3;
    cursor: not-allowed;
}

.sidebar-footer {
    font-size: 0.75rem;
    color: var(--text-secondary);   /* same AA reason as .control-group label */
    text-align: center;
}

.sidebar-footer .version {
    display: block;
    margin-top: 4px;
    font-weight: 600;
    color: var(--primary);
}

/* Main Content Styling */
.main-content {
    /* Exactly the sidebar's own width, and it is 280 rather than 281. The rail is
       `width: 280px; border-right: 1px solid`, and the universal `box-sizing: border-box` at
       the top of this file means that 1px is INSIDE the 280, not added to it. So the rail's
       total footprint is 280px and this margin already lands flush on its border with no gap
       and no overlap. shell.css carries `--op-sidebar-w: 281px` with a comment reasoning the
       other way; that token is for the menubar's brand block and is not the figure to reach
       for here. Using it would open a 1px strip of canvas against the rail. */
    margin-left: 280px;
    /* Kept in step with the padding below by hand, because a page-level inset is needed in
       two places: here, and by anything that opts OUT of this padding to go full bleed and
       then has to put the inset back on its own toolbar. See the hotspots full-bleed rule
       further down. Two literals that must agree is how they stop agreeing. */
    --page-inset: 40px;
    padding: var(--page-inset);
    flex-grow: 1;
    display: flex;
    flex-direction: column;
    gap: 32px;
    max-width: 1600px;
}

/* Header */
.header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    border-bottom: 1px solid var(--border-color);
    padding-bottom: 24px;
}

.header-title h2 {
    font-size: 1.75rem;
    font-weight: 700;
    letter-spacing: -0.02em;
    margin-bottom: 4px;
}

.header-title p {
    color: var(--text-secondary);
    font-size: 0.95rem;
}

.data-status {
    display: flex;
    align-items: center;
    gap: 8px;
    background-color: rgba(0, 158, 115, 0.08);
    border: 1px solid rgba(0, 158, 115, 0.2);
    padding: 6px 12px;
    border-radius: 20px;
    font-size: 0.75rem;
    font-weight: 500;
    color: var(--emerald);
}

.status-indicator {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background-color: var(--emerald);
    box-shadow: 0 0 8px var(--emerald);
    animation: pulse 2s infinite;
}

@keyframes pulse {
    0% { transform: scale(0.9); opacity: 0.6; }
    50% { transform: scale(1.1); opacity: 1; }
    100% { transform: scale(0.9); opacity: 0.6; }
}

/* Metrics Cards Styling */
.metrics-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 24px;
}

.metric-card {
    background-color: var(--bg-card);
    border: 1px solid var(--border-color);
    border-radius: 16px;
    padding: 24px;
    /* A card and the canvas are the same white now, so the card has to lift off it rather
     * than sit in it: a slate hairline plus one soft slate shadow. Tinted, not black at low
     * alpha, which on white is just grey and is what the rejected greyscale theme was made
     * of. The shadow was the paper brown rgba(74, 66, 38, x) while the ladder was tan and is
     * the slate rgba(39, 70, 89, x) now that the ladder is cool, at the same alphas: a shadow
     * reads as the surface's own dark, so it has to be cut from the same hue as the surface
     * or the card looks lit by a second, dirtier light. Prose cards opt out in shell.css,
     * where a .chart-card is only a heading group. */
    box-shadow: 0 1px 2px rgba(39, 70, 89, 0.05), 0 8px 20px rgba(39, 70, 89, 0.05);

    display: flex;
    align-items: center;
    gap: 20px;
    backdrop-filter: blur(16px);
    transition: transform var(--transition-normal), border-color var(--transition-normal);
}

.metric-card:hover {
    transform: translateY(-2px);
    border-color: var(--border-color-active);
}

.metric-icon {
    width: 48px;
    height: 48px;
    border-radius: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.metric-icon svg {
    width: 24px;
    height: 24px;
}

.bg-indigo { background: var(--primary-glow); color: var(--primary-light); }
.bg-cyan { background: var(--cyan-glow); color: var(--cyan); }
.bg-purple { background: var(--purple-glow); color: var(--purple); }
.bg-pink { background: var(--pink-glow); color: var(--pink); }
.bg-emerald { background: rgba(0, 158, 115, 0.15); color: var(--emerald); }

.text-emerald { color: var(--emerald); }

.metric-info {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.metric-label {
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--text-secondary);
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

.metric-info h3 {
    font-size: 1.75rem;
    font-weight: 700;
    color: var(--text-primary);
}

.metric-trend {
    font-size: 0.75rem;
    font-weight: 500;
}

.text-muted { color: var(--text-muted); }
.text-pink { color: var(--pink); }

/* Charts Section Styling */
.charts-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 24px;
    margin-top: 32px;
}

.span-2 { grid-column: span 2; }
.span-3 { grid-column: span 3; }

.chart-card {
    background-color: var(--bg-card);
    border: 1px solid var(--border-color);
    border-radius: 16px;
    padding: 24px;
    /* A card and the canvas are the same white now, so the card has to lift off it rather
     * than sit in it: a slate hairline plus one soft slate shadow. Tinted, not black at low
     * alpha, which on white is just grey and is what the rejected greyscale theme was made
     * of. The shadow was the paper brown rgba(74, 66, 38, x) while the ladder was tan and is
     * the slate rgba(39, 70, 89, x) now that the ladder is cool, at the same alphas: a shadow
     * reads as the surface's own dark, so it has to be cut from the same hue as the surface
     * or the card looks lit by a second, dirtier light. Prose cards opt out in shell.css,
     * where a .chart-card is only a heading group. */
    box-shadow: 0 1px 2px rgba(39, 70, 89, 0.05), 0 8px 20px rgba(39, 70, 89, 0.05);

    backdrop-filter: blur(16px);
    display: flex;
    flex-direction: column;
    gap: 20px;
}

.card-header {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.card-header h4 {
    font-size: 1.1rem;
    font-weight: 600;
    color: var(--text-primary);
}

.card-subtitle {
    font-size: 0.8rem;
    color: var(--text-secondary);
}

.chart-container {
    position: relative;
    height: 300px;
    width: 100%;
}

.flex-center {
    display: flex;
    align-items: center;
    justify-content: center;
}

/* Table Section Styling */
.table-section {
    width: 100%;
    margin-top: 32px;
}

.table-card {
    background-color: var(--bg-card);
    border: 1px solid var(--border-color);
    border-radius: 16px;
    /* A card and the canvas are the same white now, so the card has to lift off it rather
     * than sit in it: a slate hairline plus one soft slate shadow. Tinted, not black at low
     * alpha, which on white is just grey and is what the rejected greyscale theme was made
     * of. The shadow was the paper brown rgba(74, 66, 38, x) while the ladder was tan and is
     * the slate rgba(39, 70, 89, x) now that the ladder is cool, at the same alphas: a shadow
     * reads as the surface's own dark, so it has to be cut from the same hue as the surface
     * or the card looks lit by a second, dirtier light. Prose cards opt out in shell.css,
     * where a .chart-card is only a heading group. */
    box-shadow: 0 1px 2px rgba(39, 70, 89, 0.05), 0 8px 20px rgba(39, 70, 89, 0.05);

    backdrop-filter: blur(16px);
    overflow: hidden;
}

.table-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 24px;
    border-bottom: 1px solid var(--border-color);
}

.table-header-left h4 {
    font-size: 1.1rem;
    font-weight: 600;
}

.table-subtitle {
    font-size: 0.8rem;
    color: var(--text-secondary);
}

.rows-count {
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--primary-light);
    background-color: var(--primary-glow);
    padding: 6px 12px;
    border-radius: 20px;
    border: 1px solid var(--border-color);
}

.table-scroll-wrapper {
    overflow-x: auto;
    width: 100%;
}

.data-table {
    width: 100%;
    border-collapse: collapse;
    text-align: left;
    font-size: 0.875rem;
}

.data-table th {
    background-color: var(--bg-header);
    color: var(--text-secondary);
    font-weight: 600;
    padding: 16px 24px;
    border-bottom: 1px solid var(--border-color);
    text-transform: uppercase;
    font-size: 0.75rem;
    letter-spacing: 0.03em;
    user-select: none;
}

.data-table th.sortable {
    cursor: pointer;
    transition: var(--transition-fast);
}

.data-table th.sortable:hover {
    color: var(--text-primary);
    background-color: var(--straw-tint);   /* pointing at it, so straw, per the two-hue rule */
}

.sort-icon {
    width: 12px;
    height: 12px;
    margin-left: 4px;
    vertical-align: middle;
}

.data-table td {
    padding: 16px 24px;
    border-bottom: 1px solid var(--border-color);
    color: var(--text-primary);
    white-space: nowrap;
}

.data-table tbody tr {
    transition: var(--transition-fast);
}

/* --bg-card-hover exists for exactly this and was going unused: the rows are --bg-card, so
 * hovering to --bg-main used to be a real step and is now a no-op. */
.data-table tbody tr:hover {
    background-color: var(--bg-card-hover);
}

/* Hotspot rows carry tabindex="0" so a keyboard user can open the modal with Enter, and a
 * focusable thing with no visible focus state is worse than one that cannot be focused at
 * all. Olive, because reaching a row deliberately is commitment; the straw hover fill above
 * still marks where the cursor is. Inset, so it cannot widen a fixed-layout row. */
#hotspots-table tbody tr:focus-visible {
    outline: 2px solid var(--primary);
    outline-offset: -2px;
}

/* Badge styling inside table */
.badge {
    display: inline-flex;
    align-items: center;
    padding: 4px 8px;
    border-radius: 12px;
    font-size: 0.7rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.02em;
}

.badge-project {
    background-color: rgba(86, 180, 233, 0.08);
    color: var(--cyan);
    border: 1px solid rgba(86, 180, 233, 0.15);
}

.badge-missense {
    background-color: rgba(230, 159, 0, 0.08);
    color: var(--amber);
    border: 1px solid rgba(230, 159, 0, 0.15);
}

/* OFF THE DATA PALETTE, deliberately. This names a consequence class of a mutation
 * (truncating vs missense vs indel); it does not say which half of the dataset the row
 * belongs to. --loss and --gain are reserved for that one question, and a reader who has
 * learned the Loss badge reads any red-inked pill as a loss claim.
 *
 * It was also already internally inconsistent: the fill and the border were a hand-written
 * rgba(213, 94, 0, ...), which is the OLD vermillion --loss, while the ink was var(--loss),
 * which is now #c62828. Two different reds in one three-line rule, because half of it was
 * tied to a token and half of it was a copy of the token's value from an earlier theme.
 * Straw is the site's neutral attention wash and belongs to no series, so it can never go
 * out of step with a data hue again. */
.badge-truncating {
    background-color: var(--straw-tint);
    border: 1px solid var(--straw-line);
    color: var(--text-secondary);
}

.badge-indel {
    background-color: rgba(204, 121, 167, 0.08);
    color: var(--purple);
    border: 1px solid rgba(204, 121, 167, 0.15);
}

.gene-cell {
    font-weight: 600;
    color: var(--text-primary);
}

.position-cell {
    font-family: monospace;
    color: var(--text-secondary);
}

.hgvsp-cell {
    font-family: monospace;
    color: var(--primary-light);
    font-weight: 500;
}

/* Loading rows and spinner */
.loading-row {
    background-color: transparent !important;
}

/* THE THIN LINE THAT CROSSED THE SCREEN DURING LOADING.
 *
 * It was this row's own cell border. Both tables give every td a 1px bottom rule
 * (.data-table td and #hotspots-table td), and the loading state replaces the entire tbody
 * with a single <tr> holding one <td colspan="14">. That cell's bottom border is therefore a
 * hairline running the full width of the table, and the Hotspots view is full bleed with
 * max-width: none and no page padding, so "the full width of the table" is the full width of
 * the window. With rows either side of it a cell border reads as a row separator; with
 * nothing above or below it, it reads as a stray line drawn across the page.
 *
 * !important, joining the declaration above it, because #hotspots-table td is an id selector
 * and outranks any class-only rule that could turn its border off. Same reason the background
 * beside it carries one. */
.loading-row td {
    border-bottom: 0 !important;
}

/* DELETED: `.loading-row .loading-container { position: sticky; top: 120px; }`.
 *
 * Its comment claimed the spinner followed a reader who was scrolled deep into the table
 * when they flipped a filter. It did not, and had never been able to. A sticky element can
 * only travel inside its own containing block, which here is the loading <td>, and that cell
 * is exactly as tall as the spinner plus its 48px padding: there is nowhere to travel to, so
 * the declaration resolved to a static box on every paint.
 *
 * The height pin that makes the tall box is showHotspotsLoadingRow()'s `minHeight`, and it is
 * written on .table-container, which is an ANCESTOR of the <table>. Nothing stretches the
 * table, the row or the cell to fill it, so the pinned space opens up below a short table
 * rather than around a tall cell.
 *
 * Making it work needs the cell itself to be as tall as the pin, which means a second height
 * source (an inline height on the <td>, or turning .table-container into a flex column) that
 * has to agree with the JS pin on every paint. Two owners of one height is how the sticky
 * <thead> above already broke once, and the payoff is a spinner that follows the scroll for
 * the fraction of a second a rebuild takes. Not worth it; a rule whose comment describes an
 * effect it does not have is worse than no rule, so the rule is gone rather than papered
 * over. If it comes back it needs the <td> height, not just the declaration. */

.loading-container {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 16px;
    padding: 48px 0;
    color: var(--text-secondary);
}

.spinner {
    width: 40px;
    height: 40px;
    border: 3px solid var(--border-color);
    border-radius: 50%;
    border-top-color: var(--primary);
    animation: spin 1s ease-in-out infinite;
}

@keyframes spin {
    to { transform: rotate(360deg); }
}

/* Table Footer & Pagination */
.table-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px 24px;
    background-color: var(--bg-header);
    border-top: 1px solid var(--border-color-strong);
}

.pagination-info {
    font-size: 0.8rem;
    color: var(--text-secondary);
}

.pagination-controls {
    display: flex;
    align-items: center;
    gap: 12px;
}

.page-numbers {
    display: flex;
    gap: 6px;
}

.page-btn {
    min-width: 32px;
    height: 32px;
    border-radius: 6px;
    border: 1px solid var(--border-color-strong);
    background-color: var(--bg-header);
    color: var(--text-secondary);
    font-family: var(--font-family);
    font-size: 0.8rem;
    font-weight: 600;
    cursor: pointer;
    transition: var(--transition-fast);
    display: flex;
    align-items: center;
    justify-content: center;
}

.page-btn:hover {
    background-color: var(--straw-tint);
    color: var(--text-primary);
    border-color: var(--text-secondary);
}

.page-btn.active {
    background-color: var(--primary);
    border-color: var(--primary);
    color: var(--text-primary);
    box-shadow: 0 1px 2px rgba(39, 70, 89, 0.22);
}

/* Custom Scrollbars */
::-webkit-scrollbar {
    width: 8px;
    height: 8px;
}

/* The thumb has to beat the track it slides in, and it did not: a 10% straw wash over the
 * track was nearly the track, and the hover state was literally the track colour. */
::-webkit-scrollbar-track {
    background: var(--bg-header);
}

::-webkit-scrollbar-thumb {
    background: var(--border-color-strong);
    border-radius: 4px;
}

::-webkit-scrollbar-thumb:hover {
    background: var(--straw);
}

/* Responsive Scaling */
@media (max-width: 1200px) {
    .charts-grid {
        grid-template-columns: 1fr;
    }
    .span-2, .span-3 {
        grid-column: span 1;
    }
}

@media (max-width: 900px) {
    .dashboard-wrapper {
        flex-direction: column;
    }
    .sidebar {
        width: 100%;
        height: auto;
        position: relative;
        border-right: none;
        border-bottom: 1px solid var(--border-color);
    }
    .main-content {
        margin-left: 0;
        --page-inset: 24px;
        padding: var(--page-inset);
    }
}

/* Dashboard Tab Switching */
/* Sidebar Loss/Gain global mode toggle (segmented control) */
/* Segmented controls: a sunken slate track, light keys, one olive segment. The track is what
 * tells you the segments are alternatives rather than a row of separate buttons, so it has
 * to be visible; it was a black 12% wash, which went muddy grey on the tan paper of the day
 * and would go flat grey on the slate that replaced it. It keeps the
 * strong edge now that the keys on it are light, or the track shows only as three 3px
 * gutters and the group loses its outline. */
.mode-toggle {
    display: flex;
    gap: 3px;
    background: var(--bg-sunken);
    border: 1px solid var(--border-color-strong);
    border-radius: 9px;
    padding: 3px;
}

/* PTM selector holds many entries (N-Glyco, Phospho, Ubiquitination, ...): let
   the buttons wrap onto multiple rows so none are clipped off the sidebar edge. */
#ptm-toggle {
    flex-wrap: wrap;
}
#ptm-toggle .ptm-toggle-btn {
    flex: 1 1 auto;
    white-space: nowrap;
}

/* Sized down a step when the PTM labels went from "N-Glyco" to "N-glycosylation". The rail is
 * a fixed 280px and the type list is five entries on a wrapping row, so the full names only
 * fit two to a row if the buttons stop paying for 10px of side padding they do not need. */
/* An unchosen segment is a light plate, not a hole in the track. Transparent worked while
 * the rail was a pale tan and the track barely darker than it, but on the deeper rail a row
 * of unfilled segments read as text, and the whole complaint was that these did not look
 * touchable. Light key on a tinted track is the same "content on furniture" rule the rest of
 * the ladder follows. */
.mode-toggle-btn, .ptm-toggle-btn {
    flex: 1;
    background: var(--bg-card);
    border: none;
    color: var(--text-secondary);
    font-family: var(--font-family);
    font-size: 0.74rem;
    font-weight: 500;
    padding: 6px 7px;
    border-radius: 6px;
    cursor: pointer;
    transition: var(--transition-fast);
}

/* Opaque, because the key it replaces is opaque: --straw-wash here would composite over the
 * sunken track instead of over the key and hovering would make the key go DARK. */
.mode-toggle-btn:hover, .ptm-toggle-btn:hover {
    color: var(--text-primary);
    background-color: var(--straw-tint);
}

.mode-toggle-btn:focus-visible, .ptm-toggle-btn:focus-visible {
    outline: 2px solid var(--primary);
    outline-offset: -2px;
}

/* Both segmented controls select in olive, and the weight steps up with the fill so the
 * choice survives being printed or read by someone who cannot separate olive from the
 * track. Sky blue was tried here and removed for a good reason that still holds: it is
 * N-glycosylation's colour in the structure and sequence views, so filling the active PTM
 * button with it taught two different things with one colour. Olive is not in the data
 * palette, which is the whole reason it is the one the chrome may spend. */
.mode-toggle-btn.active, .ptm-toggle-btn.active {
    color: #fff;
    background-color: var(--primary);
    font-weight: 600;
    box-shadow: 0 1px 2px rgba(39, 70, 89, 0.18);
}

.mode-toggle-btn.active:hover, .ptm-toggle-btn.active:hover {
    color: #fff;
    background-color: var(--accent-dark);
}

.mode-toggle-btn:disabled,
.mode-toggle-btn.disabled {
    opacity: 0.4;
    cursor: not-allowed;
}
.mode-toggle-btn:disabled:hover,
.mode-toggle-btn.disabled:hover {
    color: var(--text-secondary);
    background-color: var(--bg-card);
}

/* ---- the Loss / Gain badge (#modal-hotspot-badge) ----
 *
 * ONE component, TWO hues, and nothing else different between them.
 *
 * There was no .badge-loss rule at all. app_v1.3.js has always written
 * `badge ${type === "Loss" ? "badge-loss" : "badge-gain"}`, and only one of those two class
 * names was ever declared, so the Gain badge came out as a filled green oval and the Loss
 * badge fell back to bare .badge: no fill, no border, and whatever colour it inherited. Not a
 * design decision that drifted, a rule that was never written.
 *
 * THEY CARRY THE DATA HUES, and that is a deliberate reading of the doctrine at the top of
 * this file. This badge does not label a control or a region of the page; it states which
 * half of the dataset the row in front of you belongs to, which is the same fact the charts
 * already draw with varColor('--loss') and varColor('--gain'). Chrome that happened to be
 * red would be the violation. A data label in the data's own colour is the rule working.
 *
 * Gain moves off --emerald and onto --gain while it is here. --emerald is methylation's
 * colour in the PTM palette (see the collision note at the top of the file), so the old badge
 * was green for a reason unrelated to gain, and it was a different green from every gain bar
 * on the page. --loss and --gain are a matched pair; --emerald and --loss are not.
 *
 * The fill and the edge are color-mix of the same token rather than hand-written rgba, so
 * they cannot go stale the day either token moves, which is exactly what happened to the
 * rgba(0, 158, 115, 0.15) they replace. */
.badge-loss {
    background-color: color-mix(in srgb, var(--loss) 12%, transparent);
    border: 1px solid color-mix(in srgb, var(--loss) 30%, transparent);
    color: var(--loss);
}

.badge-gain {
    background-color: color-mix(in srgb, var(--gain) 12%, transparent);
    border: 1px solid color-mix(in srgb, var(--gain) 30%, transparent);
    color: var(--gain);
}

.badge-other {
    background-color: var(--bg-sunken);
    color: var(--text-secondary);
}

/* =============================================
   Cohort Selector Button (Sidebar)
   ============================================= */
.btn-cohort-select {
    width: 100%;
    display: flex;
    align-items: center;
    gap: 8px;
    background: var(--bg-card);      /* light plate on the rail; see `select` */
    border: 1px solid var(--border-color-strong);
    border-radius: 10px;
    color: var(--text-primary);
    font-family: var(--font-family);
    font-size: 0.875rem;
    font-weight: 500;
    padding: 10px 12px;
    cursor: pointer;
    transition: var(--transition-fast);
    text-align: left;
}

.btn-cohort-select:hover {
    background: var(--straw-tint);
    border-color: var(--border-color-strong);
}

.btn-cohort-select i {
    width: 16px;
    height: 16px;
    flex-shrink: 0;
    color: var(--primary-light);
}

/* =============================================
   Primary / Small / Ghost Button Utilities
   ============================================= */
.btn-primary {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    background: var(--accent);
    border: none;
    border-radius: 8px;
    color: #fff;
    font-family: var(--font-family);
    font-size: 0.9rem;
    font-weight: 600;
    padding: 10px 20px;
    cursor: pointer;
    transition: var(--transition-fast);
}

.btn-primary:hover {
    filter: brightness(1.15);
    transform: translateY(-1px);
}

.btn-primary i { width: 16px; height: 16px; }

.btn-sm {
    padding: 6px 12px;
    font-size: 0.8rem;
    border-radius: 6px;
}

.btn-ghost {
    background: transparent;
    border: 1px solid var(--border-color);
    color: var(--text-secondary);
    font-family: var(--font-family);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    gap: 5px;
    transition: var(--transition-fast);
}

.btn-ghost:hover {
    background: var(--straw-wash);
    color: var(--text-primary);
    border-color: var(--border-color-strong);
}

.btn-ghost i { width: 14px; height: 14px; }

/* =============================================
   Cohort Selection Modal
   ============================================= */
.cohort-overlay {
    position: fixed;
    inset: 0;
    background: rgba(35, 43, 50, 0.30);
    backdrop-filter: blur(6px);
    z-index: 1000;
    display: flex;
    align-items: center;
    justify-content: center;
    animation: overlayFadeIn 0.2s ease;
}

@keyframes overlayFadeIn {
    from { opacity: 0; }
    to   { opacity: 1; }
}

.cohort-modal {
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    border-radius: 18px;
    box-shadow:
        0 25px 60px rgba(39, 70, 89, 0.16),
        0 0 0 1px rgba(39, 70, 89, 0.05) inset,
        0 0 60px rgba(39, 70, 89, 0.06);
    width: min(92vw, 860px);
    height: min(750px, 80vh);
    max-height: 88vh;
    display: flex;
    flex-direction: column;
    animation: modalSlideIn 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
    overflow: hidden;
}

@keyframes modalSlideIn {
    from { opacity: 0; transform: scale(0.94) translateY(16px); }
    to   { opacity: 1; transform: scale(1) translateY(0); }
}

/* Landing gate: the dashboard stays fully hidden until the first-run cohort/
   PTM/mode confirmation (state.hasConfirmedLanding) removes this class. */
.main-content.landing-gate-hidden {
    display: none;
}

/* Landing-gate two-column body: cohort checklist (always) + PTM/Mode controls
   (gate-only, hidden and the modal narrowed again once .is-landing-gate is
   removed after the first confirm). */
.cohort-modal-body {
    display: flex;
    flex-direction: row;
    flex: 1;
    min-height: 0;
}

.cohort-modal-col-left {
    display: flex;
    flex-direction: column;
    flex: 1;
    min-width: 0;
}

.cohort-modal-col-left .cohort-groups-container {
    flex: 1;
}

.cohort-modal-col-right {
    display: none;
    flex-direction: column;
    gap: 20px;
    width: 260px;
    flex-shrink: 0;
    padding: 20px 24px;
    border-left: 1px solid var(--border-color);
    overflow-y: auto;
}

.gate-control-group label {
    display: block;
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--text-secondary);
    margin-bottom: 8px;
}

.cohort-modal.is-landing-gate {
    width: min(92vw, 1120px);
}

.cohort-modal.is-landing-gate .cohort-modal-col-right {
    display: flex;
}

.cohort-modal.is-landing-gate #btn-cancel-cohort,
.cohort-modal.is-landing-gate .cohort-close-btn {
    display: none;
}

/* #ptm-toggle-gate holds the same button set as the sidebar's #ptm-toggle,
   let it wrap for the same reason (many PTM types, narrow column). */
#ptm-toggle-gate {
    flex-wrap: wrap;
}
#ptm-toggle-gate .ptm-toggle-btn {
    flex: 1 1 auto;
    white-space: nowrap;
}

.cohort-modal-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    padding: 24px 28px 16px;
    border-bottom: 1px solid var(--border-color);
    flex-shrink: 0;
}

.cohort-modal-header h2 {
    display: flex;
    align-items: center;
    gap: 10px;
    font-size: 1.25rem;
    font-weight: 700;
    color: var(--text-primary);
    margin-bottom: 4px;
}

.cohort-modal-header h2 i { width: 22px; height: 22px; color: var(--primary-light); }

.cohort-modal-subtitle {
    font-size: 0.82rem;
    color: var(--text-secondary);
    max-width: 540px;
    line-height: 1.5;
}

.cohort-close-btn {
    background: none;
    border: none;
    color: var(--text-secondary);
    cursor: pointer;
    padding: 6px;
    border-radius: 6px;
    transition: var(--transition-fast);
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
}

.cohort-close-btn:hover { background: var(--straw-wash); color: var(--text-primary); }
.cohort-close-btn i { width: 20px; height: 20px; }

.cohort-modal-toolbar {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 14px 28px;
    border-bottom: 1px solid var(--border-color);
    flex-shrink: 0;
    flex-wrap: wrap;
}

.cohort-search-wrap {
    position: relative;
    flex: 1;
    min-width: 200px;
}

.cohort-search-icon {
    position: absolute;
    left: 10px;
    top: 50%;
    transform: translateY(-50%);
    width: 15px;
    height: 15px;
    color: var(--text-muted);
    pointer-events: none;
}

#cohort-search {
    width: 100%;
    background: var(--bg-card);
    border: 1px solid var(--border-color-strong);
    border-radius: 8px;
    color: var(--text-primary);
    font-family: var(--font-family);
    font-size: 0.875rem;
    padding: 8px 12px 8px 34px;
    outline: none;
    transition: var(--transition-fast);
}

#cohort-search:focus {
    border-color: var(--primary);
    background: var(--bg-card);
    box-shadow: 0 0 0 3px var(--primary-glow);
}

.cohort-toolbar-actions {
    display: flex;
    gap: 8px;
}

/* Scrollable groups area */
.cohort-groups-container {
    overflow-y: auto;
    flex: 1;
    padding: 20px 28px;
    display: flex;
    flex-direction: column;
    gap: 20px;
    scrollbar-width: thin;
    scrollbar-color: var(--border-color-strong) transparent;
}

.cohort-groups-container::-webkit-scrollbar { width: 6px; }
.cohort-groups-container::-webkit-scrollbar-track { background: transparent; }
.cohort-groups-container::-webkit-scrollbar-thumb {
    background: var(--bg-sunken);
    border-radius: 3px;
}

/* Individual cancer-type group */
.cohort-group {
    background: transparent !important;
    border: none !important;
    border-radius: 0 !important;
    margin-bottom: 24px;
    padding: 0 !important;
}

.cohort-group.hidden { display: none; }

.cohort-group-header {
    display: flex;
    align-items: center;
    background: transparent !important;
    border: none !important;
    border-bottom: 1px solid var(--border-color) !important;
    padding: 0 0 6px 0 !important;
    margin-bottom: 10px;
    user-select: none;
    cursor: default !important;
}

.cohort-group-title {
    font-weight: 600;
    font-size: 1.05rem;
    color: var(--primary-light);
}

/* Studies list inside each group */
.cohort-studies-list {
    padding: 4px 0 10px 0 !important;
    border: none !important;
    display: flex;
    flex-direction: column;
    gap: 6px;
}

/* Each study = a simple checkbox row */
.cohort-study-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 4px 0 !important;
    cursor: pointer;
    background: transparent !important;
    border: none !important;
    border-radius: 0 !important;
    transition: opacity 0.15s ease;
    width: 100%;
}

.cohort-study-item:hover {
    opacity: 0.85;
}

.cohort-study-item.no-data {
    opacity: 0.45;
}

.cohort-study-item.no-data:hover {
    opacity: 0.75;
}

/* Cell-line / PDX-organoid "model system" studies: excluded from the default
   patient-only cohort. Still fully visible and re-selectable, just visually
   flagged (badge is added inline in JS) with a subtle left accent.

   This rule was `rgba(230, 159, 0, 0.4)`, a raw --amber literal, and it was the only warm
   value left in the sheet that was neither a token nor a data colour. It is now --straw-line,
   for two reasons rather than one.

   The colour reason: --amber is N-glycosylation in the science palette and is ALSO spent on
   .badge-missense, a collision the header note already records as a debt. A third meaning
   made it worse, and this list is dense enough that a repeated ochre rule down its left edge
   was a real warm patch on a panel that is now cool. It is also the first panel a visitor
   sees, because the cohort drawer is the landing gate.

   The doctrinal reason: the flag says "this study is not patient data, so it is out of your
   default selection". That is a caveat about the control you are operating, not a measurement
   plotted from the data, which puts it in the same job as the one notice band that tells you
   how much of the table you are being shown. That band is straw, so this is straw.
   --straw-line rather than --straw because the comment above promises "subtle": the amber at
   40% over the white panel resolved to about #f5d999, and --straw-line is a shade stronger
   than that, which is the right direction for a flag without turning it into a warning. */
.cohort-study-item.model-system {
    /* !important here beats .cohort-study-item's own `border: none !important` a few rules
       up, not another stylesheet. Both live in this file. */
    border-left: 2px solid var(--straw-line) !important;
    padding-left: 8px !important;
}

.cohort-study-item input[type="checkbox"] {
    width: 14px;
    height: 14px;
    accent-color: var(--primary);
    cursor: pointer;
    flex-shrink: 0;
}

.cohort-study-code {
    font-weight: 700;
    font-size: 0.8rem;
    color: var(--primary-light);
    font-family: 'Courier New', monospace;
    letter-spacing: 0.04em;
    min-width: 80px;
}

.cohort-study-fullname {
    font-size: 0.82rem;
    color: var(--text-secondary);
    flex: 1;
}

.cohort-study-patient-count {
    font-size: 0.75rem;
    color: var(--text-secondary);
    text-align: right;
    white-space: nowrap;
    margin-left: auto;
    font-variant-numeric: tabular-nums;
}

/* Modal footer. It was `rgba(0,0,0,0.2)`, a 20% black wash inherited from the dark theme,
 * which over the white panel resolved to flat #cccccc: an untinted object on a tinted site,
 * and the user named it. It is now the same chrome plate as the table header and the table
 * footer, with the structural edge above it, so the bar that holds Apply reads as the
 * same kind of furniture wherever it appears. */
.cohort-modal-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 16px 28px;
    border-top: 1px solid var(--border-color-strong);
    flex-shrink: 0;
    background: var(--bg-header);
}

.cohort-count-badge {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--primary-light);
    background: var(--primary-glow);
    padding: 6px 14px;
    border-radius: 20px;
    border: 1px solid var(--border-color);
}

/* HPA Profile Card and Tabs */
.hpa-tabs {
    display: flex;
    gap: 8px;
}

.hpa-tab-btn {
    background: none;
    border: 1px solid var(--border-color);
    color: var(--text-secondary);
    font-family: var(--font-family);
    font-size: 0.8rem;
    font-weight: 500;
    padding: 6px 12px;
    border-radius: 6px;
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: 6px;
    transition: var(--transition-fast);
}

.hpa-tab-btn i {
    width: 14px;
    height: 14px;
}

.hpa-tab-btn:hover {
    color: var(--text-primary);
    background-color: var(--straw-wash);
    border-color: var(--text-secondary);
}

.hpa-tab-btn.active {
    color: var(--text-primary);
    background-color: var(--primary-glow);
    border-color: var(--primary-light);
    box-shadow: 0 1px 2px rgba(39, 70, 89, 0.22);
}

.hpa-prognosis-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
    gap: 12px;
    padding: 4px;
}

.hpa-prog-card {
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    border-radius: 10px;
    padding: 12px;
    display: flex;
    flex-direction: column;
    gap: 6px;
    transition: var(--transition-fast);
}

/* The whole card is the door now, not just the plot inside it, so the affordance lives here:
 * one zoom cursor, one lift, one border colour change.
 * Gated on .is-zoomable, which app_v1.3.js sets on the card only when there is a published
 * Human Protein Atlas curve to enlarge. A card without it must look completely inert, because
 * clicking it does nothing, and a lift that leads nowhere is worse than no lift.
 *
 * NO BACKGROUND CHANGE ON HOVER, at any size, on the card or on the plot panel inside it.
 * The wash moved from the plot to the card in the belief that a fill is harmless once it
 * lands on chrome, and it is not: the card background sits BEHIND the chart, so a straw wash
 * still tints the plot area, which is the shading the user reported twice. The affordance is
 * carried entirely by things that are not a fill. */
.hpa-prog-card.is-zoomable {
    cursor: zoom-in;
}

.hpa-prog-card.is-zoomable:hover,
.hpa-prog-card.is-zoomable:focus-visible {
    transform: translateY(-1px);
    border-color: var(--straw-line);
    outline: none;
}

/* Olive, because focus is commitment and hover is straw; the two must not use one colour. */
.hpa-prog-card.is-zoomable:focus-visible {
    box-shadow: 0 0 0 2px var(--primary-glow);
}

.hpa-prog-card.favorable {
    border-color: rgba(0, 158, 115, 0.3);
    background: rgba(0, 158, 115, 0.04);
}

.hpa-prog-card.favorable.is-zoomable:hover,
.hpa-prog-card.favorable.is-zoomable:focus-visible {
    border-color: var(--emerald);
}

.hpa-prog-card.unfavorable {
    border-color: rgba(204, 121, 167, 0.3);
    background: rgba(204, 121, 167, 0.04);
}

.hpa-prog-card.unfavorable.is-zoomable:hover,
.hpa-prog-card.unfavorable.is-zoomable:focus-visible {
    border-color: var(--pink);
}

.hpa-prog-title {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-primary);
    white-space: normal;
    word-break: break-word;
    line-height: 1.35;
}

.hpa-prog-status {
    display: flex;
    align-items: center;
    gap: 6px;
    font-size: 0.75rem;
    font-weight: 500;
}

.hpa-prog-status.favorable {
    color: var(--emerald);
}

.hpa-prog-status.unfavorable {
    color: var(--pink);
}

.hpa-prog-status.unprognostic {
    color: var(--text-muted);
}

.hpa-prog-pval {
    font-family: monospace;
    font-size: 0.72rem;
    color: var(--text-secondary);
}

/* The KM canvas draws its own axes/labels in JS (drawKM in app_v1.3.js) and was built for
 * the old dark theme: a translucent-black panel with near-invisible white hairlines. On
 * paper it needs the opposite, a card that matches its siblings above. */
/* A thumbnail this size can show the shape of a curve but not read a value off it, so the
 * card is a door to the full-size view.
 *
 * The plot panel carries NO hover state, and neither does the card around it any more. The
 * panel used to take a --straw-tint fill, which washed pale yellow straight across the plot
 * area: the one surface on the card that is data rather than chrome, and yellow is this
 * site's highlighter, so it read as if something in the chart had been marked. Moving that
 * wash up to .hpa-prog-card did not fix it, because the card is what the chart is drawn on
 * top of, so the tint came through the plot exactly as before. Hover is now cursor, lift and
 * border only. Do not reintroduce a fill at either level. */
.hpa-km-wrapper {
    position: relative;
    margin-top: 8px;
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    border-radius: 6px;
    padding: 6px;
}

.hpa-km-zoom-hint {
    position: absolute;
    left: 10px;
    bottom: 10px;
    display: inline-flex;
    align-items: center;
    gap: 4px;
    padding: 2px 8px;
    border-radius: 999px;
    background: var(--bg-card);
    border: 1px solid var(--straw-line);
    color: var(--primary-light);
    font-size: 0.62rem;
    font-weight: 600;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    opacity: 0;
    transform: translateY(2px);
    transition: opacity 0.14s ease, transform 0.14s ease;
    pointer-events: none;
}

/* Driven from the card, because the card is what you hover and what you click. */
.hpa-prog-card.is-zoomable:hover .hpa-km-zoom-hint,
.hpa-prog-card.is-zoomable:focus-visible .hpa-km-zoom-hint {
    opacity: 1;
    transform: none;
}

.hpa-km-zoom-hint svg {
    width: 11px;
    height: 11px;
}

/* No-curve prognosis cards. kmModel no longer invents a curve when the Human Protein Atlas
 * published none, so these cards render the measured direction and p-value as two sentences
 * inside the panel the plot used to occupy. Reusing .hpa-km-wrapper keeps the card rhythm;
 * withholding .is-zoomable from the CARD is what removes the zoom cursor, the hover lift and
 * the Enlarge chip, because there is nothing to enlarge. The cursor is repeated here anyway so
 * the panel stays inert even if a caller marks the card zoomable by mistake. */
.hpa-km-wrapper.is-nocurve {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 6px;
    min-height: 110px;
    padding: 10px 12px;
    cursor: default;
}

.hpa-km-wrapper.is-nocurve p {
    margin: 0;
    font-size: 0.74rem;
    line-height: 1.45;
    color: var(--text-secondary);
}

/* The second sentence explains the absence rather than reporting a finding, so it sits a
 * step back from the first. */
.hpa-km-wrapper.is-nocurve p + p {
    color: var(--text-muted);
}

.hpa-km-wrapper.is-nocurve b {
    color: var(--text-primary);
    font-weight: 600;
}

/* Shown only while the survival bucket is still in flight. "Not fetched yet" and "never
 * published" are different claims, so they must not share a look. */
.hpa-km-wrapper.is-nocurve.is-pending {
    justify-content: center;
    align-items: center;
}

.hpa-km-wrapper.is-nocurve.is-pending p {
    color: var(--text-muted);
    font-style: italic;
}

/* ─── Enlarged survival curve ───────────────────────────────────────────────
 * Above .full-page-modal (9999), because that IS the gene popup this opens from. Anything
 * lower renders behind the card that launched it. The theme lab keeps 99999 and stays on
 * top of both, deliberately, since its whole job is to be reachable while something else
 * is on screen. */
.km-zoom {
    position: fixed;
    inset: 0;
    z-index: 10000;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 28px;
    background: rgba(35, 43, 50, 0.32);
    backdrop-filter: blur(2px);
}

.km-zoom[hidden] { display: none; }

body.km-zoom-open { overflow: hidden; }

.km-zoom-panel {
    display: flex;
    flex-direction: column;
    gap: 12px;
    width: min(940px, 100%);
    padding: 18px 20px 16px;
    background: var(--bg-card);
    border: 1px solid var(--border-color-strong);
    border-radius: 12px;
    box-shadow: 0 18px 48px rgba(35, 43, 50, 0.2);
}

.km-zoom-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
}

.km-zoom-title {
    font-size: 1.02rem;
    font-weight: 650;
    letter-spacing: -0.01em;
    color: var(--text-primary);
}

.km-zoom-status {
    margin-top: 3px;
    font-size: 0.76rem;
    font-weight: 600;
    color: var(--text-muted);
}

.km-zoom-status.favorable { color: var(--emerald); }
.km-zoom-status.unfavorable { color: var(--pink); }

.km-zoom-close {
    flex-shrink: 0;
    width: 30px;
    height: 30px;
    line-height: 1;
    font-size: 1.2rem;
    color: var(--text-secondary);
    background: var(--bg-card);
    border: 1px solid var(--border-color-strong);
    border-radius: 8px;
    cursor: pointer;
    transition: background-color 0.14s ease, border-color 0.14s ease, color 0.14s ease;
}

.km-zoom-close:hover {
    background: var(--straw-tint);
    border-color: var(--straw-line);
    color: var(--text-primary);
}

.km-zoom-plot {
    position: relative;
    height: min(520px, 58vh);
}

.km-zoom-canvas {
    display: block;
    width: 100%;
    height: 100%;
    border-radius: 8px;
    cursor: crosshair;
}

.km-zoom-canvas:focus-visible {
    outline: 2px solid var(--primary);
    outline-offset: 2px;
}

.km-zoom-readout {
    position: absolute;
    min-width: 172px;
    padding: 8px 10px;
    background: var(--bg-card);
    border: 1px solid var(--border-color-strong);
    border-radius: 8px;
    box-shadow: 0 6px 18px rgba(35, 43, 50, 0.14);
    font-size: 0.76rem;
    pointer-events: none;
}

.km-zoom-readout[hidden] { display: none; }

.km-ro-time {
    margin-bottom: 5px;
    font-family: var(--font-mono);
    font-size: 0.8rem;
    font-weight: 600;
    color: var(--text-primary);
}

.km-ro-row {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-top: 3px;
    color: var(--text-secondary);
}

.km-ro-row b {
    margin-left: auto;
    font-family: var(--font-mono);
    color: var(--text-primary);
}

.km-ro-dot {
    flex-shrink: 0;
    width: 8px;
    height: 8px;
    border-radius: 50%;
}

/* .km-ro-gap is gone with the line it styled. The scrub readout reported the difference
 * between the two arms in percentage points, a derived number stated to the point, which
 * invites reading a precision these step curves do not carry. Both arms and the year remain. */

.km-zoom-foot {
    font-size: 0.73rem;
    line-height: 1.5;
    color: var(--text-muted);
}

@media (prefers-reduced-motion: reduce) {
    .hpa-prog-card,
    .hpa-km-zoom-hint,
    .km-zoom-close {
        transition: none;
    }
}

/* Multi-Omics "bubble" sections in the hotspot modal: all shown at once, no tab click required */
.omics-bubble-grid {
    display: flex;
    flex-direction: column;
    gap: 16px;
}

.omics-bubble {
    border: 1px solid var(--border-color);
    border-radius: 10px;
    background: var(--bg-card);
    overflow: hidden;
}

.omics-bubble-header {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 10px 14px;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
    background: var(--bg-header);
    border-bottom: 1px solid var(--border-color);
}

.omics-bubble-header i {
    width: 15px;
    height: 15px;
    color: var(--primary);
}

.omics-bubble-body {
    padding: 12px;
}

/* HPA Normal Tissue Accordion Styles */
.hpa-tissue-container {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding-right: 4px;
}

/* Lighter than the table header it used to share a token with. This band is inside the data
 * panel, not chrome around it, and --bg-header stacked a second tan layer under bars that
 * were already losing to their track. */
.hpa-group-row {
    display: flex;
    align-items: center;
    padding: 4px 10px;
    background: var(--bg-track);
    border: 1px solid var(--border-color);
    border-radius: 6px;
    cursor: pointer;
    transition: all 0.2s ease;
    gap: 8px;
}

.hpa-group-row:hover {
    background: var(--straw-tint);
    border-color: var(--straw-line);
}

.hpa-group-info {
    display: flex;
    align-items: center;
    gap: 6px;
    width: 200px;
    flex-shrink: 0;
}

.hpa-group-chevron {
    transition: transform 0.2s ease;
    color: var(--text-secondary);
    display: flex;
    align-items: center;
    justify-content: center;
}

.hpa-group-row.expanded .hpa-group-chevron {
    transform: rotate(90deg);
}

.hpa-group-name {
    font-size: 0.78rem;
    font-weight: 500;
    color: var(--text-primary);
}

/* The group-level bar, and the most prominent mark in the panel, so it gets the same track
 * and the same edge as the sub-bars below it. Leaving this one on --bg-sunken was the visible
 * half of the problem: the tall bars sat on the darkest surface on the site while the short
 * ones under them did not. */
.hpa-bar-wrapper {
    flex-grow: 1;
    height: 8px;
    background: var(--bg-track);
    border-radius: 3px;
    overflow: hidden;
    position: relative;
}

.hpa-bar-fill {
    height: 100%;
    border-radius: 3px;
    box-shadow: inset 0 0 0 1px rgba(35, 43, 50, 0.22);
    transition: width 0.3s ease;
}

.hpa-val-label {
    width: 70px;
    text-align: right;
    font-size: 0.75rem;
    font-family: monospace;
    color: var(--text-secondary);
    flex-shrink: 0;
}

.hpa-subtissues-container {
    display: flex;
    flex-direction: column;
    gap: 2px;
    padding-left: 16px;
    border-left: 1px dashed var(--border-color-strong);
    margin-left: 13px;
    margin-bottom: 2px;
    overflow: hidden;
    transition: max-height 0.3s ease-out;
}

.hpa-subtissue-row {
    display: flex;
    align-items: center;
    padding: 2px 10px;
    gap: 8px;
}

/* "This tissue is one your selected cohort maps to." A standing fact about the cohort you
 * picked, so olive, not straw. It was `rgba(6, 182, 212, 0.03)`, a Tailwind cyan from the
 * dead dark theme at 3% alpha, which is not visible against anything. */
.hpa-subtissue-row.is-cohort-match,
.hpa-group-row.is-cohort-match {
    background: var(--primary-tint);
    border-left: 2px solid var(--primary);
    padding-left: 8px;
}

.hpa-subtissue-name {
    font-size: 0.74rem;
    color: var(--text-secondary);
    width: 181px;
    flex-shrink: 0;
    text-transform: capitalize;
}

.hpa-subtissue-bar {
    flex-grow: 1;
    height: 5px;
    background: var(--bg-track);
    border-radius: 2px;
    overflow: hidden;
}

/* The tissue palette spans the whole lightness range, #1977B4 through #FEEF79, so no single
 * track value can carry all seven: light paper hides the pale yellow exactly as tan paper hid
 * the pale lavender. The edge is what closes that gap, for the same reason --highlight-edge
 * exists. A pale shape on pale paper has no silhouette of its own. The 0.75 opacity is gone
 * with it: it was muting every category toward the surface underneath, which is the opposite
 * of what a categorical fill is for. */
.hpa-subtissue-bar-fill {
    height: 100%;
    border-radius: 2px;
    box-shadow: inset 0 0 0 1px rgba(35, 43, 50, 0.22);
}




/* =========================================
   Hotspots Database View
========================================= */
.view-nav-btn {
    background: transparent;
    border: none;
    color: var(--text-secondary);
    padding: 6px 12px;
    font-size: 0.9rem;
    font-family: var(--font-family);
    font-weight: 500;
    cursor: pointer;
    border-radius: 6px;
    transition: var(--transition-fast);
}

.view-nav-btn:hover {
    color: var(--text-primary);
    background: var(--straw-wash);
}

.view-nav-btn.active {
    background: var(--accent);
    color: #fff;
    box-shadow: 0 1px 2px rgba(39, 70, 89, 0.22);
}

/* No horizontal scrolling, ever. All 13 columns are sized to fit the content pane.
 *
 * Why it scrolled: the container carries an inline `overflow-y: auto`, and CSS does not allow
 * one axis to be `auto` while the other stays `visible`, so the browser promotes overflow-x to
 * `auto` too. That gave a scrollbar the moment the natural table width exceeded the pane, and
 * it did, by about 80px, which cut the DepMap column in half. The width was not going into
 * data: every sortable header carries a sort caret plus a filter button, so nine of the
 * thirteen headers were ~28px wider than their text, and `table-layout: auto` then handed each
 * column whatever its widest cell asked for.
 *
 * `table-layout: fixed` plus explicit percentages takes that decision back: the table is
 * exactly 100% of the pane at every viewport, so there is nothing to scroll. Percentages, not
 * pixels, so this holds at any window width rather than only at the one it was measured on.
 * The percentages sum to exactly 100 -- keep it that way if you re-tune them.
 *
 * The trade is that a long value now wraps instead of widening its column. That is why the
 * text columns get the generous share and the numeric ones, whose contents are never wider
 * than their own headers, get the least. */
#hotspots-table {
    width: 100%;
    table-layout: fixed;
    border-collapse: collapse;
    font-size: 0.85rem;
}

/* THE WIDTHS ARE COMPUTED IN JS. HOTSPOTS_COL_WEIGHTS in app_v1.3.js is the source, and
 * applyHotspotsColumnWidths() normalizes the weights of whatever columns are currently
 * attached to 100 and writes each one on as an inline width. Inline beats this sheet, so
 * from the first render onwards nothing below decides anything.
 *
 * table-layout is fixed and .table-container clips rather than scrolls horizontally, so the
 * percentages ARE the layout: over 100 is clipped, under 100 leaves a dead strip on the
 * right. That constraint is why the weights are normalized rather than hand-summed, and it is
 * the one reason `table-layout: fixed` above must stay.
 *
 * WHAT WAS HERE AND IS GONE: three more sets of these declarations, 38 in all, keyed on
 * `#hotspots-table.no-dyn-col` and `#hotspots-table.no-cohort-col`, one set per combination
 * of "is the Residue/Mutation column attached" and "is the Cohort Cases column attached".
 * Nothing has applied either class since the weights table landed. syncHotspotsDynColumn()
 * in app_v1.3.js says so in as many words, and syncHotspotsCohortCasesColumn is not a
 * function any more; both switches detach the <th> outright, and a detached column needs no
 * width. Four hand-authored sets could not have survived the column chooser anyway, which
 * would need one set per SUBSET of fourteen columns.
 *
 * The fourteen below are kept, and only for the gap between parsing this sheet and the first
 * applyHotspotsColumnWidths() call: the <thead> is markup in both index.html files, so it is
 * on screen before any script has run. They are the full fourteen-column case and they are
 * the same numbers as the weights (Prognostic keeps 8.5% because "Prognostic" is the longest
 * header that cannot break at a space; Drug target matches it but can break). If the weights
 * move, these may drift and it costs one frame, which is the whole reason it is safe to keep
 * a duplicate here at all. Do not add class-keyed variants back.
 * 8.5 + 8 + 8.5 + 9.5 + (6.5 x 5) + 5 + 5 + 8.5 + 6 + 8.5 = 100.0. */
#hotspots-table th:nth-child(1)  { width: 8.5%; }    /* Gene */
#hotspots-table th:nth-child(2)  { width: 8%; }      /* Residue "317" or Mutation "p.S207C" */
#hotspots-table th:nth-child(3)  { width: 8.5%; }    /* Location: wraps to 2 lines */
#hotspots-table th:nth-child(4)  { width: 9.5%; }    /* Cancer Type: longest values */
#hotspots-table th:nth-child(5)  { width: 6.5%; }    /* Global Cases */
#hotspots-table th:nth-child(6)  { width: 6.5%; }    /* Cohort Cases */
#hotspots-table th:nth-child(7)  { width: 6.5%; }    /* Cohort % */
#hotspots-table th:nth-child(8)  { width: 6.5%; }    /* Robust % */
#hotspots-table th:nth-child(9)  { width: 6.5%; }    /* Cancer Types Affected */
#hotspots-table th:nth-child(10) { width: 5%; }      /* Tissue */
#hotspots-table th:nth-child(11) { width: 5%; }      /* Cancer */
#hotspots-table th:nth-child(12) { width: 8.5%; }    /* Prognostic: widest badge header */
#hotspots-table th:nth-child(13) { width: 6%; }      /* DepMap */
#hotspots-table th:nth-child(14) { width: 8.5%; }    /* Drug target */

/* Under fixed layout a cell no longer grows to fit, so a long VALUE has to be allowed to
 * wrap inside its column, otherwise it overflows and lands on its neighbour. */
#hotspots-table td {
    overflow-wrap: break-word;
}

/* Headers are the exception: they wrap between words but never inside one. Breaking a word
 * split "PROGNOSTIC" across two lines as "PROGNOS TIC", which reads as two truncated words
 * rather than one wrapped one. Column names are short and known, so if one does not fit its
 * column the answer is to re-tune the percentage above, not to hyphenate the header. */
#hotspots-table th {
    overflow-wrap: normal;
    word-break: keep-all;
}

/* Belt and braces on the scroll axis: the table above cannot exceed the pane, so this only
 * matters if a future column is added without re-tuning the percentages. Better that a stray
 * column is clipped than that the header, which is position: sticky and therefore does not
 * scroll sideways with the body, silently desynchronises from its own data.
 *
 * `clip`, NOT `hidden`, AND THAT IS LOAD BEARING. The two clip identically. The difference is
 * that `hidden` makes the element a scroll container and `clip` does not, and CSS will not let
 * one axis be a scroll container while the other is `visible`: set `overflow-x: hidden` and
 * the used value of `overflow-y` is forced from `visible` up to `auto`. So the `hidden` this
 * rule used to carry quietly turned .table-container into the nearest scrollport on BOTH axes.
 *
 * That broke the sticky <thead> below, which is the whole reason this is written out at
 * length. A sticky element sticks to its nearest scrollport, and .table-container deliberately
 * has no height cap any more (see the card-body comment in index.html), so it is exactly as
 * tall as the table it holds and can never scroll vertically. The header was pinned to a box
 * that does not move, which on screen is indistinguishable from `position: sticky` not being
 * there at all: you scroll the rows and the column names leave with them.
 *
 * With `clip` no scrollport is created here, the search continues up the ancestor chain, and
 * the header resolves to .main-content, which is the element that actually scrolls in v2. Do
 * not swap this back to `hidden` to appease a linter or an old browser target: the clipping
 * is the incidental part and the not-becoming-a-scroll-container is the point.
 *
 * Support: overflow `clip` is Chrome and Edge 90, Firefox 81, Safari 16.0. Baseline widely
 * available since Safari 16 passed the 30-month mark in early 2025, so it needs no fallback.
 * A browser old enough to ignore it drops the declaration entirely and gets `visible`, which
 * shows a stray column instead of clipping it and leaves the sticky header working. That is a
 * strictly better failure than the `hidden` this replaces. */
.table-container:has(#hotspots-table) {
    overflow-x: clip;
}

/* The header band was `rgba(0,0,0,0.2)`, a 20% black wash that rendered as a hard grey slab
 * across the top of the one table this whole site exists to show. It is now the same sunken
 * paper as every other inset surface, separated from the rows by a rule instead of by tone,
 * and it carries a 2px olive underline so the sticky header still reads as a header once the
 * rows scroll under it. */
#hotspots-table th {
    background: var(--bg-header);
    padding: 10px 8px;
    text-align: left;
    font-weight: 600;
    font-size: 0.72rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-secondary);
    border-bottom: 2px solid var(--border-color-strong);
    position: sticky;
    /* Zero, not a menubar offset. The .table-container around this table deliberately has no
     * height cap any more, so the header's scrollport is whichever ancestor scrolls: in v2
     * that is .main-content, which already begins under the menubar, and in v1 it is the
     * viewport, which has no menubar above it. Either way the header lands flush against the
     * top of its own pane and any positive offset here would open a dead strip.
     *
     * That description was true when it was written, then quietly stopped being true and the
     * comment did not notice, which is worth spelling out because the failure was invisible in
     * the source. `overflow-x: hidden` was added to .table-container purely as a clipping
     * guard, but `hidden` also makes an element a scroll container, so .table-container itself
     * became the nearest scrollport and the search never reached .main-content at all. The
     * header was sticking to a box with no height cap, therefore a box that never scrolls,
     * therefore not appearing to stick to anything. `top: 0` was correct the whole time and
     * was not the bug.
     *
     * It is `overflow-x: clip` there now, which clips the same and creates no scrollport, so
     * the sentence above is accurate again. Two things have to stay true for it to remain so:
     * .main-content keeps `overflow-y: auto` (set in shell.css), and no ancestor between this
     * <thead> and .main-content acquires an `overflow` value other than `visible` or `clip`.
     * Adding `overflow: hidden` or `auto` anywhere in that chain reintroduces exactly this
     * bug, and it will look like the sticky positioning broke rather than like the ancestor
     * changed. */
    top: 0;
    z-index: 10;
}

#hotspots-table th.sortable {
    cursor: pointer;
    user-select: none;
    transition: var(--transition-fast);
}

#hotspots-table th.sortable:hover {
    color: var(--text-primary);
    background: var(--straw-tint);
}

/* The column you sorted by. Twelve near-identical grey carets gave no answer to "which
 * column is this table in the order of", which is the first thing you need to know when you
 * are reading the top 300 rows of 8,945. The olive tint and underline answer it from across
 * the room. .is-sorted is set by updateHotspotsSortIndicators in app_v1.3.js; the primary
 * sort gets the full underline and a tiebreaker column gets only the tint, which matches
 * the superscript rank the same function already draws. */
#hotspots-table th.is-sorted {
    background: var(--primary-tint);
    color: var(--primary-light);
}

#hotspots-table th.is-sorted-primary {
    border-bottom-color: var(--primary);
}

#hotspots-table th.is-sorted .sort-icon {
    color: var(--primary);
}

#hotspots-table th.is-sorted:hover {
    background: var(--primary-tint-hover);
}

#hotspots-table .sort-icon-wrap {
    display: inline-flex;
    align-items: baseline;
}

#hotspots-table .sort-tiebreak-order {
    opacity: 0.65;
    font-size: 0.65em;
    margin-left: 1px;
}

#hotspots-table .col-filter-btn {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    margin-left: 6px;
    padding: 2px;
    background: transparent;
    border: none;
    border-radius: 4px;
    color: var(--text-muted);
    cursor: pointer;
    opacity: 0.5;
    transition: var(--transition-fast);
    vertical-align: middle;
}

#hotspots-table .col-filter-btn:hover {
    opacity: 1;
    color: var(--text-primary);
    background: var(--bg-sunken);
}

#hotspots-table .col-filter-btn.active {
    opacity: 1;
    color: var(--primary-light);
}

#hotspots-table .col-filter-btn .filter-icon {
    width: 12px;
    height: 12px;
}

#hotspots-table .col-filter-btn.active .filter-icon {
    color: var(--primary-light);
}

/* Active-filter indicator dot */
#hotspots-table .col-filter-btn.active::after {
    content: '';
    position: absolute;
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: var(--primary-light);
    transform: translate(6px, -6px);
}

.col-filter-popover {
    /* Opaque, not the translucent --bg-card the cards use: this floats over the densest
       table in the app, and at ~30 cancer-type rows the text behind it showed through the
       labels badly enough to be hard to read. Same colour, no alpha. */
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    padding: 10px;
    display: flex;
    flex-direction: column;
    gap: 6px;
    z-index: 1000;
    box-shadow: 0 6px 20px rgba(39, 70, 89, 0.22);
    font-size: 0.85rem;
    font-weight: 400;
    color: var(--text-primary);
}

/* The options scroll, the buttons do not. Cancer Type lists every present cancer group
 * (about 30 checkboxes), which used to grow the popover straight past the bottom of the
 * window; positionColFilterPopover() caps the outer height and this pair decides what gives
 * way. .cf-actions is last and unshrinkable, so Apply/Clear stay reachable at any height. */
.col-filter-popover .cf-body {
    display: flex;
    flex-direction: column;
    gap: 6px;
    overflow-y: auto;
    min-height: 0;      /* a flex item defaults to min-content, which refuses to scroll */
    padding-right: 4px; /* keep the scrollbar off the checkbox labels */
}

.col-filter-popover .cf-actions {
    display: flex;
    gap: 6px;
    flex-shrink: 0;
    padding-top: 8px;
    border-top: 1px solid var(--border-color);
}

/* Gene type-ahead in the Hotspot/Gene text filters (initColFilterGeneSuggest). Deliberately
 * inside .cf-body rather than floating: it scrolls with the input and stays inside the
 * popover's height cap, so a long list can never push Apply/Clear off screen again. */
.col-filter-popover .cf-ac {
    list-style: none;
    margin: 0;
    padding: 0;
    max-height: 170px;
    overflow-y: auto;
    border: 1px solid var(--border-color);
    border-radius: 6px;
    background: var(--bg-card);
}

.col-filter-popover .cf-ac li {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 12px;
    padding: 4px 8px;
    cursor: pointer;
    white-space: nowrap;
}

/* Was --bg-sunken, the same value as the list behind it, so neither the cursor nor the
 * keyboard walk showed at all. Straw, because both mean "candidate you are pointing at". */
.col-filter-popover .cf-ac li.is-on,
.col-filter-popover .cf-ac li:hover {
    background: var(--straw-tint);
}

.col-filter-popover .cf-ac em {
    font-style: normal;
    font-size: 0.75rem;
    color: var(--text-secondary);
}

/* 90px (the shared rule below) fits a number, not a gene symbol plus room to keep typing. */
.col-filter-popover input#cf-text {
    width: 170px;
}

.col-filter-popover input {
    width: 90px;
    background: var(--bg-card);
    color: var(--text-primary);
    border: 1px solid var(--border-color-strong);
    border-radius: 4px;
    padding: 3px 6px;
}

.col-filter-popover input[type="checkbox"] {
    width: auto;
    padding: 0;
}

.col-filter-popover button {
    background: var(--primary);
    color: #fff;
    border: none;
    border-radius: 4px;
    padding: 4px 10px;
    cursor: pointer;
    font-size: 0.8rem;
    transition: var(--transition-fast);
}

.col-filter-popover button:hover {
    background: var(--primary-light);
}

.col-filter-popover button#cf-clear {
    background: var(--secondary);
}

#hotspots-table td {
    padding: 8px;
    border-bottom: 1px solid var(--border-color);
    color: var(--text-primary);
    vertical-align: middle;
}

#hotspots-table tbody tr {
    transition: background 0.15s ease;
    cursor: pointer;
}

/* Row hover is the one interaction that matters most here, because a row is 15 columns wide
 * and the number you are trying to read is usually nowhere near the gene name that tells you
 * which row you are on. A 10% black wash did the job badly: it dimmed the row you pointed at,
 * so the act of aiming at a row made it harder to read.
 *
 * Straw wash plus a 3px olive spine on the leading edge instead. The spine is an inset
 * box-shadow rather than a border-left so it costs no layout: a real border would shift all
 * fifteen cells 3px sideways on hover and the table would twitch as the cursor runs down it.
 * It hangs on the first CELL, not on the row, because this table is border-collapse: collapse
 * and a collapsed table's <tr> has no box of its own to paint a shadow on. */
#hotspots-table tbody tr:hover {
    background: var(--straw-tint);
}

#hotspots-table tbody tr:hover td:first-child {
    box-shadow: inset 3px 0 0 var(--primary);
}

/* Positional text is mono: a residue number, a mutation like "p.S207C", and anything else
 * that is really a coordinate. It used to be the first column, back when that column was the
 * "ERBB3 353" hotspot id; the id is gone and the coordinate now lives in the dynamic second
 * column, so the mono treatment moved with it. The gene symbol in column one is a name, not a
 * coordinate, and reads as the site's own type. */
#hotspots-table tbody tr td.hs-dyn {
    font-family: var(--font-mono);
    font-size: 0.76rem;
    font-weight: 500;
    letter-spacing: -0.02em;
    color: var(--text-primary);
}

/* Badge Match / Miss Styles */
.badge-omics {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 24px;
    height: 24px;
    border-radius: 50%;
    font-size: 12px;
    margin: 0 auto;
}

/* Present/absent is a boolean, and a boolean needs contrast, not hue. These were sky blue,
 * which is N-glycosylation's colour elsewhere, so five columns of blue ticks were competing
 * with the one palette the reader has to learn.
 *
 * Five columns of solid olive discs turned out to be its own problem: at 24px, filled and
 * repeated down 300 rows, they carried more visual weight than the counts they sit beside,
 * and olive is supposed to mean "you chose this", which a data cell never is. So a match is
 * an olive ring and an olive tick over the faintest olive fill, and a miss is a hairline ring
 * with a muted dash. Same information, a fraction of the ink.
 *
 * THERE IS ONE MATCH STYLE AND THERE MUST STAY ONE. Five badge columns run side by side here,
 * Tissue, Cancer, Prognostic, DepMap and Drug target, and the distinction that used to be
 * drawn (filled for the HPA-backed columns, hollow for DepMap) encoded which upstream
 * database a column came from. That is provenance, not a finding, and it is already stated in
 * the column header and on the Data tab.
 *
 * The comment that stood here previously claimed all of them already read the same. They did
 * not: this rule set `background: transparent` and a second `.badge-omics.match.accent` rule
 * layered `var(--primary-glow)` on top of it, so DepMap alone, the one column emitting the
 * plain `match` class, rendered as a hollow disc in a row of tinted ones. That is what the
 * user saw and reported as "the DepMap check marks look different".
 *
 * Fixed on both sides so it cannot drift apart again. app_v1.3.js now emits BADGE_MATCH_ACCENT
 * for all five columns and the hollow BADGE_MATCH variant was deleted outright, and the two
 * rules here are merged into this one, so there is no longer a selector that could give one
 * column a different fill. The `accent` class is still in the markup and is now inert; leave
 * it, because reaching into the JS to strip a harmless class name is how the next split gets
 * introduced. If a future column genuinely needs to look different, that is a new class with
 * a stated reason, not a resurrection of this one. */
.badge-omics.match {
    background: var(--primary-glow);
    border: 1.5px solid var(--primary);
    color: var(--primary-light);
    box-shadow: none;
}

.badge-omics.miss {
    background: transparent;
    border: 1px solid var(--border-color);
    color: var(--border-color-strong);
}

/* =========================================
   Full-Page Hotspot Modal
========================================= */
.full-page-modal {
    position: fixed;
    inset: 0;
    z-index: 9999;
    background: var(--bg-card);
    backdrop-filter: blur(10px);
    display: flex;
    justify-content: center;
    align-items: center;
}

.full-page-modal-content {
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    box-shadow: 0 25px 50px -12px rgba(39, 70, 89, 0.16);
    border-radius: 12px;
    width: 95vw;
    height: 95vh;
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

/* THE MODAL COLUMN MUST MEASURE THE SAME AS THE GENES VIEW'S COLUMN.
 *
 * All six declarations here were an inline style attribute on the element in both
 * index.html files, which is why no stylesheet rule had ever been able to touch this
 * geometry: an inline declaration outranks any selector short of !important. Stripping the
 * attribute is the other half of this change and is what lets the rule below apply at all.
 *
 * The cap is not taste. renderSequencePanel decides how many residues fit on a line by
 * measuring the panel's clientWidth in seqPerLine(), so an uncapped modal on a 27 inch
 * screen lays the SAME protein out at a different residues-per-line and a different ruler
 * cadence from the Genes view. Two numbers for one sequence is the kind of divergence a
 * reader reasonably reads as a data difference.
 *
 * 1124px IS 1180px, AND COPYING 1180 HERE WAS THE BUG. .op-gv in shell.css caps ITSELF at
 * 1180px and carries the 28px side padding inside that cap, because the universal
 * box-sizing: border-box above applies to it too. So a Genes-view card measures
 * 1180 - 28 - 28 = 1124px. Here the padding is on .modal-body, the scroll container, and the
 * cap is on its children, so a card capped at 1180px measured 1180px: 56px wider than the
 * column it was supposed to match. At the shared 0.79rem monospace that is a whole extra
 * ten-residue block on the line, 130 residues against 120, with the ruler numbering
 * every tenth column in a different place. The two panels are direct children of a
 * .chart-card in both views and that card's 24px padding and 1px border are the same on
 * both sides, so equal card width is the whole of what equal measurement needs.
 *
 * If .op-gv's 1180px or its 28px side padding ever moves, this number is 1180 minus twice
 * the padding and must move with it.
 *
 * The cap is on the CHILDREN rather than on .modal-body itself because .modal-body is the
 * scroll container. Capping the scroller would inset the scrollbar and the header rule with
 * it; capping each panel leaves the scrollbar on the window edge where the reader expects
 * it and centres the content inside. .cohort-modal-body is a different class and a different
 * component, and is untouched. */
.modal-body {
    padding: 26px 28px 70px;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: 20px;
    flex-grow: 1;
}

.modal-body > * {
    width: 100%;
    max-width: 1124px;   /* .op-gv's 1180px cap minus its own 2 x 28px side padding */
    margin-left: auto;
    margin-right: auto;
}

/* EMPTINESS IS THE SIGNAL, and this rule is the only thing acting on it.
 *
 * The modal's key used to be shown and hidden by writing style.display on the element from
 * JS. That is gone: renderPtmLegend either writes keys or writes nothing, and a legend host
 * with nothing in it must not spend its parent's 20px flex gap on a blank line. The two
 * mechanisms must never both exist, because an inline display: flex outranks this sheet and
 * would leave a hidden-by-JS legend visibly reserving space the day someone re-added it.
 *
 * Shared by both views on purpose, and it is here rather than in shell.css because the
 * modal's #structure-legend is one of the two hosts and the desktop build stages no
 * shell.css. Do not add a copy there. */
.op-legend:empty {
    display: none;
}

.modal-header {
    padding: 16px 20px;
    border-bottom: 1px solid var(--border-color-strong);
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    background: var(--bg-header);
}

/* The ink here was a leftover indigo from the dark theme, i.e. a third chrome hue on a
 * site that allows two. It is a plain label on a tinted chip. */
.badge-cohort {
    background: var(--bg-header);
    border: 1px solid var(--border-color);
    color: var(--text-secondary);
}

/* Open Targets, in the hotspot modal header beside the cohort chip. Injected by
 * setHotspotDrugTargetChip rather than written into markup, because the header lives in both
 * index.html files.
 *
 * Both states are drawn, and that is the point: "no known drug" is an ANSWER from Open
 * Targets, not an absence of data, and a chip that only appeared for targets would make the
 * other 16,500 genes look unchecked. The negative is muted so it does not compete with the
 * loss/gain badge next to it; the tooltip carries the phase either way. No science-palette
 * hue on either, since this is a chrome label about the gene, not an encoded measurement. */
.badge-drug.is-target {
    background: var(--primary-tint);
    border: 1px solid var(--primary);
    color: var(--accent-dark);
}

.badge-drug.is-untargeted {
    background: transparent;
    border: 1px dashed var(--border-color-strong);
    color: var(--text-muted);
}

/* The per-cancer-type Focus control that lived in #hotspot-focus-toggle is gone: it was a
 * third cancer-type filter alongside the cohort drawer's per-group select-all and the
 * Cancer Type column filter, and it silently disagreed with the column filter about whether
 * the Cohort_Pct denominators move. It used to be hidden from here, because the empty
 * container was still sitting in the card header of both index.html files; the container
 * itself is now deleted from both, so there is nothing left to hide and the rule is gone
 * with it. Recorded here so the removal does not read as an accident if the control is ever
 * proposed again. */

/* ONLY v1/index.html still has a header on this card, and it holds only the "Hotspots
 * Database" heading. v2 has no such element at all: the menubar names the page and Export
 * moved to the sidebar rail, so both rules below simply match nothing there, which is what
 * puts the table's sticky <thead> flush under the menubar.
 *
 * .card-header is a bare flex wrapper with no padding or margin of its own, so 10px is what
 * stops the heading sitting on the table's top border and reading as part of it. Scoped to
 * the hotspots card so the chart cards, whose headers are title-plus-subtitle text blocks,
 * keep their own spacing. */
#hotspots-view > .card > .card-header { margin-bottom: 10px; }

/* ---------------------------------------------------------------- hotspots full bleed --
 *
 * The hotspots table spans the window from the left rail to the right edge, with no page
 * padding around it. It is the densest object on the site, fourteen columns of it, and every
 * pixel of inset comes straight out of the columns that wrap: Location, Cancer Type and the
 * five-column badge block all breathe measurably better without 80px taken off the top.
 *
 * WHERE THE INSET ACTUALLY CAME FROM, because it was not where it looked like it was. There
 * is no `.card` rule anywhere in this file or in shell.css: `class="card"` on the hotspots
 * wrapper is an unstyled hook, `.card-header` is a bare flex box, `.card-body` carries an
 * inline `padding: 0`, and `.table-container` has no base rule at all. Every bit of the inset
 * was .main-content's own `padding: 40px`, plus `max-width: 1600px` holding the pane off the
 * right edge on any window wider than about 1920. Both are set in this file, so none of this
 * needed shell.css, which only ever overrides the vertical padding.
 *
 * SCOPED BY :has(), because there is no class to hang it on. shell.js switches views by
 * setting `el.style.display` directly, inline, with no body or wrapper class marking which
 * data view is up: '' when a view is shown, which removes the declaration outright, and
 * 'none' when it is hidden. So the presence of `display: none` in the inline style attribute
 * IS the state, and this reads it. Not a direct-child combinator: #views-container sits
 * between .main-content and the views. The other views keep their normal page padding, which
 * is the point of scoping rather than stripping it globally.
 *
 * If a body-level class for the active view ever appears, move this onto it. Matching on a
 * serialized inline style is legitimate but it depends on how CSSOM writes the attribute
 * back, and a class would not. */
.main-content:has(#views-container > #hotspots-view:not([style*="display: none"])) {
    padding-left: 0;
    padding-right: 0;
    /* Lifted, not widened. 1600px is a measure for reading columns of prose and charts; a
       table with its own internal percentage grid gets nothing from being held short of the
       window, it just parks a strip of dead canvas on the right of a wide monitor. */
    max-width: none;
}

/* The heading row puts its inset back. Full bleed is for the TABLE, which has structure of
 * its own at both edges, and not for a line of text, which with zero page padding would sit
 * hard against the window edge and read as clipped rather than as placed. --page-inset is
 * .main-content's own padding value, inherited, so this tracks the 40px and the 24px narrow
 * variant without restating either. Matches only on v1, which is the front end that still has
 * a header on this card. */
#hotspots-view > .card > .card-header {
    padding-left: var(--page-inset);
    padding-right: var(--page-inset);
}

/* Hotspot bucket toggle, moved from the table's card header onto the sidebar rail.
 *
 * The rail is a fixed 280px, so the three segments are given room to wrap onto a second line
 * rather than being squeezed: this is exactly what #ptm-toggle above does with its five PTM
 * names, and the same reasoning applies. `flex: 1 1 auto` plus nowrap means the segments size
 * to their own words and break BETWEEN buttons if they must, instead of hyphenating
 * "Mutation" down the middle. At the current three labels they fit on one row with room to
 * spare; the rule is insurance against a fourth bucket or a wider system font. */
#hotspot-bucket-toggle {
    flex-wrap: wrap;
}
#hotspot-bucket-toggle .ptm-toggle-btn {
    flex: 1 1 auto;
    white-space: nowrap;
}

/* The evidence-tier control has NO rules here, and that is the point of what it now is.
 *
 * It used to be a <select> injected into a #hotspot-tier-sort-host div, which needed a
 * `display: contents` on the host so the injected group did not claim a rail gap of its own
 * while hidden, plus an order value in shell.css to place it. It is a plain checkbox in the
 * Hotspot Options group in both index.html files now, so it inherits .checkbox-label and
 * .custom-checkbox from the two neighbours it sits between and there is nothing left to
 * override. Do not add a #hotspot-tier-wrap rule: anything here would make this one filter
 * look unlike the two it shares a group with. */

/* ---- .op-out: the ONE link-out treatment ----
 *
 * There were five for one concept: a bordered pill in the gene header, a plain
 * --primary-light link in the protein card's subtitle, two inline-styled links in
 * app_v1.3.js, and the AlphaFold link in the provenance line, which no rule matched at all
 * and therefore rendered as browser-default blue and underlined on warm paper. A link off
 * this site is one kind of thing and now looks like one kind of thing. (The header pill and
 * the subtitle link also pointed at the SAME UniProt entry, so the pill is gone outright.)
 *
 * An olive tint rather than a border, because these sit inside running text as often as they
 * sit alone, and a hairline rectangle in a sentence reads as a box drawn around a word. Straw
 * on hover, per the two-hue rule: olive is commitment, straw is transience.
 *
 * The glyph is on ::after so no caller has to remember to type it, and so a link keeps it
 * through an href rewritten in place. */
.op-out {
    display: inline-flex;
    align-items: baseline;
    gap: 5px;
    padding: 3px 10px;
    border: 1px solid transparent;
    border-radius: 999px;
    background: var(--primary-tint);
    color: var(--primary-light);
    font-family: var(--font-family);
    font-size: 0.75rem;
    font-weight: 600;
    line-height: 1.4;
    text-decoration: none;
    cursor: pointer;
    transition: background-color 0.14s ease, border-color 0.14s ease;
}
.op-out::after {
    content: '↗';
    font-weight: 400;
    font-size: 0.95em;
}
.op-out:hover {
    background: var(--straw-tint);
    border-color: var(--straw-line);
    text-decoration: none;
}
.op-out:focus-visible {
    outline: 2px solid var(--primary);
    outline-offset: 2px;
}

/* The PLAIN variant, for a link that sits beside controls. The pill above is right in running
 * prose and wrong directly under a row of pressed olive buttons, where it reads as one more
 * button; this strips the tint, the padding and the radius and leaves underlined muted text
 * with the same ::after arrow, so it is still visibly a link off the site. Used by the
 * structure provenance line. */
.op-out-plain {
    display: inline;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
    color: var(--text-muted);
    font-size: inherit;
    font-weight: inherit;
    text-decoration: underline;
    text-underline-offset: 2px;
}
.op-out-plain:hover {
    background: none;
    border-color: transparent;
    color: var(--text-secondary);
    text-decoration: underline;
}

@media (prefers-reduced-motion: reduce) {
    .op-out { transition: none; }
}

/* Protein name, accession and chain length on ONE line (protHeaderHtml in app_v1.3.js).
 *
 * IN THIS FILE AND NOT shell.css, which is where the rest of .op-prot* lives, because this
 * block is rendered into the hotspot modal as well as the Genes view and the Tauri desktop
 * build stages index.html, app_v1.3.js, styles_v1.3.css, pagination.css and
 * sequon_presets.json only. A rule the modal needs that sits in shell.css is a rule the
 * desktop app does not have, and the heading would silently fall back to the stacked
 * .card-header column there.
 *
 * .card-header is `flex-direction: column`, which is what put the accession on its own line;
 * this turns that one instance back into a row. `baseline` and not `center`, because the two
 * items are different type sizes (1.05rem name, 0.8rem meta) and centring them would leave
 * the accession floating off the name's baseline.
 *
 * THE LONG-NAME CASE, which is the whole reason this is more than one declaration. UniProt
 * recommended names run past 60 characters ("Dolichyl-diphosphooligosaccharide--protein
 * glycosyltransferase subunit 1") and the modal is far narrower than the Genes view. So:
 *
 *   - nowrap on the container. With `wrap` a long name pushes the meta onto a second line,
 *     which is the layout this change exists to remove, and it would do it exactly on the
 *     proteins whose names are worth reading.
 *   - the heading gets `min-width: 0`, so it may shrink below its longest word and wrap
 *     INSIDE itself over as many lines as it needs. A flex item's default min-width is auto,
 *     which is the min-content width, and without this override the heading refuses to go
 *     narrow and overflows the modal instead.
 *   - the meta gets `flex: 0 0 auto` and `nowrap`, so it keeps its full width and stays on
 *     the first line whatever the name does. It is nine characters plus a length; there is
 *     no width at which giving it up helps.
 *
 * The result in the narrow modal is the name wrapping to two or three lines with the
 * accession parked at the top right of the block, still on the name's first line. Nothing is
 * truncated and nothing is hidden behind a tooltip. */
.op-prot-head {
    flex-direction: row;
    flex-wrap: nowrap;
    align-items: baseline;
    gap: 10px;
}

.op-prot-head > :first-child {
    min-width: 0;
    overflow-wrap: break-word;
}

.op-prot-meta {
    flex: 0 0 auto;
    white-space: nowrap;
}

/* The rest of the protein description block.
 *
 * ALL of it is here and none of it is in shell.css, for the reason the .op-prot-head note
 * above gives: renderProteinDescription paints the hotspot modal as well as the Genes view,
 * and the desktop build does not stage shell.css. It used to live there, which meant the two
 * views rendered the same markup and the desktop modal got none of the type.
 *
 * Nothing here is scoped under a view. Scoping is what lets the two drift. */
.op-prot {
    /* One flex column, not loose children of the card. The host is a .chart-card, itself a
     * flex column with a 20px gap meant for whole panels; letting that gap fall between a
     * heading and its own first paragraph spaced the prose like four unrelated cards. One
     * child means one gap, and the spacing inside is this rule's. */
    display: flex;
    flex-direction: column;
    gap: 12px;
    min-width: 0;
}
.op-prot-head h4 { font-size: 1.05rem; }
.op-prot-head .card-subtitle { font-variant-numeric: tabular-nums; }

.op-prot-p {
    margin: 0 0 10px;
    font-size: 0.9rem;
    line-height: 1.62;
    color: var(--text-secondary);
    max-width: 78ch;            /* a measure, not a truncation: nothing is hidden */
}
.op-prot-p:last-child { margin-bottom: 0; }

/* The sentences past the third. Hidden by the `hidden` attribute the renderer writes and
 * toggles, so there is no clamp, no max-height, no overflow and no fade gradient over the
 * cut. There is nothing to fade: the text below the cut is not present, it is display:none.
 *
 * A fade was here. It greyed out the last readable line to imply "there is more", which the
 * control one line lower already says in words, and it did it by painting a --bg-card
 * gradient that was wrong the moment the block sat on any other surface. */
.op-prot-sum { min-width: 0; }

/* Reads as text, not as a button.
 *
 * Same family, size and leading as .op-prot-p, so it sits on the prose's own baseline grid
 * and looks like the paragraph continuing rather than a control interrupting it. Underlined
 * and inked --primary-light, which is the link idiom already used elsewhere on the page.
 *
 * It stays a real <button>: it toggles page content and carries aria-expanded, so it is a
 * button in every sense except its paint. Styling a control as text is not licence to make
 * it a <span> that a keyboard cannot reach.
 *
 * align-self keeps it to its own intrinsic width inside .op-prot's flex column; without it a
 * stretched flex item would give the underline the full width of the card. */
.op-prot-more {
    align-self: flex-start;
    margin: 0;
    padding: 0;
    border: 0;
    background: none;
    color: var(--primary-light);
    font-family: inherit;
    font-size: 0.9rem;
    line-height: 1.62;
    font-weight: 500;
    text-align: left;
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}
.op-prot-more:hover {
    color: var(--primary);
}
.op-prot-more:focus-visible {
    outline: 2px solid var(--primary);
    outline-offset: 2px;
    border-radius: 3px;
}

/* Amber rather than red: this says "the data may be offset", which is a caveat on what is
 * drawn, not a failure to draw it.
 *
 * The fill was a raw rgba(230, 159, 0, 0.10), which is --amber written out by hand and
 * therefore a value that goes stale silently the day the token moves. color-mix keeps the
 * wash tied to the token it is a wash OF. */
.op-prot-warn {
    margin: 0;
    padding: 9px 12px;
    border-left: 2px solid var(--amber);
    background: color-mix(in srgb, var(--amber) 10%, transparent);
    border-radius: 0 7px 7px 0;
    font-size: 0.82rem;
    line-height: 1.6;
    color: var(--text-secondary);
}

/* The honest empty state and the in-flight line. Muted, because both are statements about
 * the lookup rather than about the protein.
 *
 * ONE waiting class, not two. .op-prot-wait was here and is gone: it said exactly what
 * .op-gv-wait says, in exactly this type, and the only difference between them was which
 * view had written the sentence. renderProteinDescription now emits .op-gv-wait for its
 * in-flight line, which is the same class the static scaffolds in both index.html files
 * already carry, so the modal and the Genes view show one sentence in one class and there is
 * no second name for a third caller to pick wrongly.
 *
 * .op-gv-none and .op-gv-wait are here rather than in shell.css for a staging reason: both
 * index.html files use them for the static "loading" placeholders inside the structure and
 * sequence cards, so the desktop build needs them. The Genes view uses them in a dozen other
 * places; the names keep their op-gv- prefix so that churn stays out of this change. */
.op-prot-none,
.op-gv-none, .op-gv-wait {
    margin: 0;
    font-size: 0.84rem;
    line-height: 1.6;
    color: var(--text-muted);
}
.op-gv-wait { font-style: italic; }

/* Why a lollipop is showing no highlight ring, written by renderLollipopMarkNote as a sibling
 * of the 300px chart box rather than inside it, so it cannot take height out of the plot. Same
 * muted type as the two classes above because it is the same kind of sentence: an absence with
 * a reason. Here rather than in shell.css because the hotspot modal is in both front ends and
 * the desktop build stages no shell.css. */
.lolli-mark-note {
    margin: 10px 0 0;
    font-size: 0.8rem;
    line-height: 1.55;
    color: var(--text-muted);
}

/* ---- cancer expression panel (renderCancerExpressionChart in app_v1.3.js) ----
 *
 * IN THIS FILE, not shell.css: the panel is inside the hotspot modal as well as the Genes
 * view, and the Tauri desktop build stages no shell.css. See the .op-prot-head note below.
 *
 * The box that reserves the plot's space. Its height is the height the canvas is sized to in
 * script (HPA_CANCER_PLOT_H), and the two must move together; before the script runs, this is
 * what stops the panel collapsing to a bare 150px canvas default and reflowing the card.
 *
 * It replaces a .chart-container with an inline height on the bubble body itself. That put
 * the canvas's parent's padding inside the width the canvas measured, so every paint was
 * about 24px wider than the box it sat in. */
.hpa-cancer-plot {
    position: relative;
    width: 100%;
    height: 500px;
}

/* What the marks mean, in prose, under the plot rather than painted into it.
 *
 * A box plot whose caps are Tukey whiskers and whose outlined columns are an independent
 * cohort needs a sentence of each, and there is no honest way to fit either on a canvas. Text
 * here is selectable, wraps at any width, and is read by a screen reader; text in the bitmap
 * is none of those and steals plot height besides.
 *
 * :empty collapses the whole block, gap and all, so a panel with nothing to say spends no
 * vertical space saying it. */
.hpa-cancer-note {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin-top: 12px;
    max-width: 82ch;
}
.hpa-cancer-note:empty { display: none; }
.hpa-cancer-note p {
    margin: 0;
    font-size: 0.8rem;
    line-height: 1.6;
    color: var(--text-muted);
}
/* The clauses a reader must not skim past: which caps are not minima and maxima, and which
 * boxes are the independent cohort. Ink, not weight alone, because the surrounding note is
 * deliberately muted and bold-on-muted is a weak signal. */
.hpa-cancer-note strong {
    color: var(--text-secondary);
    font-weight: 600;
}

/* ---- Open Targets drug section (renderDrugSection in app_v1.3.js) ----
 *
 * IN THIS FILE AND NOT shell.css, for the reason the .op-prot-head note above gives at
 * length: this block is rendered into the hotspot modal as well as the Genes view, and the
 * Tauri desktop build stages index.html, app_v1.3.js, styles_v1.3.css, pagination.css and
 * sequon_presets.json only. A rule the modal needs that sits in shell.css is a rule the
 * desktop app does not have, and this section would render as unstyled list items there.
 *
 * Nothing here is scoped under a view, for the same reason nothing in .op-prot is: scoping is
 * what lets the two drift, and "the modal and the Genes view do not look the same" is the
 * complaint this whole family of seams exists to end.
 *
 * The geometry mirrors .op-prot deliberately, down to the 12px column gap, because the two
 * cards sit in the same stack in both views and a different rhythm in each would read as two
 * components rather than two panels. */
.op-drugs {
    display: flex;
    flex-direction: column;
    gap: 12px;
    min-width: 0;
}
.op-drugs-head h4 { font-size: 1.05rem; }
/* The link out to the target's own Open Targets page now sits IN this header, beside the
 * words naming what it links to. Baseline-aligned and small: it is a way out of the section,
 * not a second heading. The provenance line that used to carry it is gone -- Open Targets is
 * credited once, on the Data view, for every panel that uses it rather than per panel. */
.op-drugs-head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 6px 12px;
}
.op-drugs-head .op-out { font-size: 0.78rem; font-weight: 400; }

/* The two counts and the sentence that keeps the per-row pair of stages readable. Body type,
 * not caption type: a reader who skips this misreads every row under it. */
.op-drugs-sum {
    margin: 0;
    font-size: 0.86rem;
    line-height: 1.6;
    color: var(--text-secondary);
    max-width: 78ch;
}
.op-drugs-sum strong {
    color: var(--text-primary);
    font-variant-numeric: tabular-nums;
}

.op-drug-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin: 0;
    padding: 0;
    list-style: none;
    min-width: 0;
}

/* One drug is one card, not one table row.
 *
 * A table was the obvious shape and it is the wrong one here: mechanism is a sentence of
 * arbitrary length that is ABSENT for some drugs, indications are a wrapping list, and the
 * two stage chips are a pair that must stay together. Every one of those fights a fixed
 * column, and the modal is narrow. Stacked cards let a row with no mechanism simply be
 * shorter instead of leaving a hole in a column. */
.op-drug {
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 10px 12px;
    border: 1px solid var(--border-color);
    border-left: 3px solid var(--straw-line);
    border-radius: 0 8px 8px 0;
    background: var(--bg-track);
    min-width: 0;
}

/* Not optional: the display: flex above outranks the UA's [hidden] { display: none }, so
 * without this rule every .op-drug-rest row past DRUG_LEAD_ROWS renders despite shipping
 * with the hidden attribute, and the "Show N more drugs" button toggles an attribute with
 * no visible effect. Same trap and same fix as .op-fb-fallback[hidden] in shell.css. */
.op-drug[hidden] { display: none; }

.op-drug-top {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    min-width: 0;
}

/* Small molecule, Antibody, Cell, Gene and the rest. A modality, not a stage, so it is
 * deliberately inked as a neutral tag and never as one of the phase chips: confusing "what
 * kind of thing this is" with "how far it got" is the exact error this section is built to
 * prevent. */
.op-drug-type {
    padding: 2px 8px;
    border-radius: 999px;
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: var(--text-secondary);
}

.op-drug-mech {
    margin: 0;
    font-size: 0.85rem;
    line-height: 1.55;
    color: var(--text-secondary);
    max-width: 78ch;
}
/* The ACTION verb (INHIBITOR, AGONIST, DEGRADER), lower-cased by the renderer. Set apart from
 * the mechanism sentence because it is a separate field that is separately absent. */
.op-drug-act {
    color: var(--text-muted);
    font-style: italic;
}

/* THE PAIR OF STAGES, and the reason this section is laid out at all.
 *
 * `phase` and `anyIndicationPhase` are different numbers about the same drug and a reader who
 * cannot tell which is which will read a repurposing trial as an approved cancer drug. So they
 * are drawn adjacent when they differ, and NEITHER IS EVER DRAWN BARE: .op-drug-k carries the
 * key word ("In cancer" / "Any disease") inside the chip, so the number cannot be lifted out of
 * its context by a screenshot or by a skim. That rule is what makes it safe for drugRowHtml to
 * drop the second chip on the 71% of rows where the two stages agree; the flex row is sized by
 * its children, so one chip needs no separate case here. */
.op-drug-phases {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
}
.op-drug-phase {
    display: inline-flex;
    align-items: baseline;
    gap: 6px;
    padding: 3px 10px;
    border-radius: 999px;
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--text-secondary);
}
/* Approved is the one stage worth a colour, and it is olive rather than green-for-good: this
 * is a fact about a regulatory milestone, not a verdict on the drug. */
.op-drug-phase.is-approved {
    background: var(--primary-tint);
    border-color: var(--border-color-active);
    color: var(--primary-light);
}
.op-drug-k {
    font-size: 0.68rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-muted);
}

.op-drug-ind {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 6px;
    margin: 0;
    font-size: 0.8rem;
    line-height: 1.6;
    color: var(--text-secondary);
}
/* Disease names link out, but NOT as .op-out pills: three pills per row across seventy rows
 * is a field of lozenges with the prose lost between them. .op-out is reserved for the
 * destinations a reader goes to on purpose (the drug, the compound, the target); an
 * indication is a label that happens to be clickable. */
.op-drug-dis {
    color: var(--primary-light);
    text-decoration: underline;
    text-underline-offset: 2px;
}
.op-drug-dis:hover { color: var(--primary); }

/* ---- colour key ----
 *
 * ONE key component for the two cards that need one: the Genes view's five PTM types and the
 * hotspot modal's single modified residue. They were two: the Genes view drew .op-gv-ptmkey
 * as a full-width row under the card header, and the modal drew #structure-legend inline
 * inside its <h3>, right-aligned, with every property written as a style attribute. Same job,
 * different swatch size, different type size, different place on the card.
 *
 * The swatch colour is the one thing still written inline, by both callers, because it is
 * data: the Genes view takes it from GV_PTM_STYLE and the modal from --highlight. Everything
 * that is not the colour is here.
 *
 * A key sits directly under the card header and above the thing it keys, always. The modal's
 * old position (in the heading, on the right) put it further from the model than the heading
 * was and made the card title's own baseline depend on how many entries the key had. */
.op-legend {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px 16px;
    font-size: 0.76rem;
    color: var(--text-secondary);
}
.op-legend-k { display: inline-flex; align-items: center; gap: 7px; }
/* border-color is set inline by the caller when the swatch needs an edge. --highlight is a
 * pure yellow with almost no silhouette of its own against a white card, which is the same
 * reason --highlight-edge exists; the PTM type colours do not need one. transparent rather
 * than no border at all, so a swatch with an edge and one without are the same size. */
.op-legend-k i {
    width: 11px;
    height: 11px;
    border: 1px solid transparent;
    border-radius: 3px;
    flex: 0 0 auto;
}
.op-legend-k b {
    color: var(--text-primary);
    font-variant-numeric: tabular-nums;
    font-weight: 600;
}
/* A type with no sites on this gene stays in the key, so the key always means the same five
 * things, but greys out so it is not read as something to look for. */
.op-legend-k.is-off { color: var(--text-muted); opacity: 0.5; }
.op-legend-k.is-off b { color: var(--text-muted); }
.op-legend-note { color: var(--text-muted); font-style: italic; }

/* ---- 3D structure box (renderAlphaFold's host) ----
 *
 * ONE box for both callers. The Genes view had this class at 400px with a 10px radius, a card
 * background and a hairline border; the modal had 420px with an 8px radius and neither, all
 * of it written inline in two index.html files. Same component, same protein, two boxes.
 *
 * 3Dmol measures its container on creation and gets zero from an auto height, so this is a
 * fixed height rather than an aspect ratio. position: relative because it absolutely
 * positions its own canvas inside, and because the x-ray overlay is positioned against it. */
.op-struct {
    position: relative;
    height: 440px;
    border-radius: 10px;
    overflow: hidden;
    background: var(--bg-card);
    border: 1px solid var(--border-color);
}
.op-struct canvas { display: block; }
/* Full screen is the one place the fixed height is wrong: the element is the whole screen and
 * the canvas has to fill it. The background is repeated because a fullscreen element is
 * composited against black by default, and the model is drawn on paper. */
.op-struct:fullscreen {
    height: 100%;
    width: 100%;
    border-radius: 0;
    border: 0;
    background: var(--bg-card);
}
.op-struct:fullscreen canvas { width: 100% !important; height: 100% !important; }

/* THE STAGE: the viewer plus the one control that belongs to the picture rather than to the
 * section. Only a containing block in the ordinary case; it is what goes full screen, so the
 * button inside it stays reachable and is how the reader gets back out. */
.op-struct-stage { position: relative; }
.op-struct-full {
    position: absolute;
    right: 10px;
    bottom: 10px;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    padding: 0;
    /* Over the x-ray outline, which is a full-size transparent viewer on top of the model. */
    z-index: 2;
    cursor: pointer;
    color: var(--text-secondary);
    /* Translucent paper rather than a solid chip: the button sits ON the model, and a solid
     * one punched a hole in whatever it covered. */
    background: rgba(255, 255, 255, 0.78);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    -webkit-backdrop-filter: blur(3px);
    backdrop-filter: blur(3px);
    transition: color 0.12s ease, background 0.12s ease, border-color 0.12s ease;
}
.op-struct-full:hover,
.op-struct-full:focus-visible {
    color: var(--text-primary);
    background: rgba(255, 255, 255, 0.95);
    border-color: var(--text-secondary);
}
.op-struct-full svg { display: block; }
/* Full screen: the stage is the whole screen, so the box inside it has to stop being 440px.
 * Background repeated because a fullscreen element composites against black by default and
 * this model is drawn on paper. */
.op-struct-stage:fullscreen { width: 100%; height: 100%; background: var(--bg-card); }
.op-struct-stage:fullscreen .op-struct {
    height: 100%;
    border-radius: 0;
    border: 0;
}
.op-struct-stage:fullscreen .op-struct canvas {
    width: 100% !important;
    height: 100% !important;
}

/* What renderAlphaFold writes when there is no model to draw. The renderer emits this markup
 * itself now; it used to emit a div carrying seven inline style properties, and the Genes view
 * passed an onMessage callback purely to avoid them. */
.op-struct-msg {
    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    margin: 0;
    padding: 0 40px;
    text-align: center;
    line-height: 1.6;
}

/* Sequence panel (renderSequencePanel): a monospace grid in 10-residue blocks, as many
 * blocks to a line as the panel is wide, every tenth position numbered above the block it
 * ends. Only exact character columns make that readable, so the text must not reflow and
 * every character must be the same width.
 *
 * .seq-panel is the SHARED class. The hotspot modal's #modal-structure-sequence and the
 * Genes view's panel are the same component now; the class is added by the renderer, so any
 * host it is pointed at inherits the grid without markup changes.
 *
 * ONE set of typography, and it is the Genes view's 0.79rem / 1.85 / 10px rather than the
 * modal's old 0.75rem / 1.35 / 8px. The leading decides it: this panel fills residue
 * backgrounds, and once a caller marks hundreds of them (the Genes view marks all five PTM
 * types at once) 1.35 stacks the coloured cells into one solid block with the ruler jammed
 * against them. 0.79rem is also the more legible size for a wall of single characters.
 *
 * The !important flags are gone. They were here because the div carried font-family,
 * letter-spacing, word-break and line-height inline in both index.html files and an inline
 * style outranks a sheet; those attributes have now been emptied, so the sheet wins on its
 * own. Do not put presentation back on that element.
 *
 * It also no longer scrolls. It was max-height 220px with overflow-y: auto, which showed
 * about six lines of a protein at a time, and it sat on a 25% black fill left from the dark
 * theme. The panel is as tall as the protein now and the modal scrolls if anything does. */
/* .seq-panel ONLY, and no longer also #modal-structure-sequence. The id was left over from
 * when the modal's host was styled by hand; keeping it meant the modal's empty host was
 * already wearing the panel's border, padding and white-space: pre while its "loading the
 * sequence" line was still showing, and the Genes view's identical host was not. The renderer
 * adds .seq-panel to whichever host it is pointed at, so both arrive at the box at the same
 * moment now. */
.seq-panel {
    font-family: var(--font-mono);
    font-size: 0.79rem;
    line-height: 1.85;
    letter-spacing: 0;
    word-break: normal;
    white-space: pre;
    overflow: visible;
    background: var(--bg-card);
    border: 1px solid var(--border-color);
    border-radius: 10px;
    padding: 12px 14px;
}

/* The two rows share one character grid: seqGridHtml right-aligns each label with
 * padStart(width, ' ') so its last digit lands over its block's last residue, and NOTHING
 * holds that together except both rows advancing by the same width per character.
 *
 * So the ruler is set smaller the only way that keeps the grid: shrink the glyphs and hand
 * the lost advance straight back as tracking. At 0.85em each character advances 0.85ch of
 * the panel's cell, so 0.15 of a panel cell is missing per character; expressed in the
 * ruler's OWN ch unit -- which is now 0.85 of the panel's -- that is 0.15 / 0.85 = 0.1765ch.
 * Add exactly that and character N still starts at N whole panel cells, on every line, with
 * no drift however long the protein.
 *
 * This is the one place tracking is allowed, and only because it is calculated to cancel.
 * Do not put letter-spacing on .seq-line, and do not change one of these two numbers without
 * the other: they are a pair, and breaking the identity shears the ruler off the sequence a
 * little further on every line. */
.seq-panel .seq-ruler,
.seq-panel .seq-num {
    color: var(--text-muted);
    user-select: none;
    font-size: 0.85em;
    letter-spacing: 0.1765ch;
    /* Vertical only, so the character grid is untouched -- line-height changes no advance.
     * The ruler is pulled down onto the residues it labels and the space it gave up is put
     * back above it, so each number row reads as belonging to the line below rather than
     * floating equidistant between two. */
    line-height: 1.15;
    margin-top: 0.5em;
}

/* Full-strength ink. The residues are the content; the ruler above them is an index, and the
 * contrast between the two is what lets a reader scan the sequence and consult the numbers
 * rather than reading both at once. */
.seq-panel .seq-line {
    color: var(--text-primary);
}

/* Padding on a residue would break the grid, so a marked residue is coloured in place.
 *
 * SQUARE, deliberately. The Genes view's panel is selectable, which means a span PER RESIDUE
 * rather than one per marked run, so a corner radius rounded every letter individually and a
 * six-residue sequon read as six separate pills instead of one highlighted stretch. At zero
 * the backgrounds of neighbouring residues meet exactly and a run reads as a single band --
 * a highlighter drawn across the sequence, which is what it is. The gaps between ten-residue
 * blocks still break it, and should: they are real gaps in the ruler above.
 *
 * The band is the height of the glyph box rather than the 1.85 line, which is what keeps it
 * reading as a highlight over text and not as a filled table cell. */
.seq-panel .seq-res {
    border-radius: 0;
}

/* The chrome rows are prose, not grid, so they opt out of the panel's white-space: pre. */
.seq-panel-tools,
.seq-panel-warn,
.seq-panel-msg {
    white-space: normal;
    font-family: var(--font-family);
}

/* The tools row now carries the panel's own caption on the left and the copy control on the
 * right. The caption used to be markup in index.html, above the modal's panel only, so the
 * Genes view's identical grid had no label and the modal's did. It is rendered by the panel
 * itself now, which is the only arrangement in which the two cannot disagree about it. */
.seq-panel-tools {
    display: flex;
    align-items: baseline;
    gap: 10px;
    margin-bottom: 8px;
}

.seq-panel-title {
    font-size: 0.8rem;
    color: var(--text-secondary);
    margin-right: auto;         /* everything after it is pushed to the right edge */
}

/* "Copy sequence" is not a nicety. A selectable panel hijacks mousedown to drag-select
 * residues into the 3D model, which means the browser's own text selection is off and the
 * sequence cannot be copied out any other way. */
.seq-panel .seq-copy-btn {
    padding: 3px 10px;
    border-radius: 999px;
    border: 1px solid var(--border-color-strong);
    background: var(--bg-card);
    color: var(--text-secondary);
    font-family: var(--font-family);
    font-size: 0.72rem;
    font-weight: 600;
    cursor: pointer;
    transition: var(--transition-fast);
}

.seq-panel .seq-copy-btn:hover {
    border-color: var(--straw-line);
    color: var(--text-primary);
}

.seq-panel .seq-copy-btn:focus-visible {
    outline: 2px solid var(--primary);
    outline-offset: 2px;
}

.seq-panel .seq-copy-msg {
    font-size: 0.7rem;
    color: var(--text-muted);
}

.seq-panel-warn,
.seq-panel-msg {
    font-size: 0.75rem;
    line-height: 1.5;
    color: var(--text-muted);
    margin: 0;
}

/* The only prose the panel still carries below its tools row, and it is a caveat about the
 * data (the Genes view's isoform and accession mismatch), not a caption. */
.seq-panel-warn {
    margin-bottom: 8px;
    color: var(--text-secondary);
}

/* The x-ray outline's canvas, stacked over the structure viewer's own.
 *
 * A second 3Dmol viewer draws only the marked residue on a transparent background, so it
 * composites over the finished protein image and stays visible whatever the fold puts in
 * front of it. See the note above paintOverlay() in app_v1.3.js for why the mark cannot
 * simply be drawn into the main scene.
 *
 * pointer-events must stay none: the overlay covers the whole panel, and without it the
 * main viewer would never see a drag and the structure would not rotate. */
.af-xray-overlay {
    position: absolute;
    inset: 0;
    pointer-events: none;
    z-index: 1;
}
