@import url('https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@300;400;500;600;700&display=swap');

  :root {
    --bg-color: #121212;
    --panel-bg: #1a1a1a;
    --block-color: #242424;
    --text-color: #ffffff;
    --accent-color: rgba(46, 204, 113, 0.4);
    --grid-size: 100px;
    --grid-gap: 10px;
    --radius: 12px;
    --border-color: #333;
    --border-subtle: #2a2a2a;
    --bg-mid: #1f1f1f;
    --bg-elevated: #222;
    --bg-input: #1a1a1a;
    --text-secondary: #ccc;
    --text-muted: #888;
    --text-dim: #555;
    --shadow-md: rgba(0,0,0,0.5);
    --shadow-lg: rgba(0,0,0,0.8);

    /* Chart Colors */
    --c-draw: #6b7280;

    /* Semantic result colours */
    --c-win:  #2ecc71;
    --c-loss: #e74c3c;
    --c-draw: #888888;

    /* Transition speeds */
    --transition-fast: 0.15s ease;
    --transition-base: 0.2s ease;
    --transition-slow: 0.35s ease;

    /* Type scale tokens */
    --t-micro:   0.5rem;
    --t-label:   0.6rem;
    --t-data:    0.7rem;
    --t-body:    0.7rem;
    --t-title:   1rem;
    --t-heading: 1.25rem;
    --t-hero:    1.75rem;
  }

  [data-theme="light"] {
    --bg-color: #f0f0f2;
    --panel-bg: #ffffff;
    --block-color: #e8e8ec;
    --text-color: #111111;
    --accent-color: rgba(46, 204, 113, 0.35);
    --border-color: #d0d0d8;
    --border-subtle: #e0e0e8;
    --bg-mid: #f8f8fa;
    --bg-elevated: #eeeeef;
    --bg-input: #f5f5f7;
    --text-secondary: #444;
    --text-muted: #71717a;
    --text-dim: #a1a1aa;
    --shadow-md: rgba(0,0,0,0.12);
    --shadow-lg: rgba(0,0,0,0.18);
  }

  html {
    background-color: var(--bg-color);
    background-image:
      linear-gradient(to right, #1f1f1f 1px, transparent 1px),
      linear-gradient(to bottom, #1f1f1f 1px, transparent 1px);
    background-size: calc(var(--grid-size) + var(--grid-gap)) calc(var(--grid-size) + var(--grid-gap));
    background-position: 51px 61px;
  }


  body {
    margin: 0;
    padding: 0;
    min-width: 100vw;
    min-height: 100vh;
    color: var(--text-color);
    font-family: 'Instrument Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    -webkit-user-select: none;
    -moz-user-select: none;
    -ms-user-select: none;
    user-select: none;
    -webkit-tap-highlight-color: transparent;
  }

  /* Loading overlay (index.html) — covers the whole real page while
     js/catalyst-data.js's own real ~70MB downloads and executes, per direct
     instruction once this is about to be hosted publicly rather than run
     locally. Hardcoded #121212 (not var(--bg-color)) deliberately — this
     has to render correctly the instant the very first bytes of HTML/CSS
     arrive, before the theme-detection <script> in <head> or any CSS
     custom property has necessarily taken effect, and per this app's own
     "light theme is out of scope" convention there's no real second value
     it would ever need to match anyway. */
  #loading-screen {
    position: fixed; inset: 0; z-index: 100000;
    display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px;
    background: #121212;
    transition: opacity 0.4s ease;
  }
  #loading-screen.loaded { opacity: 0; pointer-events: none; }
  .loading-spinner {
    width: 42px; height: 42px;
    border: 3px solid rgba(46, 204, 113, 0.2);
    border-top-color: #2ecc71;
    border-radius: 50%;
    animation: loading-spin 0.8s linear infinite;
  }
  @keyframes loading-spin { to { transform: rotate(360deg); } }
  .loading-text { font-size: 0.8rem; color: #999; letter-spacing: 0.04em; }

  /* Cursor — rooted at body so Chrome's compositor picks it up without lag.
     :has() is supported Chrome 105+ / Safari 15.4+. Falls back to per-element grab. */
  .placed-module          { cursor: grab; }
  .placed-module *        { cursor: grab; }
  .placed-module .resize-handle { cursor: nwse-resize; }
  .module-sub-controls    { cursor: default; }
  .module-sub-controls *  { cursor: default; }
  .module-body            { cursor: default; }
  .module-body *          { cursor: default; }
  #top-bar                { cursor: pointer; }
  #top-bar *              { cursor: pointer; }
  #module-search          { cursor: text !important; }

  /* Top Global Bar */
  #mobile-menu-btn { display: none; }
  #mobile-menu-panel { display: none; }

  #top-bar {
    position: fixed;
    top: 0;
    left: 56px;
    right: 0;
    height: 50px;
    background-color: var(--panel-bg);
    border-bottom: 1px solid #2a2a2a;
    z-index: 20;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    padding: 0 20px;
    box-sizing: border-box;
    cursor: pointer;
  }
  .top-bar-controls {
    display: flex;
    align-items: center;
    gap: 10px;
  }
  
  /* Preset Buttons */
  .preset-group {
    display: flex;
    gap: 6px;
    margin-right: 15px;
    border-right: 1px solid #333;
    padding-right: 15px;
  }
  .preset-btn {
    background: #1a1a1a;
    color: #ccc;
    border: 1px solid #333;
    padding: 4px 10px;
    border-radius: 4px;
    font-size: var(--t-body);
    cursor: pointer;
    transition: all 0.2s;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 5px;
    text-decoration: none;
    font-family: inherit;
  }
  .preset-btn.icon-btn {
    padding: 4px 8px;
  }
  .preset-btn:hover {
    background: #2a2a2a;
    color: #fff;
    border-color: #2ecc71;
  }
  .clear-btn:hover {
    border-color: #e74c3c !important;
    color: #e74c3c !important;
  }
  .undo-btn:hover {
    border-color: #f39c12 !important;
    color: #f39c12 !important;
  }
  /* Grid Background */
  #workspace {
    position: relative;
    width: calc(24 * (var(--grid-size) + var(--grid-gap)));
    height: calc(48 * (var(--grid-size) + var(--grid-gap)));
    background: transparent;
    margin: 66px 16px 100px 56px;
    outline: none;
    transform-origin: 0 0;
    transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.2, 1);
    cursor: grab;
  }


  /* Elegant Welcome Screen */
  #welcome-screen {
    position: absolute;
    transform: translate(-50%, -50%);
    text-align: center;
    z-index: 2;
    transition: opacity 0.5s ease;
    pointer-events: none;
    width: 500px;
  }

  .welcome-buttons {
    display: flex;
    gap: 12px;
    justify-content: center;
    margin-top: 24px;
    pointer-events: auto;
  }

  .welcome-buttons button {
    padding: 10px 28px;
    border-radius: 8px;
    border: none;
    font-size: var(--t-body);
    font-weight: 600;
    cursor: pointer;
    letter-spacing: 0.01em;
    transition: opacity 0.15s;
  }

  .welcome-buttons button:hover { opacity: 0.85; }

  #welcome-tour-btn {
    background: #2ecc71;
    color: #111;
  }

  #welcome-dismiss-btn {
    background: rgba(255,255,255,0.12);
    color: #ccc;
    border: 1px solid rgba(255,255,255,0.15) !important;
  }

  #welcome-screen h1 {
    margin: 0 0 10px 0;
    color: #fff;
    font-size: var(--t-hero);
    font-weight: 600;
    letter-spacing: -0.5px;
    text-shadow: 0 4px 20px rgba(0,0,0,0.8);
  }

  #welcome-screen p {
    margin: 0;
    color: #aaa;
    font-size: var(--t-title);
    text-shadow: 0 2px 10px rgba(0,0,0,0.8);
  }

  #welcome-screen .welcome-byline {
    font-family: 'Barlow Condensed', sans-serif;
    font-size: var(--t-title);
    color: #aaa;
    letter-spacing: 0.12em;
    text-shadow: 0 2px 10px rgba(0,0,0,0.8);
    margin-top: 24px;
  }
  .welcome-byline .byline-prefix { font-weight: 300; }
  .welcome-byline .byline-brand  { font-weight: 600; }
  .var-a { display: inline-block; position: relative; z-index: 1; }
  .byline-static { display: inline-block; position: relative; z-index: 2; }

@keyframes variance-a-fade {
  0%   { opacity: 1; }
  50%  { opacity: 0.25; }
  100% { opacity: 1; }
}

  /* Autonomous Flexing Ghost Blocks */
  .autonomous-ghost {
    position: absolute;
    border: 2px solid #2ecc71;
    border-radius: var(--radius);
    background: rgba(46, 204, 113, 0.08);
    transition: all 0.7s cubic-bezier(0.25, 1, 0.5, 1);
    pointer-events: none;
    z-index: 1;
  }
  /* Telegraphs an in-progress merge — the existing color transition above
     ("all 0.7s") carries the green-to-blue shift smoothly, same electric
     blue (#00c2ff) as a real Stats/Radar module merge's own snap-indicator
     elsewhere in the app, so this reads as the same language, not a new one. */
  .autonomous-ghost.ghost-merging {
    border-color: #00c2ff;
    background: rgba(0, 194, 255, 0.14);
    box-shadow: 0 0 16px rgba(0, 194, 255, 0.35);
  }

  /* Permanent Menu Rail */
  #hover-zone {
    position: fixed;
    top: 0;
    left: 0;
    width: 56px;
    height: 100%;
    background-color: var(--panel-bg);
    border-right: 1px solid #2a2a2a;
    z-index: 40;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding-top: 20px;
    box-sizing: border-box;
    cursor: pointer;
  }

  /* Slide-out Menu */
  #side-panel {
    position: fixed;
    top: 0;
    left: -40%;
    width: 40%;
    height: 100%;
    background-color: var(--panel-bg);
    box-shadow: 5px 0 15px rgba(0,0,0,0.5);
    transition: left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
    z-index: 30;
    padding: 20px 16px;
    box-sizing: border-box;
    display: flex;
    flex-direction: column;
    gap: 8px; 
    overflow-y: auto;
  }

  #side-panel.open {
    left: 0;
  }

  .menu-title {
    font-size: var(--t-title);
    font-weight: 600;
    margin-top: 12px;
    margin-left: 60px;
    margin-bottom: 4px;
    color: #888;
    flex-shrink: 0;
  }

  .menu-title:first-child {
    margin-top: 0;
  }

  /* Menu Layout Updates */
  .menu-grid {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 6px;
    margin-left: 60px;
    cursor: grab;
  }

  .module-template {
    background-color: var(--block-color);
    border-radius: var(--radius);
    padding: 5px 3px;
    cursor: grab;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    border: 1px solid #333;
    user-select: none;
    transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease, color 0.18s ease;
    color: #ccc;
    font-size: var(--t-data);
    text-align: center;
    aspect-ratio: 3/2;
    box-sizing: border-box;
    touch-action: none;
  }

  .module-template:hover {
    transform: scale(1.04);
    border-color: rgba(46, 204, 113, 0.65);
    box-shadow: 0 0 6px rgba(46, 204, 113, 0.12), 0 0 14px rgba(255, 255, 255, 0.07);
    background-color: rgba(46, 204, 113, 0.07);
    color: #fff;
  }

  .module-template:hover .menu-icon {
    stroke: rgba(46, 204, 113, 0.85);
  }

  .module-template:active {
    cursor: grabbing;
  }

  .module-template.is-lifting {
    transform: scale(0.96);
    transition: transform 0.08s ease;
  }

  .menu-icon {
    margin-right: 0;
    margin-bottom: 3px;
    width: 18px;
    height: 18px;
    stroke: #888;
    flex-shrink: 0;
    display: inline-block;
    transition: stroke 0.18s ease;
  }

  /* Module search */
  #module-search-wrap {
    position: relative;
    flex-shrink: 0;
    margin-left: 60px;
  }
  #module-search {
    width: 100%;
    box-sizing: border-box;
    background: #1e1e1e;
    border: 1px solid #3a3a3a;
    border-radius: 6px;
    color: #ccc;
    font-size: var(--t-body);
    padding: 8px 28px 8px 10px;
    outline: none;
    transition: border-color 0.15s;
  }
  #module-search:focus { border-color: #555; }
  #module-search::placeholder { color: #4a4a4a; }
  #module-search-clear {
    position: absolute;
    right: 8px;
    top: 50%;
    transform: translateY(-50%);
    background: none;
    border: none;
    color: #555;
    font-size: var(--t-title);
    cursor: pointer;
    padding: 0;
    line-height: 1;
    display: none;
  }
  #module-search-clear.visible { display: block; }
  #search-results {
    display: none;
    grid-template-columns: repeat(4, 1fr);
    gap: 6px;
    margin-left: 60px;
  }
  #search-results.active { display: grid; }
  .search-empty {
    grid-column: 1 / -1;
    color: #4a4a4a;
    font-size: var(--t-body);
    text-align: center;
    padding: 12px 0;
  }

  /* Ghost block during drag */
  .dragging-ghost {
    position: fixed;
    background-color: var(--block-color);
    border-radius: var(--radius);
    box-shadow: 0 10px 30px rgba(0,0,0,0.8);
    pointer-events: none;
    z-index: 100;
    opacity: 0.4;
    transform-origin: center center;
    transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.1s ease;
  }

  /* Green valid drop glow */
  .snap-indicator {
    position: absolute;
    background-color: var(--accent-color);
    border: 2px solid #2ecc71;
    border-radius: var(--radius);
    pointer-events: none;
    /* box-sizing explicitly matched to .placed-module's own border-box —
       .snap-indicator had no box-sizing declared at all before (defaulting
       to content-box), so copying a target's own border-box width/height
       straight across (js/main.js) rendered a box a few px larger than the
       real target on every side (its own 2px border added on top of a
       width that already included the target's 1px border) instead of
       landing exactly on top of it pixel-for-pixel.
       Real bug, reported directly with two screenshots, twice — sat below
       .placed-module's own z-index (6), so a stationary target module's
       own content covered most of the merge/switch glow instead of it
       showing on top. First bumped to 7, which per the CSS spec should
       already have been enough — reported as still not fixed, so raised
       again with a much larger, unambiguous margin (40) instead of nudging
       the number a second time, and box-sizing corrected in the same pass
       in case any remaining size mismatch was contributing. Still safely
       below the actively-dragged module's own elevated z-index (50,
       js/main.js) so the module actually being dragged keeps reading as
       on top, per direct instruction on the intended stacking order
       (dragged ghost above the indicator, indicator above the stationary
       target underneath it). */
    box-sizing: border-box;
    z-index: 40;
    display: none;
  }

  /* Red invalid drop glow */
  .snap-indicator.invalid {
    background-color: rgba(231, 76, 60, 0.4);
    border-color: #e74c3c;
  }

  /* Electric-blue merge glow — dropping here won't just place the module,
     it'll combine with what's underneath into a player-vs-player
     comparison. Distinct from red: this is not blocked, it's an invitation. */
  .snap-indicator.merge {
    background-color: rgba(0, 194, 255, 0.35);
    border-color: #00c2ff;
    box-shadow: 0 0 18px rgba(0, 194, 255, 0.55);
  }

  /* Placed Modules */
  .placed-module {
    position: absolute;
    background: linear-gradient(145deg, #333333 0%, #1a1a1a 100%);
    border-radius: var(--radius);
    box-shadow: 0 4px 10px rgba(0,0,0,0.5);
    display: flex;
    flex-direction: column;
    box-sizing: border-box;
    border: 1px solid #353535;
    z-index: 6;
    cursor: grab;
  }

  /* Header for moving */
  .module-header {
    height: 40px;
    padding: 0 15px;
    display: flex;
    /* flex-start (the default) rather than space-between — a module like
       Ranking inserts an extra .module-title-prefix sibling before
       .module-title (see below) that needs to sit flush against it, not
       pushed apart by an even split of the header's leftover space the way
       space-between would with 3+ children. .header-actions' own
       margin-left:auto (below) reproduces space-between's original
       push-to-the-right effect for the common 2-child case, so every other
       module's header layout is unchanged. */
    justify-content: flex-start;
    align-items: center;
    cursor: grab;
    border-bottom: 1px solid #222;
    background-color: rgba(255,255,255,0.02);
    border-top-left-radius: var(--radius);
    border-top-right-radius: var(--radius);
    user-select: none;
    overflow: hidden;
    touch-action: none;
  }

  .module-header:active {
    cursor: grabbing;
  }

  .module-title {
    font-size: var(--t-body);
    font-weight: 500;
    color: #ccc;
    pointer-events: none;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    margin-right: 10px;
  }

  /* Inserted before .module-title only by modules whose heading needs a
     dynamic lead word without touching .module-title's own text — that
     text doubles as refreshModuleBody's lookup key for the module's config
     (a real bug already caught once on Stats' comparison-mode title
     suffix), so it must never be mutated directly. Ranking uses this for
     its category name ("Attacking" + "Ranking" reading as one heading,
     js/renderers-rankings.js) — same styling as .module-title so the two
     read as one continuous phrase, not two visually distinct labels. */
  .module-title-prefix {
    font-size: var(--t-body);
    font-weight: 500;
    color: #ccc;
    pointer-events: none;
    white-space: nowrap;
    margin-right: 6px;
  }
  
  .header-actions {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-left: auto;
  }

  .module-sub-controls {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 6px 15px;
    background-color: rgba(0, 0, 0, 0.15);
    border-bottom: 1px solid #222;
  }

  .module-controls {
    font-size: var(--t-body);
    color: #aaa;
    background: #1a1a1a;
    padding: 3px 22px 3px 8px;
    border-radius: 4px;
    pointer-events: auto;
    border: 1px solid #333;
    outline: none;
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 5'%3E%3Cpath d='M0 0l4 5 4-5z' fill='%23555'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 7px center;
    background-size: 7px 4px;
  }

  /* Every dropdown in module-sub-controls (season/template/ranking-metric/
     ranking-season/filter-season — present and future) shares this one
     .module-controls class, so it's targeted generically here rather than
     per specific class name. Closed box: the base .module-controls rule's
     own #1a1a1a already applies with nothing left here to override it back
     to #222. Open option list: this only still matters for the one native
     <select> left in the app (.player-select, hidden on desktop — see
     .styled-select-* below for why every other dropdown moved off native
     <select> entirely) and mobile's own tap-to-expand picker on it
     (unmaintained per "Mobile is out of scope" in CLAUDE.md, but this rule
     costs nothing to leave in place for it). */
  .module-controls option { background-color: #1a1a1a; color: #ccc; }

  /* Styled stand-in for a short, non-searchable <select> (season/template/
     ranking-metric pickers — see window.renderStyledSelect, js/main.js) — a
     native <select>'s open option list is a separate OS-native rendering
     surface most browsers (Safari/macOS worst of all) don't apply any
     author CSS to at all, rendering an unstylable system menu regardless of
     the background/color rules above. Same visual recipe as the player
     picker's own custom dropdown (.player-search-results/-option) for a
     consistent look across every dropdown in the app, just under its own
     class names since this is a conceptually different widget (a short
     fixed list, not a searchable one). The trigger button reuses
     .module-controls as-is (dark box, border, dropdown-arrow background
     image) — nothing new needed for the closed state. */
  .styled-select-wrap { position: relative; }
  .styled-select-trigger {
    width: 100%; text-align: left; font-family: inherit;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .styled-select-menu {
    display: none; position: absolute; z-index: 500; top: calc(100% + 4px); left: 0;
    min-width: 100%; background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 6px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.65); overflow: hidden;
  }
  .styled-select-menu.open { display: block; }
  /* Fixed header, doesn't scroll with .styled-select-options below it —
     same recipe as .value-picker-search/.value-picker-list. Only
     Ranking's metric picker actually renders one (opts.searchable), per
     direct instruction that a 48-option list needs a way to search rather
     than scroll to find one stat. */
  .styled-select-search {
    width: 100%; box-sizing: border-box; background: #141414; border: none;
    border-bottom: 1px solid var(--border-color); color: var(--text-secondary);
    font-family: inherit; font-size: 0.72rem; padding: 8px 10px; outline: none;
  }
  .styled-select-options { max-height: 240px; overflow-y: auto; }
  .styled-select-option {
    padding: 7px 12px; font-size: var(--t-data); color: #999; cursor: pointer;
    white-space: nowrap; user-select: none;
  }
  .styled-select-option:hover { background: #2a2a2a; color: #ddd; }
  .styled-select-option.selected { color: #2ecc71; }
  .styled-select-group-label {
    padding: 6px 12px 2px; font-size: 0.6rem; text-transform: uppercase;
    letter-spacing: 0.08em; color: var(--text-dim); font-weight: 600;
  }
  .styled-select-empty { padding: 10px; font-size: 0.72rem; color: var(--text-dim); text-align: center; }

  /* ── Desktop player search ────────────────────────────────────────────────── */
  .player-search-wrap { position: relative; flex: 1; min-width: 0; }
  .player-search {
    width: 100%;
    box-sizing: border-box;
    font-size: var(--t-body);
    color: #aaa;
    background: #1a1a1a;
    border: 1px solid #333;
    border-radius: 4px;
    /* Vertical padding matched to .module-controls' own 3px top/bottom
       (not the 4px this used to have) — .module-sub-controls sizes itself
       to its tallest child, so a taller search box made any row combining
       it with a <select> (season/template/every other picker) a few px
       taller than a select-only row (e.g. Ranking's). Most visible on
       Radar specifically: its own overlay mode hides the search box
       entirely, so a merged Radar module was already the "correct" select-
       only height while single-player mode wasn't — the mismatch was
       always there, just easiest to notice there. */
    padding: 3px 8px;
    outline: none;
    font-family: inherit;
  }
  .player-search:focus { border-color: #555; color: #ddd; }
  .player-search::placeholder { color: #666; }
  .player-search-results {
    display: none;
    position: absolute;
    z-index: 500;
    top: calc(100% + 4px);
    left: 0;
    right: 0;
    background: #1e1e1e;
    border: 1px solid #3a3a3a;
    border-radius: 6px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.65);
    max-height: 240px;
    overflow-y: auto;
  }
  .player-search-results.open { display: block; }
  .player-search-option {
    padding: 7px 12px;
    font-size: var(--t-data);
    color: #999;
    cursor: pointer;
    white-space: nowrap;
    user-select: none;
  }
  .player-search-option:hover { background: #2a2a2a; color: #ddd; }
  .player-search-option.selected { color: #2ecc71; }
  /* Arrow-key/mouse-hover position within the results — a different concept
     from .selected (which marks whichever row matches the already-committed
     player, and is often absent entirely mid-search). Needs to win over the
     plain :hover grey since the two always coincide (hovering a row moves
     the highlight to it), so it's spelled out for both states explicitly
     rather than relying on stylesheet order. */
  .player-search-option.highlighted,
  .player-search-option.highlighted:hover { color: #2ecc71; }
  /* The underlying <select> stays the real value/change-event source but is
     only ever visually driven by the search box above on desktop — mobile
     still shows and taps it directly (see .mobile-inline-picker below). */
  @media (min-width: 769px) {
    .module-sub-controls .player-select { display: none; }
  }
  @media (max-width: 768px) {
    .player-search-wrap { display: none; }
  }
  .picker-empty {
    padding: 10px 12px;
    font-size: var(--t-data);
    color: #555;
    text-align: center;
  }

  .module-pin {
    font-size: var(--t-title);
    line-height: 1;
    margin-top: 2px;
    color: #666;
    cursor: pointer;
    pointer-events: auto;
    user-select: none;
    transition: color var(--transition-fast);
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
  }
  .module-pin *, .module-pin *:hover { cursor: pointer; }
  .module-pin:hover { color: #aaa; }
  .module-pin.pinned { color: #2ecc71; }

  @keyframes pin-icon-bounce {
    0%   { transform: scale(1); }
    30%  { transform: scale(1.45); }
    60%  { transform: scale(0.88); }
    80%  { transform: scale(1.1); }
    100% { transform: scale(1); }
  }
  @keyframes pin-ring-expand {
    0%   { transform: scale(0.5); opacity: 1; }
    100% { transform: scale(2.6); opacity: 0; }
  }
  .module-pin.pin-animating {
    animation: pin-icon-bounce 0.36s ease-out forwards;
  }
  .module-pin.pin-animating::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    border: 1.5px solid #2ecc71;
    animation: pin-ring-expand 0.42s ease-out forwards;
    pointer-events: none;
  }

  .module-close {
    font-size: var(--t-title);
    line-height: 1;
    color: #666;
    cursor: pointer;
    pointer-events: auto;
    user-select: none;
    transition: color var(--transition-fast);
  }

  .module-close:hover {
    color: #e74c3c;
  }

  @keyframes module-close-out {
    0%   { transform: scale(1);    opacity: 1; }
    100% { transform: scale(0.78); opacity: 0; }
  }

  .placed-module.module-closing {
    animation: module-close-out 0.16s ease-in forwards;
    pointer-events: none;
  }

  @keyframes fixture-exit {
    0%   { opacity: 1; transform: translateY(0); }
    100% { opacity: 0; transform: translateY(-8px); }
  }
  @keyframes fixture-enter {
    0%   { opacity: 0; transform: translateY(10px); }
    100% { opacity: 1; transform: translateY(0); }
  }
  .placed-module.fixture-exit {
    animation: fixture-exit 0.13s ease-in forwards;
    pointer-events: none;
  }
  .placed-module.fixture-enter {
    animation: fixture-enter 0.2s ease-out forwards;
    pointer-events: none;
  }

  @keyframes body-exit {
    0%   { opacity: 1; transform: translateY(0); }
    100% { opacity: 0; transform: translateY(-5px); }
  }
  @keyframes body-enter {
    0%   { opacity: 0; transform: translateY(6px); }
    100% { opacity: 1; transform: translateY(0); }
  }
  .module-body.body-exit {
    animation: body-exit 0.12s ease-in forwards;
    pointer-events: none;
  }
  .module-body.body-enter {
    animation: body-enter 0.18s ease-out forwards;
    pointer-events: none;
  }

  /* Module body */
  .module-body {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    color: #ccc;
    font-size: var(--t-title);
    padding: 15px;
    box-sizing: border-box;
    overflow: hidden;
    overflow-y: auto;
    transition: opacity 0.45s ease;
  }

  .module-body::-webkit-scrollbar { width: 4px; }
  .module-body::-webkit-scrollbar-track { background: transparent; }
  .module-body::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; }
  .module-body::-webkit-scrollbar-thumb:hover { background: #666; }

  /* Zoom UI */
  #zoom-widget {
    position: fixed;
    right: 30px;
    top: 50%;
    transform: translateY(-50%);
    width: 24px;
    height: 200px;
    z-index: 50;
    touch-action: none;
    cursor: grab;
  }
  #zoom-widget::before {
    content: '';
    position: absolute;
    inset: -16px;
    background: rgba(0, 0, 0, 0.75);
    border-radius: 12px;
    z-index: -1;
  }
  .zoom-line {
    position: absolute;
    left: 11px;
    top: 10px;
    bottom: 10px;
    width: 2px;
    background: #333;
  }
  .zoom-dot {
    position: absolute;
    left: 8px;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #555;
    cursor: pointer;
    transition: background 0.2s;
  }
  .zoom-dot[data-idx="0"] { top: 6px; }
  .zoom-dot[data-idx="1"] { top: 51px; }
  .zoom-dot[data-idx="2"] { top: 96px; }
  .zoom-dot[data-idx="3"] { top: 141px; }
  .zoom-dot[data-idx="4"] { top: 186px; }
  
  .zoom-thumb {
    position: absolute;
    left: 4px;
    width: 16px;
    height: 16px;
    background: #2ecc71;
    border-radius: 50%;
    cursor: grab;
    box-shadow: 0 2px 6px rgba(0,0,0,0.5);
    transition: top 0.15s ease-out;
  }
  .zoom-thumb.dragging {
    cursor: grabbing;
    transition: none;
  }

  /* Sports Data Styles */
  .data-wrapper {
    width: 100%;
    min-height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    container-type: inline-size;
  }
  .data-wrapper.top-align { justify-content: flex-start; }
  
  /* League Table & Lists */
  .data-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #333; font-size: var(--t-body); line-height: 0.8rem; }
  .data-row:last-child { border-bottom: none; }
  .data-row:not(.header):not(.highlighted):nth-child(even) { background: rgba(255,255,255,0.025); }
  .data-row.header { font-weight: 600; color: #888; border-bottom: 2px solid #444; }
  .data-row.highlighted {
    background: rgba(46, 204, 113, 0.15);
  }
  .data-row.highlighted .col-team { font-weight: 600; color: #fff; }
  .col-team { flex: 4; min-width: 60px; text-align: left; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 10px; }
  .col-num { flex: 1; min-width: 22px; text-align: center; }
  .col-num.pts { font-weight: bold; color: #2ecc71; }
  .col-num.xg-pos { font-weight: bold; color: #2ecc71; }
  .col-num.xg-neg { font-weight: bold; color: #e74c3c; }
  /* A real country name (e.g. "United States") is too long for a centered
     flex:1 column to wrap gracefully — truncates with ellipsis instead,
     same treatment as .col-team, just centered rather than left-aligned. */
  .col-num.nowrap-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .col-opt { display: none; }
  .col-wide { display: none; }
  .col-diff { display: none; }
  .col-delta { display: none; }
  .col-rank { flex: 0 0 28px; text-align: left; }
  .data-row.header .col-rank { cursor: pointer; }
  .data-row.header .sortable { cursor: pointer; }
  .data-row.header .sortable:hover { color: #fff; }
  .data-row.header .sort-asc,
  .data-row.header .sort-desc { color: #fff; }
  /* League Table module (js/renderers-leaguetable.js) — clicking a row
     switches this module's own content to that team's squad (rendered by
     delegating to window.renderSquadsModule, js/renderers-squads.js — the
     same function the standalone Squads module uses). Same row-hover
     recipe as Ranking's own rows (.ranking-list .ranking-row:hover). */
  .league-table-row-clickable { cursor: pointer; }
  .league-table-row-clickable:hover { background: rgba(255,255,255,0.06); }
  /* Squads module (js/renderers-squads.js) — its own player rows are drag
     sources (dragging one opens a Player Bio), so unlike the click-only
     row above these need cursor:grab on every descendant too, and for the
     same "cursor strobe" reason: an explicit cursor on the row alone
     wouldn't reach its own child cells, which would otherwise fall through
     to .module-body *'s cursor:default. */
  .squads-row, .squads-row * { cursor: grab; }
  .squads-row:hover { background: rgba(255,255,255,0.06); }
  /* Shared "switch this module to a related one" button — used in
     .module-sub-controls by Squad ("Table"), League Table ("Results"), and
     Match Results ("Table") to jump between the three related views of the
     same competition, all genuine module-type switches (mutate
     .module-title, then a normal refresh) rather than a rendered mode flag.
     Pushed to the far right via margin-left:auto, same push-right technique
     .header-actions/.value-mode-toggle already use elsewhere, so it sits to
     the right of whatever selects are already in the row. */
  .module-switch-btn {
    margin-left: auto; flex-shrink: 0;
    background: #1a1a1a; border: 1px solid #333; color: #aaa;
    font-family: inherit; font-size: var(--t-body); padding: 3px 10px;
    border-radius: 4px; cursor: pointer;
  }
  .module-switch-btn:hover { color: #fff; border-color: #555; }


  @container (min-width: 200px) {
    .col-opt { display: block; }
  }

  @container (min-width: 340px) {
    .col-wide { display: block; }
  }

  @container (min-width: 460px) {
    .col-diff { display: block; }
    .col-delta { display: block; font-size: var(--t-label); flex: 0.75; }
  }
  
  /* Player Bio module */
  .bio-empty { color: var(--text-muted); font-size: var(--t-body); }
  .bio-wrap { width: 100%; display: flex; flex-direction: column; gap: 14px; }
  .bio-head { display: flex; flex-direction: column; gap: 2px; }
  .bio-name { font-size: var(--t-heading); font-weight: 600; color: var(--text-color); line-height: 1.15; }
  .bio-legal { font-size: var(--t-data); color: var(--text-muted); }
  .bio-position {
    align-self: flex-start;
    margin-top: 6px;
    font-size: var(--t-label);
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: #2ecc71;
    background: rgba(46, 204, 113, 0.12);
    border: 1px solid rgba(46, 204, 113, 0.3);
    border-radius: 4px;
    padding: 3px 8px;
  }
  .bio-meta { display: flex; flex-direction: column; gap: 6px; }
  .bio-meta-row { display: flex; justify-content: space-between; gap: 8px; font-size: var(--t-body); border-bottom: 1px solid var(--border-subtle); padding-bottom: 6px; }
  .bio-meta-row:last-child { border-bottom: none; padding-bottom: 0; }
  .bio-meta-label { color: var(--text-muted); }
  .bio-meta-value { color: var(--text-secondary); font-weight: 500; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .bio-rating { display: flex; flex-direction: column; gap: 6px; }
  .bio-rating-label { display: flex; justify-content: space-between; font-size: var(--t-body); color: var(--text-muted); }
  .bio-rating-value { color: var(--text-color); font-weight: 600; }

  /* Stats category modules (one per Catalyst metric category — Appearance,
     Passing, Attacking, Defending, Goalkeeping — grouped internally by
     subcategory rather than split into a module per subcategory). Compare-
     against filter builder + range/marker bars, ported verbatim (values,
     not just structure) from the "real scale" mockup. */
  .stats-empty { color: var(--text-muted); font-size: var(--t-body); text-align: center; padding: 8px 4px; }
  /* The "try a real player who has this data" link a few of .stats-empty's
     own real single-player-data-gap messages now offer (Shot/Pass Map,
     Passing Profile, Matches — everywhere only Katie McCabe has real event/
     appearance data captured) — plain house green, no default link blue or
     underline, so it reads as an action inside this app's own dark theme
     rather than a stray browser default. */
  .stats-empty-link { color: #2ecc71; text-decoration: none; cursor: pointer; }
  .stats-empty-link:hover { text-decoration: underline; }
  .stats-wrap { width: 100%; }
  .stats-rows { display: flex; flex-direction: column; gap: 0; }
  .stat-group + .stat-group { margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--border-subtle); }
  .stat-group-label {
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--text-muted);
    font-weight: 600;
    margin-bottom: 4px;
  }

  /* Filter builder */
  /* Sits to the right of the player search box in .module-sub-controls
     (appended there by js/renderers-stats.js) — flex-shrink:0 so it never
     gets squeezed as the module narrows; the search box (flex:1,
     min-width:0) shrinks first. */
  .season-select-wrap { flex-shrink: 0; }
  .season-select { min-width: 72px; }
  /* Radar's position-template picker (renderers-radar.js) — appended after
     the season select in .module-sub-controls so it sits to its right. */
  .template-select-wrap { flex-shrink: 0; }
  .template-select { min-width: 100px; }

  /* "Add or remove metrics" icon button — sits after the Radar/Distribution
     toggle in .module-sub-controls (DOM order is visual order in that flex
     row, same convention every other sub-control there already follows),
     shown in both single-player and overlay mode since mod._radarCustomAxes
     is one shared field either way. Same small dark icon-button chrome as
     .module-switch-btn, just icon-sized rather than text-sized. */
  .radar-edit-axes-btn {
    flex-shrink: 0; width: 24px; height: 24px; margin-left: 4px; padding: 0;
    display: flex; align-items: center; justify-content: center;
    background: #1a1a1a; border: 1px solid #333; color: #aaa; border-radius: 4px; cursor: pointer;
  }
  .radar-edit-axes-btn:hover { color: #fff; border-color: #555; }

  /* Popover for adding/removing/reordering a Radar's own axis list — same
     dark-panel/appended-to-body/positioned-under-anchor convention as
     .value-picker and .filter-range-picker (renderers-stats.js). Two
     stacked sections: the radar's current axes (reorderable, removable)
     on top, a real-catalog metric browser (searchable, grouped by
     category) below. */
  .radar-axis-editor {
    position: absolute; z-index: 100; width: 280px;
    background: #1e1e1e; border: 1px solid var(--border-color); border-radius: 8px;
    box-shadow: 0 10px 24px rgba(0,0,0,0.5);
    padding: 10px; display: flex; flex-direction: column; gap: 10px;
  }
  .radar-axis-editor-section { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
  .radar-axis-editor-heading {
    font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted);
  }
  .radar-axis-rows { display: flex; flex-direction: column; gap: 1px; max-height: 170px; overflow-y: auto; }
  .radar-axis-row {
    display: flex; align-items: center; gap: 6px; padding: 4px 4px 4px 2px; border-radius: 4px;
  }
  .radar-axis-row:hover { background: rgba(255,255,255,0.05); }
  .radar-axis-row.dragging { background: rgba(46, 204, 113, 0.14); opacity: 0.85; }
  .radar-axis-drag-handle {
    flex: 0 0 auto; width: 14px; text-align: center; color: var(--text-muted); cursor: grab; font-size: 0.8rem; line-height: 1;
  }
  .radar-axis-drag-handle:active { cursor: grabbing; }
  .radar-axis-row-label {
    flex: 1; min-width: 0; font-size: 0.7rem; color: var(--text-secondary);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .radar-axis-remove-btn {
    flex: 0 0 auto; width: 18px; height: 18px; display: flex; align-items: center; justify-content: center;
    background: none; border: none; color: var(--text-muted); font-size: 0.62rem; cursor: pointer; border-radius: 3px;
  }
  .radar-axis-remove-btn:hover { color: #e74c3c; background: rgba(231, 76, 60, 0.16); }
  .radar-axis-remove-btn:disabled { opacity: 0.3; cursor: not-allowed; }
  .radar-axis-editor-search {
    background: #141414; border: 1px solid var(--border-color); border-radius: 4px; color: var(--text-color);
    font-size: 0.7rem; padding: 5px 8px; font-family: inherit; width: 100%; box-sizing: border-box;
  }
  .radar-axis-add-list { max-height: 190px; overflow-y: auto; display: flex; flex-direction: column; }
  .radar-axis-add-group-label {
    font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-dim);
    padding: 6px 4px 2px;
  }
  .radar-axis-add-row {
    display: flex; align-items: center; gap: 6px; padding: 4px; font-size: 0.7rem; color: var(--text-secondary);
    cursor: pointer; border-radius: 4px;
  }
  .radar-axis-add-row:hover { background: rgba(46, 204, 113, 0.12); color: #fff; }
  .radar-axis-add-plus { color: #2ecc71; font-weight: 700; width: 10px; text-align: center; flex-shrink: 0; }
  .league-table-competition-select-wrap { flex-shrink: 0; }
  .league-table-competition-select { min-width: 90px; }
  .league-table-season-select-wrap { flex-shrink: 0; }
  .league-table-season-select { min-width: 72px; }
  .squads-competition-select-wrap { flex-shrink: 0; }
  .squads-competition-select { min-width: 90px; }
  .squads-team-select-wrap { flex: 1 1 auto; min-width: 0; }
  .squads-team-select { width: 100%; max-width: 100%; }
  .match-results-competition-select-wrap { flex-shrink: 0; }
  .match-results-competition-select { min-width: 90px; }
  .match-results-team-select-wrap { flex: 1 1 auto; min-width: 0; }
  .match-results-team-select { width: 100%; max-width: 100%; }

  /* Match Results module (js/renderers-matchresults.js) — a plain fixture
     list, no sort/filter/group at all (real Catalyst's own Match Results
     page has none either, verified live). Reuses .data-wrapper/.data-row
     for the same scroll/zebra-stripe chrome as League Table/Squad, with its
     own row layout since a fixture doesn't fit the team-name-first .col-team/
     .col-num shape — two team names either side of a centred score. */
  .match-row { align-items: center; gap: 6px; }
  .match-date { flex: 0 0 62px; font-size: 0.66rem; color: var(--text-muted); white-space: nowrap; }
  .match-home, .match-away {
    flex: 1 1 0; min-width: 0; color: #fff;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .match-home { text-align: right; }
  .match-away { text-align: left; }
  .match-score {
    flex: 0 0 44px; text-align: center; font-weight: 600; color: var(--text-color);
    font-variant-numeric: tabular-nums;
  }

  /* Shot Map / Pass Map (js/renderers-shotmap.js) — real StatsBomb
     event-level data (one row per shot/pass), plotted on a standard
     120x80-yard pitch. Chart + summary card side by side, wrapping to
     stacked below the pitch's own min-width the same way Radar's chart/
     table pair already does. */
  .event-competition-select-wrap, .event-season-select-wrap { flex-shrink: 0; }
  .event-competition-select { min-width: 120px; }
  /* Colour By control (both Shot Map and Pass Map, js/renderers-shotmap.js)
     — lives inside .event-filter-bar itself, to the left of the filter
     chips/"+ Add filter", per direct instruction ("above the pitch map, to
     the left of the filters"). Styled as a plain .filter-chip pill, per
     direct instruction, rather than a dropdown — .filter-chip's own padding
     (5px 6px 5px 10px) is asymmetric to leave room for a .remove ✕ every
     real filter chip has; this one never does, so it's evened back out. */
  .event-color-by-chip { flex-shrink: 0; padding: 5px 10px; cursor: pointer; }
  .event-season-select { min-width: 72px; }
  /* .module-body's align-items:center shrinks an unsized child to fit its
     own content instead of spanning the module — same recurring fix as
     .stats-wrap/.radar-wrap/.filter-wrap elsewhere in this app — needed
     here because the filter bar sits as a direct sibling of the pitch
     layout, not nested inside something that already has width:100%.
     height:100% (a real, bounded value — .module-body itself always has a
     definite height, flex:1 inside the module's own fixed-height box) plus
     flex-direction:column is what lets .event-pitch-wrap below claim
     "whatever's left after the filter bar" rather than just growing to fit
     its own content, which is what let the pitch overflow past the bottom
     of the module on a short/wide resize before this fix. */
  .event-map-wrap { width: 100%; height: 100%; display: flex; flex-direction: column; }
  .event-filter-bar { width: 100%; flex-shrink: 0; }
  /* Both Shot Map and Pass Map's own areas (shotMapAreaHtml/passMapAreaHtml,
     js/renderers-shotmap.js) add a real per-event Details side panel next to
     the pitch, not a summary card — see .event-detail-layout below.
     Per direct instruction, the pitch must always stay within the module's
     own box regardless of how it's resized, on both axes — not just
     scaled to the module's width the way it was before (which let a short/
     wide resize stretch the pitch's height past the bottom of the module).
     flex:1 + min-height:0 lets this wrap actually shrink below its
     content's intrinsic size in the flex column above (the usual flexbox
     default is to never shrink a child below its content height, which
     would silently defeat this); centering keeps the pitch in the middle
     of whatever space is actually available rather than pinned to a
     corner. */
  .event-pitch-wrap {
    width: 100%; flex: 1; min-height: 0;
    display: flex; align-items: center; justify-content: center;
    /* Anchors the drag-select rectangle and the "Reset view" button below —
       both are plain HTML siblings of the <svg>, positioned via screen-space
       coordinates converted relative to this wrap's own bounding rect. */
    position: relative;
  }
  /* Live preview while dragging out an area to zoom into (js/renderers-
     shotmap.js's wireAreaSelect) — a plain HTML div, not an SVG rect, since
     it tracks the raw on-screen drag directly rather than anything in the
     pitch's own (possibly already-zoomed) coordinate space. Removed the
     instant the drag ends, whether it commits to a new zoom or not — this
     is transient drag feedback only, never a persistent "you are zoomed in"
     indicator (the Reset view button below is that). */
  .event-area-select-overlay {
    position: absolute; z-index: 5; pointer-events: none;
    border: 1.5px dashed #00c2ff; background: rgba(0, 194, 255, 0.12);
  }
  /* Persistent perimeter around an already-zoomed pitch view (Shot Map/Pass
     Map's applyPitchZoom, Zone Finder's applyZoneFinderZoom) — same colour/
     style as the live drag preview above, but an outline rather than a
     border so it doesn't grow the element's own box (which would nudge the
     already-tight aspect-ratio sizing). outline-offset pulls it fully
     inside the box's own edge rather than straddling it. */
  .event-pitch-svg.event-zoomed {
    outline: 1.5px dashed #00c2ff; outline-offset: -1.5px;
  }
  /* Shown only while mod._eventAreaSelection is active — same small dark
     pill chrome as .module-switch-btn, just corner-overlaid on the pitch
     itself (where the zoom it undoes actually happened) rather than in a
     control row, since this is a whole-view reset, not a data filter. */
  .event-area-reset-btn {
    position: absolute; top: 6px; right: 6px; z-index: 6;
    background: #1a1a1a; border: 1px solid #333; color: #aaa;
    font-family: inherit; font-size: var(--t-body); padding: 3px 10px;
    border-radius: 4px; cursor: pointer;
  }
  .event-area-reset-btn:hover { color: #fff; border-color: #555; }
  /* Table view's own reset control — same real need (mod._eventAreaSelection
     narrows the table rows too), but rendered inline in the filter bar
     (eventFilterBarHtml's extraHtml slot, the same slot Colour By already
     uses) rather than corner-overlaid, since there's no pitch to overlay in
     Table mode. Deliberately its own class rather than .module-switch-btn —
     that class carries a baked-in margin-left:auto meant for a control
     placed *last* in a row; this one renders first (before the filter
     chips), same position Colour By's own plain .filter-chip styling
     already sits in, so it needs plain static flow, not a push-right. */
  .event-area-reset-inline-btn {
    flex-shrink: 0;
    background: #1a1a1a; border: 1px solid #333; color: #aaa;
    font-family: inherit; font-size: var(--t-body); padding: 3px 10px;
    border-radius: 4px; cursor: pointer;
  }
  .event-area-reset-inline-btn:hover { color: #fff; border-color: #555; }

  /* Zone Finder (js/renderers-zonefinder.js) — the pitch+panel row is now
     built from EvKit.eventPitchPanelHtml, the exact same shared template
     Shot Map/Pass Map build theirs from (js/renderers-shotmap.js), so
     .event-detail-layout/.event-details-panel/.event-details-body apply
     here completely unmodified — there is no Zone-Finder-specific override
     for the row or the panel box any more, which is what actually
     guarantees "no difference in basic layout" between the three modules:
     a future tweak to either shared rule now applies to all three
     automatically, rather than three separately-tuned copies of the same
     numbers risking drift. .zone-finder-panel-content is the one small,
     genuinely Zone-Finder-only addition — a plain flex column (gap:4px)
     living *inside* the shared .event-details-body, giving the What/Who/How
     stack the same vertical rhythm the old .zone-finder-filters-panel had,
     without touching the shared class it now sits inside of.
     Real bug fixed, reported directly ("when the user clicks load event
     data, the right hand panel should not change size, nor should the
     pitch"): .zone-finder-list used to be a second flex:1 sibling of the
     row inside .zone-finder-wrap, splitting the wrap's height with it —
     and the render function hid the *whole* .zone-finder-list/.stats-empty
     elements outright before data loaded, which removed them as flex
     children entirely, letting .event-detail-layout claim 100% of the
     wrap's height while unloaded and forcing it back down to its real
     split the instant the leaderboard reappeared after a load. Fixed on
     both sides: .zone-finder-list is no longer flex:1 at all — it's a
     fixed-share, non-growing region (flex-shrink:0, a real fixed height
     capped at roughly the two extra grid rows this module's own default
     size grew by, own internal overflow-y:auto beyond that), so
     .event-detail-layout is the *sole* flex:1 child of .zone-finder-wrap
     and claims exactly "everything left after the leaderboard's fixed
     share" — structurally identical to how .event-map-wrap's own
     .event-detail-layout claims everything left after its fixed-size
     filter bar on Shot/Pass Map. And js/renderers-zonefinder.js's own
     load-gate no longer toggles display on .zone-finder-list/.stats-empty
     at all (only clears/restores their real content) — the wrap's flex
     children, and thus the row's computed height, now stay identical
     whether data has loaded or not.
     Real follow-on bug, reported directly with two screenshots ("why...
     does searching one of the bottom three metrics [a low-population
     action type, e.g. Offsides — 2 real players] stretch the pitch/panel
     down vertically" vs. a normal-population one that doesn't): this rule
     used to be `max-height` only, not a floor — fine for the *unloaded*
     gate (js/renderers-zonefinder.js used to paper over that one specific
     case with its own inline min-height, see that file's history), but the
     exact same shrink happens for real, after loading, whenever the
     genuine leaderboard is just naturally short (a handful of real rows,
     nowhere near 280px of content) — .event-detail-layout, the wrap's one
     other flex:1 child, silently claims whatever height the shrunken list
     gives up, stretching the pitch+panel row taller than normal. Changed
     `max-height` to a real fixed `height`, so this box never shrinks below
     280px regardless of how few real rows it holds — the same value, just
     a floor as well as a ceiling now, which is what let the JS-side
     inline min-height patch (only ever a partial fix for one of the two
     real trigger cases) be removed outright as dead code rather than kept
     alongside a second, now-redundant mechanism. */
  .zone-finder-wrap { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
  .zone-finder-wrap > .stats-empty { flex-shrink: 0; }
  /* Real bug fixed, reported directly ("the pitch in the Zone Finder module
     is slightly wider than the pitch that appears in the Shot Map and Pass
     Map modules... we need consistency"): all three share the exact same
     .event-pitch-svg/.event-detail-layout markup and CSS, so the pitch's
     own rendered size is driven entirely by how much real pixel space
     .event-detail-layout (the pitch+panel row) happens to get — and that
     genuinely differed. Measured directly via a real headless render at
     each module's own default size (6x6 for Shot/Pass Map, 6x8 for Zone
     Finder) rather than guessed: Shot Map's row gets 433px of height and
     its aspect-ratio-locked portrait pitch is height-bound there (renders
     281px wide, comfortably under its 304px available width). Zone
     Finder's row was getting 514px — 81px more — which was just enough
     extra to flip its own pitch from height-bound to width-bound instead,
     rendering the full 304px available width (visibly wider, and shorter
     relative to its own max, than Shot/Pass Map's). The 81px gap traces
     directly to this rule's own max-height: the two extra grid rows Zone
     Finder's default size was given (see the comment above) were meant to
     go entirely to this reserved leaderboard share, but 200px of them
     undershot what was actually needed by ~81px, leaking that difference
     into the row instead. Raised to 280px (200+81, the measured gap) so
     the row's real available height matches Shot/Pass Map's exactly (433px
     either way) and the pitch renders at the same size in all three — a
     empirically-derived constant like this file's own 300px panel
     max-width, not a design width chosen from nothing; re-measure and
     adjust both this value and the matching inline min-height
     (js/renderers-zonefinder.js's own load-gate) if either module's real
     above-row chrome (header/sub-controls/filter-bar on Shot/Pass Map;
     header/population-readout on Zone Finder) changes enough to reopen
     this gap. */
  .zone-finder-list { flex-shrink: 0; height: 280px; overflow-y: auto; }
  /* Real bug fixed, reported directly with a screenshot ("not enough
     vertical space between the first two [What/its chip]... adjust the
     spacing so the three elements are evenly and consistently spaced"):
     this column's own flex `gap` was the only thing meant to control
     spacing between every section label and the content below it, but two
     other rules were silently adding extra space *only* between a
     section's content and the *next* label, never between a label and its
     own content — .zone-finder-section-label's own margin-top (2px) and
     the shared .filter-bar's own margin-bottom (12px, sized for its usual
     job of separating a filter-chip row from unrelated content below it
     elsewhere in this app, e.g. Stats/Ranking's own population readout).
     Stacked together, that made "Who's filter row -> How's label" ~18px
     while "What's label -> its own chip" was a bare 4px — the exact
     asymmetry reported. Fixed by raising the one real gap this column
     needs to a single, larger, consistent value, and zeroing both of the
     other two margins out entirely *within this column* (scoped
     selectors, not touching .filter-bar's own base rule other modules
     rely on) — every label-to-content and content-to-next-label gap in
     this stack is now driven by this one number, not three overlapping
     ones. */
  .zone-finder-panel-content {
    display: flex; flex-direction: column; gap: 10px;
  }
  .zone-finder-section-label,
  .zone-what-chip-wrap,
  .zone-name-search-wrap,
  .zone-finder-panel-content .filter-bar { flex-shrink: 0; }
  .zone-finder-panel-content .filter-bar { margin-bottom: 0; }
  .zone-finder-section-label {
    font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted);
    margin-top: 0;
  }
  /* Real restructure, per direct instruction: three sections now, not two —
     Who (bio profile), What (the action itself), How (filter the
     characteristics of that action). What started as a single cycling
     toggle, became a 9-way button row once the real action types grew from
     3 to 9, then a plain dropdown once 9 buttons (wrapping to 3 rows) read
     as more chrome than the choice warranted, then this — a real third
     distinct style, per direct instruction ("the same way we display the
     colour options in the Heat and Pass Maps"): a .filter-chip "lozenge"
     pill, the exact same shape/interaction as Pass Map's own "Colour: OBV
     (Total)" chip (js/renderers-shotmap.js's colorByChipHtml), opening the
     same EvKit.openEventValuePicker search-to-pick popover rather than a
     styled-select dropdown or a button row. */
  .zone-what-chip-wrap { align-self: flex-start; }
  /* Padding override matches .event-color-by-chip's own (js/renderers-
     shotmap.js/css above) — neither chip has a remove (✕) button, so
     .filter-chip's own base padding (asymmetric, extra room on the right
     for one) would leave this text looking off-centre. */
  .zone-what-chip { padding: 5px 10px; cursor: pointer; }
  /* The real "load a separate event-data file on demand" prompt — originally
     built Zone-Finder-only (as .zone-load-prompt*) for the six
     statsBombOpenData-backed action types, generalized to .event-load-
     prompt* the moment shotPassDetail got the same lazy-load treatment and
     Shot Map/Pass Map/Passing Profile/Heat Map all needed the identical
     idle/loading/error+retry UI too (js/renderers-shotmap.js's shared
     dataLoadPromptHtml/loadShotPassDetail/loadStatsBombOpenData). */
  .event-load-prompt { text-align: center; padding: 10px 4px; display: flex; flex-direction: column; gap: 8px; align-items: center; }
  /* Action Map only (js/renderers-zonefinder.js's own ZONE_EXPERIMENTAL_
     NOTICE) — this shared .event-load-prompt shell is also used by Shot/
     Pass Map/Passing Profile/Heat Map's own lazy-load prompts, none of
     which are experimental, so this class is never emitted by any of
     those. */
  .event-load-prompt-experimental { color: #e2586b; font-size: var(--t-body); font-weight: 600; margin: 0; }
  .event-load-prompt-text { color: var(--text-muted); font-size: var(--t-body); margin: 0; max-width: 280px; }
  .event-load-prompt-btn {
    background: #1a1a1a; border: 1px solid #2ecc71; color: #2ecc71;
    font-family: inherit; font-size: 0.78rem; font-weight: 600; padding: 6px 14px; border-radius: 4px; cursor: pointer;
  }
  .event-load-prompt-btn:hover { background: rgba(46, 204, 113, 0.12); }
  /* Every real entry currently matching the Who/How filters *and* falling
     inside the drawn zone (the whole pitch, if none is drawn), plotted at
     its own real x/y in .event-layer — per direct instruction, so a user
     can see where an action type clusters before drawing a zone, and see
     the population visibly narrow to match once one is drawn (an earlier
     version kept every dot on screen and only dimmed the ones outside the
     zone; corrected per direct instruction that outside-the-zone dots
     should disappear, not just fade). House green (the same "this is real
     data" green used throughout this app), not the zone's own electric
     blue, so the two never read as the same kind of thing. A bigger
     radius plus a real stroke was tried right after shipping this and
     reported back as "massive" — reverted to a small, plain, stroke-free
     dot per direct instruction ("don't complicate it, simplify it"). */
  .zone-finder-dot { fill: #2ecc71; pointer-events: none; }
  /* Real name search, narrowing the actual leaderboard/pitch population (a
     real filter), not a jump-to-and-highlight action within an already-
     rendered list — same real box/clear-button chrome as Ranking's own
     "Find player…" search (js/renderers-rankings.js's .ranking-player-
     search*), duplicated rather than shared, per Zone Finder's own
     standing ring-fenced/isolated design (a Zone-Finder-only need shouldn't
     grow a shared component other modules would then depend on). Lives in
     .module-sub-controls now, not the "Who" panel section it started in —
     moved per direct instruction to match Heat Map's own header pattern
     (player search, then league, then year, all in one row) — so it also
     carries .player-search-wrap (js/renderers-zonefinder.js applies both
     classes to the same element) purely for that shared class's
     flex:1/min-width:0 sizing, letting it claim the row's leftover width
     the same way Heat Map's own search box does, beside the two fixed-
     width league/year boxes below. */
  .zone-name-search-wrap { position: relative; }
  .zone-name-search {
    width: 100%; box-sizing: border-box; font-size: var(--t-body); color: #aaa;
    background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 5px 26px 5px 8px;
    outline: none; font-family: inherit;
  }
  .zone-name-search:focus { border-color: #555; color: #ddd; }
  .zone-name-search::placeholder { color: #666; }
  .zone-name-search-clear {
    position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
    width: 16px; height: 16px; line-height: 14px; text-align: center;
    background: none; border: none; color: #888; font-size: 15px; cursor: pointer;
    padding: 0; border-radius: 3px;
  }
  .zone-name-search-clear:hover { color: #fff; background: rgba(255,255,255,0.08); }
  /* Genuinely fixed, single-option stand-ins for Heat Map's own real
     competition/season selects (js/renderers-zonefinder.js builds them via
     the same window.renderStyledSelect, just with one option each) — per
     direct instruction ("I realise these second two won't be possible to
     change, but at least we get the pattern nailed down"), since this
     whole module is permanently pinned to one real season. flex-shrink:0
     so the search box's own flex:1 is what absorbs/gives up width on
     resize, matching .event-competition-select-wrap/.event-season-select-
     wrap's own identical rule for Shot/Pass Map/Heat Map's real pickers. */
  .zone-league-select-wrap, .zone-year-select-wrap { flex-shrink: 0; }
  /* max-width/max-height (not width/height) is what makes this genuinely
     fit *inside* the wrap on whichever axis is actually the tight one —
     width alone (the old rule) only ever constrained the horizontal size
     and let the height implied by the aspect ratio grow unboundedly past
     the wrap's real available height. aspect-ratio uses the pitch's own
     real dimensions (114x74 — see PITCH_L/PITCH_W in
     js/renderers-shotmap.js, matched to the real captured coordinate
     range, not the textbook 120x80) so it shrinks to fit without ever
     distorting the pitch's own proportions. */
  .event-pitch-svg {
    display: block; max-width: 100%; max-height: 100%;
    width: auto; height: auto; aspect-ratio: 114 / 74;
  }
  /* Shot Map/Pass Map's own pitch rotated 90° anti-clockwise (portrait,
     attack toward the top — js/renderers-shotmap.js's own pitchShellHtml),
     per direct instruction. A more specific two-class override rather than
     flipping the base rule above, since Heat Map's `.heatmap-svg` shares
     that same base `.event-pitch-svg` class but stays landscape/unrotated
     — flipping the shared rule in place would have distorted it instead. */
  .event-pitch-svg.event-pitch-svg-rotated { aspect-ratio: 74 / 114; }
  /* Per-event Details side panel — shared shell (eventDetailAreaHtml) and
     grid primitives for both Pass Details and Shot Details
     (js/renderers-shotmap.js's passDetailsPanelHtml/shotDetailsPanelHtml),
     genuinely identical structure for both real event types (a real per-
     event detail panel updated live by whichever event is currently
     magnetically selected — see wireMagneticPassHover/wireMagneticShotHover
     — rather than two independently-styled panels that'd need every future
     tweak applied twice). .event-pitch-wrap's own existing flex:1/
     min-height:0/centering rules work unchanged as a row child here (flex
     is axis-agnostic) — align-items defaults to stretch on a row, which is
     exactly what lets both the pitch and this panel share the same full
     height without any extra rule needed for that. */
  .event-detail-layout { display: flex; gap: 12px; width: 100%; flex: 1; min-height: 0; }
  .event-details-panel {
    flex: 1 1 220px; min-width: 180px; max-width: 300px; min-height: 0;
    display: flex; flex-direction: column;
    background: rgba(255,255,255,0.03); border: 1px solid var(--border-subtle); border-radius: 8px;
    overflow: hidden;
  }
  .event-details-header {
    flex-shrink: 0; padding: 10px 12px; font-size: var(--t-body); font-weight: 600;
    color: var(--text-color); border-bottom: 1px solid var(--border-subtle);
  }
  .event-details-body { flex: 1; min-height: 0; overflow-y: auto; padding: 10px 12px; }
  .event-details-empty { color: var(--text-dim); font-size: var(--t-body); text-align: center; padding: 20px 8px; }
  /* align-content:start is load-bearing, not decoration — a grid
     container's default (normal) computes to stretch for auto-sized rows,
     so on a module resized tall/narrow (panel height >> real content
     height), every row was silently padded out to fill the extra space
     instead of staying compact, shoving the last real content (Shot
     Details' own OBV chart) below the panel's visible/scrollable bounds.
     Reported directly ("too much space and data disappears from the
     bottom") — a latent bug in this shared class exposed by an unusual
     resize, not new content; start keeps rows at their natural height and
     lets any real leftover space collapse after the last row instead. */
  .event-detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; align-content: start; }
  .event-detail-span2 { grid-column: 1 / -1; }
  .event-detail-label {
    font-size: 0.62rem; letter-spacing: 0.04em; text-transform: uppercase;
    color: var(--text-dim); margin-bottom: 3px;
  }
  /* Coordinates/OBV used to force a monospace font here (a leftover from an
     early "code-like" styling instinct) — per direct instruction, this
     panel uses the exact same font as the rest of the app, no override. */
  .event-detail-value { font-size: 0.74rem; color: var(--text-color); line-height: 1.4; }
  /* Pass's Characteristics/Pass Into and Shot's Characteristics rows hold a
     variable number of wrapping pills (0 when empty, shown as a plain "—",
     up to real-world 2 when populated — checked directly: no real pass or
     shot in the data has more than 2 true characteristic flags at once) —
     reported directly for passes, this row's own height changing between
     events was shifting every field below it ("everything jump down
     slightly"). min-height reserves room for the real worst case (2 wrapped
     pill lines) so switching between an empty and a populated event never
     moves anything else in the panel — same fix, same reasoning, now shared
     by both event types via one class. */
  .event-detail-value.event-detail-tags { min-height: 44px; }
  .event-detail-pill {
    display: inline-block; font-size: 0.68rem; color: var(--text-secondary);
    background: rgba(255,255,255,0.06); border-radius: 10px; padding: 2px 8px; margin: 0 4px 4px 0;
  }
  .event-detail-pill.event-pill-good { color: #2ecc71; background: rgba(46, 204, 113, 0.12); }
  .event-detail-pill.event-pill-bad { color: #e2586b; background: rgba(226, 88, 107, 0.12); }
  /* Shot Outcome pill's real third tier — "on target but not scored"
     (Saved), distinct from a real Goal (good) and a real off-target/blocked/
     wayward attempt (bad) — reuses the exact same blue already used for the
     on-target pitch marker/EVENT_BODY_PART_COLORS' Left Foot, rather than
     inventing a fourth app colour for the same real concept. */
  .event-detail-pill.event-pill-neutral { color: #4d8bf5; background: rgba(77, 139, 245, 0.12); }
  /* Result row (js/renderers-shotmap.js's matchResultDisplay, shared by both
     panels) — a real win/loss/draw from McCabe's own side conveyed by
     colour alone, not a trailing W/D/L letter, on a scoreline that's always
     written home-first (never reordered to put her team first) per direct
     instruction. Same real green/red as the Outcome pill just above; a draw
     needs no override, the plain .event-detail-value colour is already
     white/neutral. */
  .event-detail-value.event-result-win { color: #2ecc71; }
  .event-detail-value.event-result-loss { color: #e2586b; }
  .event-detail-positive { color: #2ecc71; }
  .event-detail-negative { color: #e2586b; }
  /* Diverging mini charts (OBV's real For/Against/Total; Shot's real xG
     Base/Execution ±/Final) — horizontal bars centred on a real zero line,
     per direct instruction ("something beautiful and clever... like a small
     graph"). Each bar's length is scaled relative to the largest |value|
     among that exact chart's own row set by default (obv_against_net is
     genuinely almost always much smaller than obv_for_net/obv_total_net in
     real data — checked directly: against ranges roughly ±0.06-0.09 across
     McCabe's whole career vs for/total's much wider ±0.24/+0.74, so a shared
     *global* scale would flatten "against" to an invisible sliver on nearly
     every real pass) or a fixed override when the metric has a real
     theoretical bound worth holding constant (xG's own 0-1, see
     renderXgChart) — the class names keep their original "obv-chart" prefix
     since the shared renderer (renderDivergingBarChart) grew out of the OBV
     chart first, not because this is OBV-specific any more. */
  .obv-chart { display: flex; flex-direction: column; gap: 6px; margin-top: 2px; }
  .obv-chart-row { display: grid; grid-template-columns: 46px 1fr 52px; align-items: center; gap: 8px; }
  .obv-chart-row.obv-chart-row-total { padding-top: 6px; border-top: 1px solid var(--border-subtle); }
  .obv-chart-label { font-size: 0.66rem; color: var(--text-secondary); }
  .obv-chart-row-total .obv-chart-label { color: var(--text-color); font-weight: 600; }
  .obv-chart-track { position: relative; height: 10px; background: rgba(255,255,255,0.05); border-radius: 3px; }
  .obv-chart-zero { position: absolute; left: 50%; top: -2px; bottom: -2px; width: 1px; background: var(--text-dim); }
  .obv-chart-bar { position: absolute; top: 1px; bottom: 1px; border-radius: 2px; }
  .obv-chart-bar.positive { background: #2ecc71; }
  .obv-chart-bar.negative { background: #e2586b; }
  .obv-chart-value { font-size: 0.68rem; text-align: right; font-variant-numeric: tabular-nums; color: var(--text-secondary); }
  .obv-chart-value.positive { color: #2ecc71; }
  .obv-chart-value.negative { color: #e2586b; }
  /* Fixed stats bar between .module-sub-controls and .module-body, same
     "never scrolls out of view" convention Ranking/Stats' own
     .population-readout already established — carries every summary stat
     (including the "N of TOTAL shown" count) since there's no more side
     card to split them across. Wraps to multiple lines on a narrow module
     rather than truncating or overflowing. */
  .event-stats-bar {
    display: flex; flex-wrap: wrap; gap: 16px; align-items: baseline;
    padding: 10px 12px; margin: 10px 15px 0;
    background: rgba(255,255,255,0.03); border: 1px solid var(--border-subtle); border-radius: 8px;
  }
  .event-stat { display: flex; flex-direction: column; gap: 2px; }
  .event-stat-value { font-size: 1.1rem; font-weight: 700; color: var(--text-color); font-variant-numeric: tabular-nums; }
  .event-stat-label { font-size: 0.68rem; color: var(--text-muted); }

  /* Passing Profile — a wagon wheel (every pass re-centred on one origin
     point, by real direction/length) above a 3x3 real-pitch-zone grid of the
     same shape, one mini wheel per zone. Shares .event-filter-bar/
     .event-stats-bar with Shot/Pass Map (styled above, untouched here) —
     only the charts themselves are new. */
  .passing-profile-wrap { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 6px; }
  /* No max-width cap, per direct instruction that this module should follow
     Radar's own responsiveness model (renderers-radar.js's .radar-chart-
     inner, same 90% figure) — fills as much of the module as it's resized
     to rather than plateauing at a fixed pixel ceiling. */
  .pp-chart-inner { position: relative; width: 90%; margin: 0 auto 0; }
  .pp-wheel-svg { width: 100%; height: auto; display: block; overflow: visible; }
  .pp-ring { fill: none; stroke: rgba(255,255,255,0.08); stroke-width: 1; }
  .pp-ref-spoke { stroke: rgba(255,255,255,0.08); stroke-width: 1; }
  .pp-center-dot { fill: var(--text-muted); }
  .pp-wheel-line { fill: none; stroke-width: 1.1; transition: stroke-width 0.12s ease, opacity 0.12s ease; }
  /* Magnetic nearest-line hover (js/renderers-passingprofile.js's
     wireMagneticWheelHover) replaced a plain :hover rule here — thin lines
     radiating from one shared centre point overlap too much for native
     per-line hover to reliably pick up, same problem Pass Map's own lines
     had (.event-pass-line.event-pass-selected/-dimmed, same values). */
  .pp-wheel-line.pp-wheel-line-selected { stroke-width: 2.6; }
  .pp-wheel-line.pp-wheel-line-dimmed { opacity: 0.15; }
  /* Invisible pie-slice hit regions, one per real 45°-spaced direction —
     same technique as Radar's own .radar-wedge (renderers-radar.js), same
     darkening-wash hover treatment for visual consistency with it. .active
     marks whichever wedge (if any) is the currently-committed Direction
     filter — a distinct green wash so "currently hovering" and "already
     locked in by a click" never read as the same state. */
  .pp-hover-wedge { fill: transparent; cursor: pointer; transition: fill 0.15s ease; }
  .pp-hover-wedge:hover { fill: rgba(0, 0, 0, 0.28); }
  .pp-hover-wedge.active { fill: rgba(46, 204, 113, 0.16); }
  .pp-hover-wedge.active:hover { fill: rgba(46, 204, 113, 0.26); }
  /* Plain HTML overlay, not SVG <text> — same reason Radar's own axis/tick
     labels moved out of the SVG (renderers-radar.js): a viewBox scales
     everything inside it uniformly, font-size included, which a compass
     label and a distance ring must never do. */
  .pp-labels-overlay { position: absolute; inset: 0; pointer-events: none; }
  /* Plain arrow glyphs now (Forward/Right/Backward/Left, per direct
     instruction to replace the old "▲ Forward"/"▼ Backward" text with
     unlabelled arrows for all four cardinal directions) — their own word
     survives only as a hover tooltip (wireCompassLabelTooltips), so
     pointer-events needs re-enabling here despite the parent overlay
     disabling it wholesale, same fix already used for Radar's own
     .radar-axis-label inside .radar-labels-overlay. */
  .pp-compass-label {
    position: absolute; font-size: 15px; font-weight: 700;
    color: var(--text-muted); white-space: nowrap;
    pointer-events: auto; cursor: default; transition: color 0.15s ease;
  }
  .pp-compass-label:hover { color: #e4e6ec; }
  /* Bumped from a faint 8px var(--text-dim) per direct feedback ("needs to
     be a lot clearer") — same lesson Radar's own tick labels already
     learned (renderers-radar.js: "real per-axis numbers ... are only
     useful if they can actually be read at a glance"), applied here as a
     flat legible default rather than Radar's hover-to-brighten scheme,
     since these ring ticks have no matching hover interaction of their own. */
  .pp-ring-tick-label {
    position: absolute; font-size: 10px; font-weight: 700; color: #e4e6ec; white-space: nowrap;
    transform: translate(6px, -50%); font-variant-numeric: tabular-nums;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
  }
  .pitch-line { stroke: rgba(255,255,255,0.35); stroke-width: 0.3; fill: none; }
  .pitch-spot { fill: rgba(255,255,255,0.35); }
  /* .event-shot-group wraps a real shot trajectory line (hover/selected-
     revealed, see .event-shot-line below) and its own .event-shot-marker
     circle, per direct instruction ("we need to see the line for the
     shot") — grouped specifically so a shared ancestor :hover/class state
     can reveal the line, which a bare sibling circle can't drive with
     plain CSS. opacity/transition live on the group (not just the marker)
     so hiding a shot hides its line along with it, not just its dot. */
  .event-shot-group { cursor: pointer; opacity: 1; transition: opacity 0.15s ease; }
  .event-shot-marker { stroke-width: 0.4; }
  /* Invisible until hovered or the shot is locked (see below) — pointer-
     events:none keeps this purely visual, so the click hit-test (real
     distance-to-cursor against the marker's own point+radius, computed in
     JS, not a DOM hit-test) never has to account for a second clickable
     region running the length of the line. */
  .event-shot-line { stroke-width: 0.3; opacity: 0; pointer-events: none; transition: opacity 0.15s ease; }
  /* Real bug fixed, reported directly: plain :hover only reliably hit-tests
     the marker's *fill*, not its thin (0.4-unit) stroke ring — exactly the
     kind of native SVG hit-testing gap this app's own magnetic hover system
     (wireShotSelection, js/renderers-shotmap.js) already exists to route
     around for tooltips/selection. Replaced with a real class toggle
     (.event-shot-hovered, set from that same magnetic `hovered` state) so
     the line and the tooltip can never disagree about which shot counts as
     hovered again. */
  .event-shot-group.event-shot-hovered .event-shot-line { opacity: 1; }
  /* Selection is click-to-lock, not hover (wireShotSelection in
     js/renderers-shotmap.js) — per direct instruction, clicking a shot must
     stay selected while the cursor moves freely to inspect the freeze-frame
     dots it reveals, which a continuous hover-driven selection couldn't
     support. The click hit-test itself is still real distance-to-cursor
     (minus the marker's own drawn radius — every marker now shares one
     fixed size, radius no longer varies by xG, but the subtraction stays
     correct regardless) rather than relying on whichever DOM circle the
     browser's own click
     target happens to be — real shot maps cluster densely enough near the
     six-yard box (checked directly: ~10% of real marker pairs this season
     already overlap, nearest real pair only 1.28 pitch-units apart) that a
     plain click would often land on the wrong one. stroke-width bump on
     selection (double the resting 0.4) mirrors passes' own selected-line
     treatment proportionally. The line stays visible permanently while
     locked (not just on hover) — the other half of "we need to see the
     line... shown permanently when we move into the clicked state". */
  .event-shot-group.event-shot-selected .event-shot-marker { stroke-width: 0.9; }
  .event-shot-group.event-shot-selected .event-shot-line { opacity: 1; }
  /* All other shots hide outright (not just dim) while one is locked, per
     direct instruction ("remove the visual clutter") — a real, deliberate
     escalation from the original 0.15 dim: once a shot is genuinely locked
     (clicks on every other marker are already inert, see wireShotSelection)
     there's no remaining reason to keep them faintly visible, only the
     selected marker and the freeze-frame dots it reveals matter while
     frozen. */
  .event-shot-group.event-shot-hidden { opacity: 0; }
  /* Direction is shown by a per-line opacity gradient (fading in from the
     source to the end point) rather than an arrowhead, per direct
     instruction — the actual stroke colour/opacity comes from a JS-created
     <linearGradient> (see drawPasses in js/renderers-shotmap.js), not a
     flat CSS colour here, since a CSS stroke declaration on this class would
     otherwise win over the element's own stroke="url(#...)" attribute and
     silently hide the gradient. Complete/incomplete only still control the
     gradient's base hue (green vs rose), set via JS. */
  .event-pass-line { stroke-width: 0.25; stroke-linecap: round; cursor: pointer; opacity: 1; transition: opacity 0.15s ease; }
  /* Ball marker, per direct instruction — only exists while a pass is
     actually selected (built/removed by wireMagneticPassHover's own
     setSelected in js/renderers-shotmap.js, not drawn for every completed
     pass up front), and only for completed passes. Diameter matches the
     line's own thicker *selected* stroke-width (0.6 below, radius 0.3 set
     in JS) rather than its 0.25 resting width, per direct instruction — the
     ball only ever exists while its line is already selected/thickened, so
     it should match the size actually on screen at that moment. */
  .event-pass-ball { fill: #fff; }
  /* Selection is computed in JS (wireMagneticPassHover in
     js/renderers-shotmap.js) from real distance-to-cursor, not native
     :hover — a plain :hover rule would still be driven by the browser's own
     unreliable hit-test against a 0.25-unit-wide stroke, the exact problem
     this system exists to route around. */
  .event-pass-line.event-pass-selected { stroke-width: 0.6; }
  /* All other passes dim to 0.1 while one is selected, per direct
     instruction. */
  .event-pass-line.event-pass-dimmed { opacity: 0.1; }
  /* pointer-events was none until each dot needed its own real hover
     tooltip (player identity) and a click-guard (wireShotSelection in
     js/renderers-shotmap.js) — both need real hit-testing on these now, not
     none, since these dots are decorative no longer, they're the whole
     point of a persistent click-locked selection. cursor:default (not
     pointer) since hovering identifies a player, it doesn't do anything
     when clicked. */
  .freeze-frame-dot { pointer-events: auto; cursor: default; }
  /* Top-down player shape (js/renderers-shotmap.js's freeze-frame markup) —
     a capsule "shoulders" bar coloured teammate/opponent (the same real
     distinction the old plain dot's own fill already made) behind a plain
     white "head" — no stroke outline on either shape, per direct
     instruction/reference image, the fill colours alone define the
     silhouette. Teammate is red per direct instruction — McCabe's real
     team, Arsenal WFC, plays in red — with the opposition light blue
     (corrected back from an initial darker navy per direct follow-up). */
  .freeze-frame-dot.teammate .freeze-frame-body { fill: rgba(226, 88, 107, 0.9); }
  .freeze-frame-dot.opponent .freeze-frame-body { fill: rgba(77, 139, 245, 0.9); }
  /* Real goalkeepers wear a real, visibly different kit colour regardless
     of side, per direct instruction — overrides teammate/opponent's own
     blue/red above via source order (same specificity, .goalkeeper is
     simply declared later), not a fight won on specificity. */
  .freeze-frame-dot.goalkeeper .freeze-frame-body { fill: #2ecc71; }
  .freeze-frame-head { fill: #fff; }
  /* Shot Placement mini chart (renderShotPlacementChart, js/renderers-
     shotmap.js) — a real, spatial complement to the xG/OBV numbers in Shot
     Details, plotting StatsBomb's own real shot.end_location (where the
     ball actually crossed the goal line, or was blocked/went out) against
     an actual-size real goal frame (8 real yards wide, ~2.67 real yards —
     8ft — tall). The "beautiful and clever" spatial counterpart to Pass
     Map's own OBV bar chart, for a genuinely different real metric. */
  /* aspect-ratio matches the real 3:1 goal shape (see GOAL_CHART_VB_W/H,
     js/renderers-shotmap.js) rather than an arbitrary box. overflow:visible
     is load-bearing, not a default — the shot dot's own cy is deliberately
     allowed to go negative for a real shot that clears the chart's own
     height domain (z is only floor-clamped, never ceiling-clamped, per
     direct instruction: "if the player has a shot which goes high over the
     bar, just place the ball on top of the text above it"), and an SVG
     clips to its own viewBox by default — this rule is what lets that ball
     actually paint outside its box and over the "Shot Placement" label
     sitting right above it in the DOM, rather than getting silently cut off
     at the chart's own top edge. */
  .shot-placement-svg { display: block; width: 100%; height: auto; aspect-ratio: 3 / 1; margin-top: 2px; overflow: visible; }
  .shot-placement-frame-fill { fill: rgba(255,255,255,0.04); }
  .shot-placement-frame-line { stroke: rgba(255,255,255,0.45); stroke-width: 1; fill: none; }
  .shot-placement-ground { stroke: rgba(255,255,255,0.2); stroke-width: 1; }
  .shot-placement-dot { stroke: rgba(0,0,0,0.4); stroke-width: 0.5; }
  /* Real goalkeeper silhouette (js/renderers-shotmap.js's goalkeeperSilhouetteSvg),
     positioned per shot at that shot's own real opponent-goalkeeper
     freeze_frame location (see findOpponentGoalkeeper/renderShotPlacementChart's
     own comments) — white at 10% opacity, per direct instruction (corrected
     down from an initial 25%, dimmer since a real per-shot position sits
     right where the shot's own dot/trajectory naturally draws the eye and
     shouldn't compete with it). Painted right after the net fill and before
     the post/bar lines, so the goal frame itself still reads crisply on top
     of the figure. Opacity on the nested <svg> itself, not each individual
     <path> — the source SVG's 9 paths overlap each other in places (a real
     trait of this asset, not a bug), and per-path opacity would visibly
     double up wherever they do; one shared opacity on the group keeps the
     whole silhouette flat and uniform regardless of its own internal
     overlaps. */
  .shot-placement-keeper { opacity: 0.1; }
  .shot-placement-keeper path { fill: #fff; }
  .event-table .col-num { flex: 0 0 44px; }

  .filter-bar { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; align-items: center; }
  .filter-chip {
    display: flex; align-items: center; gap: 6px;
    background: rgba(46, 204, 113, 0.1); border: 1px solid rgba(46, 204, 113, 0.4);
    color: #2ecc71; font-size: 0.68rem; padding: 5px 6px 5px 10px; border-radius: 14px;
  }
  .filter-chip .remove {
    cursor: pointer; width: 14px; height: 14px; border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    background: rgba(46, 204, 113, 0.18); font-size: 0.6rem; line-height: 1;
  }
  .chip-value {
    cursor: pointer;
    border-bottom: 1px dashed rgba(46, 204, 113, 0.5);
  }
  .chip-value:hover { color: #fff; border-bottom-color: #fff; }

  /* Value picker popover — same search-to-pick shape as the app's player picker */
  .value-picker {
    position: absolute; z-index: 100;
    background: #1e1e1e; border: 1px solid var(--border-color); border-radius: 8px;
    box-shadow: 0 10px 24px rgba(0,0,0,0.5); min-width: 200px; overflow: hidden;
  }
  .value-picker-search {
    width: 100%; box-sizing: border-box; background: #141414; border: none;
    border-bottom: 1px solid var(--border-color); color: var(--text-secondary);
    font-family: inherit; font-size: 0.72rem; padding: 8px 10px; outline: none;
  }
  .value-picker-list { max-height: 200px; overflow-y: auto; }
  .value-picker-option {
    display: flex; align-items: center; gap: 8px;
    padding: 7px 10px; font-size: 0.72rem; color: var(--text-secondary); cursor: default;
  }
  .value-picker-option:hover { background: rgba(46, 204, 113, 0.12); color: #fff; }
  .value-picker-option.selected { color: #2ecc71; }
  .value-picker-option.excluded { color: #e74c3c; }
  .value-picker-option-label { flex: 1; cursor: pointer; }
  /* The reverse-filter icon — "exclude this one option, keep everything
     else" — a small always-visible, low-opacity red ring+X that brightens on
     hover, the same discoverability convention as a filter chip's own
     always-visible .remove ✕. One common pattern reused by every dropdown
     value-picker on the site (window.StatsFilterKit's Position/League/Club/
     Nationality/Foot chips and window.EventMapKit's Shot/Pass Map/Heat Map
     event chips alike), not a one-off control. */
  .value-picker-exclude-btn {
    flex: 0 0 auto; width: 18px; height: 18px; border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    background: none; border: none; padding: 0; cursor: pointer;
    color: rgba(231, 76, 60, 0.5); opacity: 0.55; transition: opacity 0.12s, color 0.12s, background 0.12s;
  }
  .value-picker-exclude-btn svg { width: 12px; height: 12px; display: block; }
  .value-picker-option:hover .value-picker-exclude-btn { opacity: 0.85; }
  .value-picker-exclude-btn:hover { opacity: 1; color: #e74c3c; background: rgba(231, 76, 60, 0.16); }
  .value-picker-exclude-btn.active { opacity: 1; color: #e74c3c; background: rgba(231, 76, 60, 0.16); }
  .value-picker-empty { padding: 10px; font-size: 0.72rem; color: var(--text-dim); text-align: center; }

  /* Draggable dual-handle range popover, shared by the Age/Height/Weight
     compare-against filters — values are relative to the selected player
     (negative = younger/shorter/lighter, positive = the opposite), so 0
     sits at the exact centre of the track rather than being a special case. */
  .filter-range-picker {
    position: absolute; z-index: 100;
    background: #1e1e1e; border: 1px solid var(--border-color); border-radius: 8px;
    box-shadow: 0 10px 24px rgba(0,0,0,0.5); width: 220px; padding: 14px 16px 12px;
  }
  .filter-range-readout {
    font-size: 0.72rem; color: var(--text-secondary); text-align: center;
    margin-bottom: 16px; font-variant-numeric: tabular-nums;
  }
  .filter-range-track-wrap { position: relative; height: 20px; margin: 0 8px; }
  .filter-range-track {
    position: absolute; top: 50%; left: 0; right: 0; height: 4px; margin-top: -2px;
    background: rgba(255, 255, 255, 0.1); border-radius: 2px;
  }
  .filter-range-fill {
    position: absolute; top: 50%; height: 4px; margin-top: -2px;
    background: #2ecc71; border-radius: 2px;
  }
  .filter-range-zero-tick {
    position: absolute; top: 50%; left: 50%; width: 1px; height: 8px; margin-top: -4px;
    background: var(--text-dim);
  }
  .filter-range-handle {
    position: absolute; top: 50%; width: 14px; height: 14px; margin-top: -7px; margin-left: -7px;
    border-radius: 50%; background: #2ecc71; border: 2px solid #141414;
    box-shadow: 0 1px 3px rgba(0,0,0,0.4); cursor: grab; touch-action: none;
  }
  .filter-range-handle:active { cursor: grabbing; }
  .filter-range-labels {
    display: flex; justify-content: space-between; font-size: 0.6rem; color: var(--text-dim); margin-top: 8px;
  }

  /* Radar chart — single-player and overlay (drag-to-merge) modes share
     this exact same SVG. Grid/spokes/labels are neutral chrome; the data
     polygon(s) and vertex dots reuse the same 5-colour palette (and letter
     classes) already established for Stats' comparison mode, so a colour
     means the same thing everywhere in the app. Ring shading, the solid
     single-player fill, and the companion Min/Value/Max table were all
     matched against real screenshots of Catalyst's own Radar page (pulled
     from the project's navigation-audit background reading) rather than
     invented from scratch, though the chart-only skeleton originally was —
     see CLAUDE.md for the comparison. */
  /* Missing this was the real bug behind the chart rendering tiny: .stats-wrap
     and .compare-wrap (which overlay mode reuses) both already have this —
     .module-body's align-items:center means a child with no explicit width
     shrinks to fit its content instead of spanning the module, so the SVG's
     90% below was 90% of a collapsed box, not 90% of the actual module. */
  .radar-wrap { width: 100%; }
  /* The chart is the star — pinned to a fixed 90% of the module's width
     (not a max-width ceiling, a percentage, so it holds at 90% whether the
     module is resized bigger or smaller) with the numbers stacked below as
     secondary reference material rather than competing for horizontal
     space beside it. Pure flexbox column, no ResizeObserver/JS — the SVG's
     own viewBox+percentage sizing rescales the chart at any module width,
     and the table wraps its own overflow independently if the module gets
     narrow. */
  .radar-body-layout { display: flex; flex-direction: column; align-items: center; gap: 14px; width: 100%; }
  .radar-svg-wrap { width: 100%; display: flex; justify-content: center; }
  /* .radar-chart-inner is the 90%-wide box the SVG and its label overlay
     both size themselves against — .radar-svg at 100% of *this*, not of
     the full-width wrap, is what actually holds the chart at 90% of the
     module (see .radar-svg-wrap above being full width so it can still
     centre a narrower child). */
  .radar-chart-inner { position: relative; width: 90%; }
  .radar-svg { width: 100%; height: auto; display: block; overflow: visible; }
  /* Axis labels are plain HTML positioned by percentage over this same
     box, not SVG <text> — see labelTransformPct's comment in
     renderers-radar.js for why: SVG viewBox scaling would otherwise scale
     the label font-size right along with the chart itself, which is
     explicitly NOT wanted — the labels must stay a small, fixed, readable
     size no matter how big the chart gets. pointer-events:none so the
     (non-interactive) label layer never blocks hovering the chart/dots
     underneath it. */
  .radar-labels-overlay { position: absolute; inset: 0; pointer-events: none; }
  .radar-axis-label {
    position: absolute; font-size: 12px; font-weight: 500; line-height: 1.15;
    color: var(--text-secondary); white-space: nowrap; text-align: center;
    transform-origin: center;
    /* .radar-labels-overlay is pointer-events:none so it never blocks
       hovering the chart/wedges underneath — re-enabled just here so a
       truncated label's full-name tooltip (data-tooltip, set only when
       actually truncated) is still reachable on hover. */
    pointer-events: auto;
  }
  /* Per-axis scale ticks, printed on the chart itself (real Catalyst does
     this — see the comment on TICK_FRACS in renderers-radar.js) — always
     horizontal (unlike the rotated category labels above), small and
     muted enough that 6-per-axis doesn't read as clutter over the shape
     itself. */
  .radar-tick-label {
    position: absolute; font-size: 9px; font-weight: 500; color: #b6bdcd;
    white-space: nowrap; text-align: center; font-variant-numeric: tabular-nums;
    transition: color 0.15s ease;
  }
  /* Brightens only for the axis currently hovered (direct wedge hover or
     the matching table row — see setAxisTicksActive in
     renderers-radar.js), not all ticks at once. */
  .radar-tick-label.active { color: #fff; }
  /* Alternating filled bands (not plain outline rings) for the background
     grid, in neutral dark tones — the "dartboard" chrome visible outside
     wherever the data shape doesn't reach. */
  .radar-ring-band.band-a { fill: #1c1e24; }
  .radar-ring-band.band-b { fill: #14151a; }
  /* Real Catalyst's rings stop short of a single converging point — a
     small solid disc caps the centre instead (counted directly off a
     reference screenshot, same as the 10-ring count above). Darker than
     either ring band so it reads as a genuine cutout, not just another
     band. */
  .radar-center-hole { fill: #0a0c10; }
  .radar-spoke { stroke: rgba(255, 255, 255, 0.09); stroke-width: 1; }
  /* Single-player fill is a *second* copy of those same alternating bands,
     in green, clipped to the live data shape (.radar-banded-fill / see
     CHART_SKELETON_HTML's comment in renderers-radar.js) — corrected after
     direct comparison against a Catalyst screenshot showed clearly
     alternating light/dark green bands *inside* the filled area, not a
     flat translucent wash. .radar-polygon itself is only still drawn for
     overlay mode now (fill: none in solo mode — the banded layer covers
     that visually instead), where 2-5 players (a capability real Catalyst
     doesn't have at all) need a flat, low-opacity fill per colour so
     overlaps don't turn to mud. Tried raising this default (0.5, then
     0.35) to give .compare-dimmed something to visibly knock back from —
     reverted per direct instruction: the original 0.2 default was correct
     and not to be touched, the dimmed hover state just needed to go lower
     than it, not the other way around. */
  .radar-banded-ring.band-a { fill: #cfe6cc; }
  .radar-banded-ring.band-b { fill: #5f9670; }
  .radar-polygon { stroke: none; fill-opacity: 0.2; transition: opacity 0.3s ease, fill-opacity 0.2s ease; }
  .radar-polygon.solo { fill: none; }
  .radar-polygon.marker-a { fill: #2ecc71; }
  .radar-polygon.marker-b { fill: #4d8bf5; }
  .radar-polygon.marker-c { fill: #f5a623; }
  .radar-polygon.marker-d { fill: #ff6b81; }
  .radar-polygon.marker-e { fill: #b06df2; }
  /* Hovering a legend chip (see wireRadarLegendHover) makes that player's
     shape look exactly like an individual radar — fill: none hides the
     flat marker colour so the green banded-fill layer underneath (clipped
     to this player's shape for the duration of the hover) is what actually
     shows, same as solo mode's own .solo rule. Every other shape knocks
     back from the normal 20% down further to 8%, so the hovered one still
     reads clearly against the rest without changing what "normal" looks
     like. */
  .radar-polygon.compare-highlighted { fill: none; }
  .radar-polygon.compare-dimmed { fill-opacity: 0.08; }
  /* No vertex dots — real Catalyst has none either (verified against a
     screenshot). Hovering is a per-axis pie-slice instead: invisible until
     hovered, then a light fill appears over that whole segment — the
     "triangular overlay on the segment" real Catalyst shows, reported
     directly after the dots read as too heavy/distracting. .row-hover is
     the identical effect triggered programmatically when the matching
     table row is hovered instead of the wedge itself directly
     (wireRadarTableHover). */
  .radar-wedge { fill: transparent; cursor: default; transition: fill 0.15s ease; }
  .radar-wedge:hover, .radar-wedge.row-hover { fill: rgba(0, 0, 0, 0.28); }

  /* Companion Metric/Min/Value/Max table, matching Catalyst's own
     chart-plus-table layout rather than the chart-only view this module
     started with — but placed below the chart and kept visually quiet
     (small, muted) per direct instruction: the radar shape is the star of
     this module, the table is reference material underneath it, not a
     second focal point beside it. Overlay mode adds one coloured column
     per merged player in place of the single "Value" column. */
  .radar-table-wrap { width: 100%; max-width: 100%; overflow-x: auto; border-top: 1px solid var(--border-color); padding-top: 10px; }
  .radar-table { width: 100%; border-collapse: collapse; font-size: 0.62rem; }
  .radar-table th {
    text-align: center; font-size: 0.56rem; text-transform: uppercase; letter-spacing: 0.04em;
    color: var(--text-dim); padding: 2px 6px; border-bottom: 1px solid var(--border-color); white-space: nowrap;
  }
  /* Metric (first column) stays left-aligned, Max (last column) stays
     right-aligned — every column in between (Min, and each player's value
     column) is centred under its header, per direct instruction. */
  .radar-table th:first-child, .radar-table td:first-child { text-align: left; }
  .radar-table th:last-child, .radar-table td:last-child { text-align: right; }
  .radar-table td {
    padding: 3px 6px; text-align: center; color: var(--text-secondary);
    border-bottom: 1px solid rgba(255, 255, 255, 0.05); font-variant-numeric: tabular-nums; white-space: nowrap;
  }
  .radar-table td.radar-table-metric { text-align: left; color: var(--text-muted); white-space: normal; }
  .radar-table td.radar-table-value { font-weight: 600; color: #eee; }
  .radar-table tbody tr:hover { background: rgba(255, 255, 255, 0.05); cursor: default; }
  .radar-table .marker-a { color: #2ecc71; }
  .radar-table .marker-b { color: #4d8bf5; }
  .radar-table .marker-c { color: #f5a623; }
  .radar-table .marker-d { color: #ff6b81; }
  .radar-table .marker-e { color: #b06df2; }

  /* Distribution view — a second, alternative view of the same real axes
     (js/renderers-radar.js's renderRadarDistribution/distRowHtml), per
     direct instruction with a reference mockup: a real density curve per
     axis (the whole real population's own values, not this one player's),
     with this player's own value marked and everything at-or-below it
     shaded to read roughly as their percentile. Renders into the exact
     same .radar-table-wrap the Metric/Min/Value/Max table normally uses —
     mutually exclusive with it, not a third panel alongside the wheel. */
  .radar-dist-header { display: flex; align-items: baseline; gap: 6px; margin-bottom: 8px; white-space: nowrap; }
  .radar-dist-header-title { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-secondary); }
  .radar-dist-header-sub { font-size: 0.64rem; color: var(--text-muted); }
  /* Column headings ("Value"/"%ile") — real per-column labels a viewer
     asked for directly, reusing the exact same flex-basis widths as a real
     data row (.radar-dist-label/.radar-dist-value/.radar-dist-pctile) so
     the headings line up with the columns below them without a second,
     separately-maintained layout. */
  .radar-dist-row.radar-dist-col-headers {
    padding-top: 0; padding-bottom: 4px; border-bottom: 1px solid var(--border-color);
    font-size: 0.6rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-dim);
  }
  .radar-dist-col-headers .radar-dist-value, .radar-dist-col-headers .radar-dist-pctile {
    font-size: 0.6rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-dim);
  }
  .radar-dist-rows { display: flex; flex-direction: column; gap: 16px; }
  .radar-dist-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
  .radar-dist-row:last-child { border-bottom: none; }
  /* white-space: normal (not truncated), same as .radar-table-metric — these
     are the full real catalog names (e.g. "PAdj Tackles & Interceptions"),
     genuinely longer than Stats' own shortNames elsewhere in this app. */
  /* Widened and capped to a single line (text itself truncated to 16 real
     characters in JS, same AXIS_LABEL_MAX_LEN/truncateLabel the wheel's own
     outer axis labels already use, full name on hover when actually cut)
     per direct feedback with a screenshot — wrapping onto two lines read
     worse than a consistent single-line truncation at this width. */
  .radar-dist-label { flex: 0 0 132px; font-size: 0.62rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; }
  .radar-dist-chart-wrap { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
  /* Real bug fixed, reported directly with a screenshot ("these triangles
     are still too wide... they should be equilateral"): .radar-dist-svg has
     a *fixed* height (28px) but a *responsive* width (100% of the module),
     so it scales x and y independently — any shape drawn inside it in
     viewBox units (however carefully computed to be equilateral in *those*
     units) necessarily renders distorted, and the distortion isn't even
     constant (it changes every time the module is resized). Fixed by
     taking the marker out of the SVG entirely — .radar-dist-svg-wrap is the
     new positioning context for a real HTML/CSS triangle
     (.radar-dist-marker-tri below) that sits *beside* the SVG, not inside
     it, so it can never inherit that non-uniform stretch. Same lesson this
     file's own axis/tick labels already learned once for the exact same
     underlying reason. */
  .radar-dist-svg-wrap { position: relative; }
  .radar-dist-svg { width: 100%; height: 28px; display: block; overflow: visible; }
  .radar-dist-baseline { stroke: rgba(255,255,255,0.12); stroke-width: 0.6; }
  /* Real reversal, per direct instruction, after a colour-coded good/bad
     fill (tried and shipped briefly) turned out to make a genuinely bad
     player's row render *mostly green* whenever "good" happened to be most
     of the curve — exactly backwards from what a colour cue should say at
     a glance. Back to a neutral, non-judged split: "below" vs "above" the
     player's own value, a plain population fact, not a verdict — two real
     shades of grey rather than a colour that implies good/bad/average
     (green/orange/red), per direct instruction. The marker dot (still
     green/orange, matching the percentile number beside it) stays the one
     place in this row allowed to say "good" or "bad"; the curve itself
     doesn't try to. */
  .radar-dist-area-below { fill: rgba(255,255,255,0.28); }
  .radar-dist-area-above { fill: rgba(255,255,255,0.12); }
  /* A genuine CSS border-triangle — equilateral in real screen pixels
     (side 6px, height 3·√3 ≈ 5.2px, the same real equilateral relationship
     the old SVG path tried and failed to preserve through a non-uniform
     stretch), pointing down (border-top solid, left/right transparent, no
     border-bottom) so its apex — positioned via left/top, both percentages
     of .radar-dist-svg-wrap's own real box — touches the curve exactly the
     way the old SVG path's own tip did. margin-top lifts it a couple of
     pixels clear of the curve line, same small gap the old "-1" viewBox-unit
     offset gave it. */
  /* Real bug fixed, reported directly with a screenshot ("it should surely
     appear directly above the point on the graph where green turns to
     grey"): left/top position this box's own top-left corner, but with
     this border combination (border-top solid, left/right transparent, no
     bottom) the box's flat/wide edge sits at its own top-left corner and
     the actual *apex* — the point that's supposed to touch the curve — is
     at the box's bottom-*center* instead, 3px right and ~5px below that
     corner. transform: translate(-50%, -100%) shifts the box by its own
     rendered size (not a hardcoded pixel guess, so it stays correct if
     half/h above are ever retuned) so the apex, not the corner, lands
     exactly on left/top's real anchor point — the extra -2px keeps the
     same small clearance above the curve the original SVG version had. */
  .radar-dist-marker-tri {
    position: absolute; width: 0; height: 0;
    border-left: 3px solid transparent; border-right: 3px solid transparent;
    border-top: 5.2px solid transparent;
    transform: translate(-50%, calc(-100% - 2px));
    pointer-events: auto;
  }
  .radar-dist-marker-tri.radar-dist-marker-good { border-top-color: #2ecc71; }
  .radar-dist-marker-tri.radar-dist-marker-bad { border-top-color: #f5a623; }
  /* Overlay/comparison mode's own markers — one per merged player, coloured
     by identity (same marker-a..marker-e palette .radar-table/.radar-polygon
     already use) rather than the single-player view's good/bad read, since
     "who's ahead" is the real question here, not "is this good" — see
     renderRadarDistributionOverlay's own comment. */
  .radar-dist-marker-tri.radar-dist-marker-a { border-top-color: #2ecc71; }
  .radar-dist-marker-tri.radar-dist-marker-b { border-top-color: #4d8bf5; }
  .radar-dist-marker-tri.radar-dist-marker-c { border-top-color: #f5a623; }
  .radar-dist-marker-tri.radar-dist-marker-d { border-top-color: #ff6b81; }
  .radar-dist-marker-tri.radar-dist-marker-e { border-top-color: #b06df2; }
  /* Compound selectors (.radar-dist-value.radar-dist-value-a, not a bare
     .radar-dist-value-a) — needed to reliably outrank the base
     .radar-dist-value/.radar-dist-pctile rules' own `color` regardless of
     source order, since both classes land on the same element. */
  .radar-dist-value.radar-dist-value-a, .radar-dist-pctile.radar-dist-pctile-a { color: #2ecc71; }
  .radar-dist-value.radar-dist-value-b, .radar-dist-pctile.radar-dist-pctile-b { color: #4d8bf5; }
  .radar-dist-value.radar-dist-value-c, .radar-dist-pctile.radar-dist-pctile-c { color: #f5a623; }
  .radar-dist-value.radar-dist-value-d, .radar-dist-pctile.radar-dist-pctile-d { color: #ff6b81; }
  .radar-dist-value.radar-dist-value-e, .radar-dist-pctile.radar-dist-pctile-e { color: #b06df2; }
  .radar-dist-ticks { position: relative; height: 11px; }
  .radar-dist-tick {
    position: absolute; top: 0; font-size: 8px; color: #ccc;
    white-space: nowrap; font-variant-numeric: tabular-nums; transform: translateX(-50%);
  }
  .radar-dist-tick:first-child { transform: translateX(0); }
  .radar-dist-tick.radar-dist-tick-end { transform: translateX(-100%); }
  .radar-dist-value { flex: 0 0 42px; text-align: right; font-size: 0.66rem; font-weight: 600; color: #eee; font-variant-numeric: tabular-nums; }
  .radar-dist-pctile { flex: 0 0 26px; text-align: right; font-size: 0.66rem; font-weight: 700; font-variant-numeric: tabular-nums; }
  .radar-dist-pctile-good { color: #2ecc71; }
  .radar-dist-pctile-bad { color: #f5a623; }

  .add-filter-wrap { position: relative; }
  .add-filter-btn {
    background: #1a1a1a; border: 1px dashed var(--border-color); color: var(--text-muted);
    font-family: inherit; font-size: 0.68rem; padding: 5px 12px; border-radius: 14px; cursor: pointer;
  }
  .add-filter-btn:hover { color: var(--text-secondary); border-color: var(--text-muted); }
  .add-filter-menu {
    display: none;
    position: absolute; top: calc(100% + 6px); left: 0; z-index: 10;
    background: #1e1e1e; border: 1px solid var(--border-color); border-radius: 8px;
    box-shadow: 0 10px 24px rgba(0,0,0,0.5); min-width: 170px; overflow: hidden;
  }
  .add-filter-menu.open { display: block; }
  .add-filter-option {
    padding: 8px 12px; font-size: 0.72rem; color: var(--text-secondary); cursor: pointer;
  }
  .add-filter-option:hover { background: rgba(46, 204, 113, 0.1); color: #fff; }
  .add-filter-option.disabled { color: var(--text-dim); cursor: default; pointer-events: none; }

  /* Shared by Stats/Ranking/Filter (all three use this exact class) — each
     renderer now builds this as its own fixed row between
     .module-sub-controls and .module-body (a real DOM sibling of both, see
     e.g. js/renderers-stats.js), not as something built inside the
     scrollable wrap — per direct instruction that it's part of the module's
     header chrome, not scrolling content that merely looks pinned. */
  .population-readout {
    display: flex; align-items: baseline; gap: 8px;
    padding: 10px 12px; margin: 10px 15px 0;
    background: rgba(255,255,255,0.03); border: 1px solid var(--border-subtle); border-radius: 8px;
  }
  .population-count { font-size: 1.3rem; font-weight: 700; color: var(--text-color); font-variant-numeric: tabular-nums; transition: color 0.2s ease; }
  .population-label { font-size: 0.7rem; color: var(--text-muted); }
  /* Also covers Action Map's own .population-readout, which nests a plain
     .event-stat-value/-label pair (Shot/Pass/Heat Map's own stacked-stat
     block) instead of .population-count/.population-label directly — a
     real bug once had it carrying both class pairs on the same span "so
     this rule would still apply," which instead lost a specificity tie to
     .population-count's own larger font-size and rendered visibly taller
     than every other module's identical-looking stat box. Listing both
     selectors here, rather than re-adding .population-count to Action
     Map's markup, is what lets that element carry only the one real class
     pair its content actually matches. */
  .population-readout.zero .population-count,
  .population-readout.zero .event-stat-value { color: #f39c12; }

  /* Percentile bars. Range fill + a distinct marker tick both encode where
     this player sits against the current filtered population, never
     colour-coded good/bad — no metric carries polarity metadata. */
  .pbar-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; }
  /* Fixed-width outer box (same 108px every row already reserved for the
     label alone) so every row's bar still starts at the same x regardless
     of label length — .pbar-label itself now only holds the truncating
     text, .pbar-info-icon is a non-shrinking sibling so a long metric name
     can never truncate the icon away along with itself. */
  .pbar-label-wrap { flex: 0 0 108px; display: flex; align-items: center; gap: 3px; min-width: 0; }
  .pbar-label {
    flex: 1 1 auto; min-width: 0; font-size: 0.68rem; color: var(--text-muted);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  /* Real per-metric definition (window.CatalystData's own captured
     `definition` field, one for all 162 real metrics — see CLAUDE.md) as a
     plain hover tooltip, per direct instruction to append it onto the end
     of every stat name rather than a separate module or icon cluster.
     Plugs into the app's existing generic [data-tooltip] delegation
     (js/tooltip.js) — no bespoke hover wiring needed. */
  .pbar-info-icon {
    flex: 0 0 auto; font-size: 0.62rem; line-height: 1; color: var(--text-muted);
    cursor: default; transition: color 0.15s ease;
  }
  .pbar-info-icon:hover { color: #e4e6ec; }
  .pbar-track {
    flex: 1; height: 7px; background: rgba(255,255,255,0.07);
    border-radius: 4px; position: relative;
    /* No overflow:hidden — the dot marker (11px) is deliberately taller
       than the track (7px) so it reads as a clean circle floating on the
       track, the way a slider thumb does. Clipping it to the track's own
       height squashed it into a stretched blob instead of a circle.
       .pbar-range already rounds its own corners, so nothing relied on the
       track doing that clipping. */
  }
  .pbar-range {
    position: absolute; height: 100%; min-width: 3px; background: rgba(46, 204, 113, 0.25); border-radius: 4px;
    transition: left 0.35s cubic-bezier(0.4,0,0.2,1), width 0.35s cubic-bezier(0.4,0,0.2,1), opacity 0.32s ease;
  }
  .pbar-marker {
    position: absolute; top: 50%; width: 8px; height: 8px; margin-top: -4px; margin-left: -4px;
    background: #2ecc71; border-radius: 50%; box-shadow: 0 0 0 2px #1a1a1a;
    transition: left 0.35s cubic-bezier(0.4,0,0.2,1), opacity 0.32s ease, transform 0.15s ease;
  }
  .pbar-marker:hover { transform: scale(1.2); }
  .pbar-value { flex: 0 0 44px; text-align: right; font-size: 0.68rem; color: var(--text-secondary); font-variant-numeric: tabular-nums; }

  /* Drag this onto the canvas to open a Ranking module for this one metric
     (js/renderers-rankings.js) — single-player mode only, never rendered in
     comparison mode's row template, since there's no single "this metric,
     this player" value once players are merged. */
  .pbar-rank-icon {
    flex: 0 0 auto; width: 18px; height: 18px; display: flex; align-items: center; justify-content: center;
    color: var(--text-muted); opacity: 0.5; border-radius: 3px; cursor: grab;
    transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
  }
  .pbar-rank-icon:hover { opacity: 1; color: var(--text-secondary); background: rgba(255,255,255,0.08); }
  .pbar-rank-icon:active { cursor: grabbing; }

  /* Player-vs-player comparison mode: up to 5 named players, a colour-
     matched dot marker per player on each bar (not a picker-generic line —
     this is specifically "player A's dot", "player B's dot", etc.
     head-to-head). Player A keeps the house green; the rest are distinct
     from each other and from the electric blue used for the merge
     drag-feedback snap indicator (a different signal: "this will merge"
     during drag vs. "this is one of the compared players" once merged). */
  .pbar-marker.marker-b { background: #4d8bf5; }
  .pbar-marker.marker-c { background: #f5a623; }
  .pbar-marker.marker-d { background: #ff6b81; }
  .pbar-marker.marker-e { background: #b06df2; }
  /* Each player gets its own fixed-width column, independently right-
     aligned — packing them tight at flex-end let a wide value (e.g.
     "77.7%") push its whole pair around row to row, so the two columns
     never lined up and read as one messy cluster instead of two. */
  .pbar-value-compare { flex: 0 0 92px; display: flex; gap: 8px; }
  .compare-value-a, .compare-value-b, .compare-value-c, .compare-value-d, .compare-value-e {
    flex: 0 0 42px; text-align: right; font-size: 0.68rem; font-variant-numeric: tabular-nums;
  }
  /* Matches whichever colour each player was actually assigned (see
     mod._mergedColors) — the value column can land on any two of these
     five once a 3+-player group has been trimmed back down to 2, not just
     a/b, so all five need a colour, not just the first two. */
  .compare-value-a { color: #2ecc71; }
  .compare-value-b { color: #4d8bf5; }
  .compare-value-c { color: #f5a623; }
  .compare-value-d { color: #ff6b81; }
  .compare-value-e { color: #b06df2; }

  .compare-wrap { width: 100%; }
  .compare-legend { display: flex; flex-wrap: wrap; gap: 18px; margin-bottom: 16px; }
  .compare-chip {
    display: flex; align-items: center; gap: 8px;
    font-size: 0.85rem; font-weight: 500; color: var(--text-color);
  }
  .compare-dot { width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0; }
  .compare-chip-a .compare-dot { background: #2ecc71; }
  .compare-chip-b .compare-dot { background: #4d8bf5; }
  .compare-chip-c .compare-dot { background: #f5a623; }
  .compare-chip-d .compare-dot { background: #ff6b81; }
  .compare-chip-e .compare-dot { background: #b06df2; }
  .compare-chip-season { font-weight: 400; font-size: 0.75rem; color: var(--text-muted); }
  .compare-remove {
    cursor: pointer; width: 14px; height: 14px; border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    background: rgba(255,255,255,0.08); font-size: 0.6rem; line-height: 1; color: var(--text-muted);
    opacity: 0.4;
  }
  .compare-chip:hover .compare-remove { opacity: 1; }
  .compare-remove:hover { background: rgba(255,255,255,0.16); color: #fff; }

  /* Ranking module (js/renderers-rankings.js) — every player in the
     database ranked by one metric, opened by dragging .pbar-rank-icon off a
     Stats row. .ranking-wrap needs width:100% for the same reason
     .radar-wrap and .stats-wrap already do: .module-body's
     align-items:center shrinks any child with no explicit width to fit its
     own content instead of spanning the module, a real bug caught once
     already on Radar — baked in from the start here rather than waiting to
     rediscover it. The metric/season pickers themselves live in the
     module's shared .module-sub-controls row (see renderRankingModule),
     not in any Ranking-specific header — per direct instruction that a
     Ranking module and its source Stats module should line up perfectly,
     which just reusing Stats'/Radar's own row achieves for free rather than
     fighting to match its padding/height by hand. The category name
     ("Attacking") isn't repeated here at all — it already reads in the
     module's own title bar (.module-title-prefix) once, so showing it a
     second time here was pure duplication. */
  .ranking-wrap { width: 100%; }
  /* The metric picker (js/renderers-rankings.js's renderRankingMetricSelect)
     lists every metric in the module's own fixed category — this module is
     a lens on one category, switchable by stat, not a fixed single-metric
     view. Takes the flexible space in .module-sub-controls the same way
     Stats' player-search box does; the season select stays fixed-width
     after it, same convention as every other module's sub-controls row. */
  .ranking-metric-select-wrap { flex: 1 1 auto; min-width: 0; }
  .ranking-metric-select { width: 100%; max-width: 100%; }
  .ranking-season-select-wrap { flex-shrink: 0; }
  .ranking-season-select { min-width: 72px; }
  .ranking-list { display: flex; flex-direction: column; width: 100%; }
  /* No highlighted "your player" row and nothing scrolls to one, per direct
     instruction — a Ranking module isn't linked to any one player, it's a
     plain top-to-bottom leaderboard, so every row is styled identically. */
  /* Draggable onto empty canvas to open a Player Bio module for that exact
     player (js/renderers-rankings.js), or onto an existing Radar/Stats
     module to merge into its comparison (js/renderers-filter.js's own
     results list rows do the exact same thing) — cursor:grab needs to beat
     .module-body *'s own cursor:default (same specificity, later in the
     stylesheet wins), and has to cover the row's own child spans too since
     an element's explicit cursor always wins over inherited grab from its
     parent, not just the row itself. Covers both .ranking-list (Ranking's
     own leaderboard) and .filter-results-list (Filter's results, which
     reuses the same .ranking-row markup) since both are real drag sources
     now. */
  .ranking-list .ranking-row, .ranking-list .ranking-row *,
  .filter-results-list .ranking-row, .filter-results-list .ranking-row * { cursor: grab; }
  .ranking-list .ranking-row:hover, .filter-results-list .ranking-row:hover { background: rgba(255,255,255,0.06); }
  /* Find-a-player search, sitting to the left of the filter section
     (js/renderers-rankings.js) — same dark/border/shadow recipe as the
     player picker's own .player-search-results/.player-search-option,
     duplicated under its own class names rather than reused since this is a
     conceptually different widget (jumps to a row already in the current
     leaderboard, doesn't select a module's active player). */
  .ranking-player-search-wrap { position: relative; flex: 1 1 160px; min-width: 120px; }
  .ranking-player-search {
    width: 100%; box-sizing: border-box; font-size: var(--t-body); color: #aaa;
    background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 3px 22px 3px 8px;
    outline: none; font-family: inherit;
  }
  .ranking-player-search:focus { border-color: #555; color: #ddd; }
  .ranking-player-search::placeholder { color: #666; }
  /* Shown only while a search jump is active (js/renderers-rankings.js) —
     lets the jumped-to player's name stay visible in the box (rather than
     clearing itself right after the jump, per direct instruction) with an
     explicit way to dismiss it and return to a plain top-of-list view. */
  .ranking-search-clear {
    position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
    width: 16px; height: 16px; line-height: 14px; text-align: center;
    background: none; border: none; color: #888; font-size: 15px; cursor: pointer;
    padding: 0; border-radius: 3px;
  }
  .ranking-search-clear:hover { color: #fff; background: rgba(255,255,255,0.08); }
  .ranking-player-search-results {
    display: none; position: absolute; z-index: 500; top: calc(100% + 4px); left: 0; right: 0;
    background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 6px;
    box-shadow: 0 8px 24px rgba(0,0,0,0.65); max-height: 220px; overflow-y: auto;
  }
  .ranking-player-search-results.open { display: block; }
  .ranking-player-search-option {
    display: flex; align-items: center; justify-content: space-between; gap: 8px;
    padding: 7px 12px; font-size: var(--t-data); color: #999; cursor: pointer; white-space: nowrap;
  }
  .ranking-player-search-option:hover { background: #2a2a2a; color: #ddd; }
  .ranking-player-search-option .rank-hint { color: var(--text-dim); font-variant-numeric: tabular-nums; flex-shrink: 0; }
  .ranking-player-search-empty { padding: 10px 12px; font-size: var(--t-data); color: var(--text-dim); text-align: center; }
  /* Persistent highlight on whichever row a search jump last landed on —
     the list has no other selection state (per direct instruction
     elsewhere, this leaderboard doesn't highlight or auto-scroll to any one
     player by default), so this is the only visual marker of "that's the
     one you searched for." Stays until the next jump moves it (js/
     renderers-rankings.js removes it from the previous row when a new one
     is picked) rather than fading out on its own. */
  .ranking-row.ranking-row-jumped { background: rgba(46, 204, 113, 0.35); }
  .ranking-row { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-radius: 4px; }
  .ranking-rank { flex: 0 0 22px; text-align: right; font-variant-numeric: tabular-nums; font-size: 0.68rem; color: var(--text-muted); }
  .ranking-player { flex: 1; min-width: 0; display: flex; flex-direction: column; }
  .ranking-player-name { font-size: 0.74rem; color: var(--text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .ranking-player-meta { font-size: 0.62rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .ranking-value { flex: 0 0 52px; text-align: right; font-variant-numeric: tabular-nums; font-size: 0.72rem; color: var(--text-secondary); }

  /* Filter module (js/renderers-filter.js) — a real slider (0 to the real
     population max) for every one of the ~162 metrics, grouped by category/
     subcategory exactly like Stats' own rows, stacked with the same bio-
     attribute filter chips Stats/Ranking already use. .filter-wrap needs
     width:100% for the same recurring reason .stats-wrap/.ranking-wrap/
     .radar-wrap all do (.module-body's align-items:center shrinks an
     unsized child to its own content instead of spanning the module). */
  .filter-wrap { width: 100%; }
  .filter-reset-all-btn { margin-left: 6px; }
  .filter-body-layout { display: flex; flex-wrap: wrap; gap: 16px; width: 100%; align-items: flex-start; }
  .filter-sliders-pane { flex: 2 1 340px; min-width: 0; }
  .filter-results-pane { flex: 1 1 220px; min-width: 0; }
  .filter-results-pane .ranking-row { padding: 5px 6px; }
  /* Results are paginated 50 at a time (js/renderers-filter.js) rather than
     ever rendering the whole matched population — a real filtered set
     routinely spans hundreds to ~1,100 players. Prev/Next + a plain
     "51–100 of 1,142" readout rather than numbered page buttons — with
     unfiltered populations running into the thousands as more rosters get
     captured, a numbered pager would itself need to scroll/truncate; a
     range readout scales to any size for free and reads as a single
     unbroken idea rather than a row of controls. */
  .filter-results-pager {
    display: flex; align-items: center; justify-content: center; gap: 10px;
    margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-subtle);
  }
  .filter-page-btn {
    background: none; border: none; font-family: inherit; line-height: 1;
    font-size: 0.95rem; color: var(--text-muted); cursor: pointer;
    padding: 3px 8px; border-radius: 4px;
  }
  .filter-page-btn:hover:not(:disabled) { color: #fff; background: rgba(255,255,255,0.08); }
  .filter-page-btn:disabled { color: var(--text-dim); opacity: 0.4; cursor: default; }
  .filter-page-readout {
    font-size: 0.64rem; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap;
  }
  .filter-category-group + .filter-category-group {
    margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border-color);
  }
  /* Every category defaults to collapsed (js/renderers-filter.js's
     ensureCollapseDefaults) — 5 plain headers read far better than a wall of
     162 sliders, and any category is still one click away. Collapsing is a
     pure display toggle: mod._filterMetricRanges (and therefore whatever a
     hidden category's sliders are still narrowed to) is untouched either
     way, so a collapsed, actively-filtered category keeps constraining the
     population exactly as if it were showing. */
  .filter-category-label {
    display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none;
    font-size: 0.8rem; font-weight: 600; color: var(--text-color); margin-bottom: 6px;
  }
  .filter-category-chevron {
    display: inline-block; font-size: 0.62rem; color: var(--text-muted);
    transition: transform 0.15s ease;
  }
  .filter-category-group.collapsed .filter-category-chevron { transform: rotate(-90deg); }
  .filter-category-group.collapsed .stat-group { display: none; }
  .filter-metric-row { padding: 6px 0; }
  .filter-metric-row-top { display: flex; align-items: baseline; gap: 6px; }
  .filter-metric-label {
    flex: 1; min-width: 0; font-size: 0.68rem; color: var(--text-muted);
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .filter-metric-readout {
    flex: 0 0 auto; font-size: 0.62rem; color: var(--text-secondary); font-variant-numeric: tabular-nums;
  }
  .filter-metric-reset {
    flex: 0 0 auto; display: none; align-items: center; justify-content: center;
    width: 14px; height: 14px; border-radius: 50%; cursor: pointer;
    background: rgba(231, 76, 60, 0.18); color: #e74c3c; font-size: 0.55rem; line-height: 1;
  }
  .filter-metric-reset:hover { background: rgba(231, 76, 60, 0.38); color: #fff; }
  .filter-metric-row .filter-range-track-wrap { height: 16px; margin: 4px 7px 0; }
  /* Deliberately its own look, not the shared green-dot idiom used
     everywhere else a handle/marker appears (percentile bar markers, the
     Age/Height/Weight range-picker's own handles — still used as-is for
     this module's own bio-attribute chips, since that's a shared, pre-
     existing popover reused elsewhere too). These 162 sliders are range
     controls, not data points, and green circles read as though they meant
     the same kind of information as everything else's green dots. Scoped
     to .filter-metric-row specifically so nothing outside this module's own
     per-metric rows is affected. Neutral grey capsule handles (no green, no
     circles) + a plain light-grey fill instead. */
  .filter-metric-row .filter-range-track { background: rgba(255,255,255,0.14); }
  .filter-metric-row .filter-range-fill { background: rgba(255,255,255,0.3); }
  .filter-metric-row .filter-range-handle {
    width: 7px; height: 16px; margin-top: -8px; margin-left: -3.5px;
    border-radius: 2px; background: #cfcfcf; border: 1px solid rgba(0,0,0,0.55);
    box-shadow: 0 1px 2px rgba(0,0,0,0.4); cursor: ew-resize;
  }
  .filter-metric-row .filter-range-handle:hover,
  .filter-metric-row .filter-range-handle:active { background: #fff; }
  .filter-metric-row-empty .filter-metric-readout { color: var(--text-dim); font-style: italic; }
  .filter-metric-row-empty .filter-range-track-wrap { opacity: 0.3; }

  /* Per 90/Total toggle, pinned to the right of .population-readout
     (margin-left:auto, same push-right technique as .header-actions)
     rather than needing that shared rule's own layout touched, since
     Stats' own population-readout has no such toggle. A single pill
     showing whichever mode is currently active, per direct instruction —
     not two buttons side by side — clicking it flips to the other mode.
     Hidden entirely (not shown-disabled) for metrics with no real total to
     derive — see hasTotalMode in js/renderers-rankings.js. */
  .value-mode-toggle {
    margin-left: auto; flex-shrink: 0; align-self: center;
    border: none; background: rgba(46, 204, 113, 0.18); color: #2ecc71;
    font-size: 0.66rem; font-weight: 500; padding: 4px 10px; border-radius: 6px; cursor: pointer;
  }
  .value-mode-toggle:hover { background: rgba(46, 204, 113, 0.28); }

  .empty-note {
    font-size: 0.76rem; color: var(--text-muted); line-height: 1.6; text-align: center; padding: 26px 8px;
  }

  /* Horizontal Bar */
  .bar-wrapper { display: flex; flex-direction: column; gap: 8px; margin-top: 5px; width: 100%; }
  .bar-bg { width: 100%; /* background: #1a1a1a;  */ border-radius: 6px; height: 16px; overflow: hidden; position: relative; }
  .bar-fill { height: 100%; background: var(--accent-color); border-right: 2px solid #2ecc71; transform-origin: left center; }
  @keyframes bar-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
  @keyframes snap-judder { 0% { transform: translateX(0); } 15% { transform: translateX(-2px); } 35% { transform: translateX(1px); } 55% { transform: translateX(-1px); } 75% { transform: translateX(1px); } 100% { transform: translateX(0); } }
  .snap-judder { animation: snap-judder 0.2s ease-out; }

  /* Merge: two same-category Stats modules collide into one comparison —
     shake together ("charging up"), then the target bursts with an electric
     flash as its content flips to the two-player view, while the absorbed
     module scales away into it. Same classList.add → animationend →
     cleanup idiom as snap-judder/pin-animating above. */
  @keyframes merge-shake {
    0%, 100% { transform: translate(0, 0) rotate(0deg); }
    15% { transform: translate(-4px, 2px) rotate(-1deg); }
    30% { transform: translate(4px, -2px) rotate(1deg); }
    45% { transform: translate(-5px, -1px) rotate(-1.5deg); }
    60% { transform: translate(5px, 2px) rotate(1.5deg); }
    75% { transform: translate(-3px, 1px) rotate(-0.5deg); }
    90% { transform: translate(2px, -1px) rotate(0.5deg); }
  }
  .merge-shaking { animation: merge-shake 0.35s ease-in-out; }

  @keyframes merge-burst {
    0% { transform: scale(1); box-shadow: 0 10px 30px rgba(0,0,0,0.8), 0 0 0 rgba(0,194,255,0); }
    40% { transform: scale(1.07); box-shadow: 0 10px 30px rgba(0,0,0,0.8), 0 0 44px rgba(0,194,255,0.9); }
    100% { transform: scale(1); box-shadow: 0 10px 30px rgba(0,0,0,0.8), 0 0 0 rgba(0,194,255,0); }
  }
  .merge-bursting { animation: merge-burst 0.42s cubic-bezier(0.34, 1.56, 0.64, 1); }

  @keyframes merge-absorbing {
    0% { transform: scale(1); opacity: 1; }
    100% { transform: scale(0.15); opacity: 0; }
  }
  .merge-absorbing { animation: merge-absorbing 0.32s ease-in forwards; pointer-events: none; }

  /* Stands in for both modules' real content for the shake phase — without
     it, the module sitting on top (visually all you can see while they
     collide) just keeps showing its old stats/bars, which reads as nothing
     happening rather than a merge in progress. */
  .merge-charging {
    display: flex; flex-direction: column; align-items: center; gap: 10px;
  }
  .merge-charging-bolt {
    font-size: 2.6rem; line-height: 1;
    animation: merge-charge-pulse 0.32s ease-in-out infinite;
  }
  .merge-charging-names { font-size: 0.8rem; font-weight: 600; color: var(--text-secondary); }
  @keyframes merge-charge-pulse {
    0%, 100% { transform: scale(0.85) rotate(-4deg); filter: drop-shadow(0 0 3px rgba(0,194,255,0.5)); }
    50% { transform: scale(1.2) rotate(4deg); filter: drop-shadow(0 0 16px rgba(0,194,255,0.95)); }
  }

  .bar-fill.blue { background: #2d56b4; border-right: none; border-radius: 0 6px 6px 0; }
  .bar-fill.green { background: #2ecc71; border-right: none; border-radius: 0 6px 6px 0; }
  .bar-fill.orange { background: #ce7900; border-right: none; border-radius: 0 6px 6px 0; }
  .bar-fill.blue-light { background: #5d82cc; border-right: none; border-radius: 0 6px 6px 0; }
  .bar-fill.orange-light { background: #e09030; border-right: none; border-radius: 0 6px 6px 0; }
  .bar-fill.team { border-right: none; border-radius: 0 6px 6px 0; }
  .bar-label { position: absolute; top: 0; left: 8px; font-size: var(--t-body); line-height: 16px; color: #fff; font-weight: 600; }
  .bar-value { position: absolute; top: 0; right: 8px; font-size: var(--t-body); line-height: 16px; color: #fff; }

  .chart-dot { transition: transform 0.12s ease; transform-box: fill-box; transform-origin: center; }
  .chart-dot:hover { transform: scale(1.2); }

  /* .bento-fill — universal hook across all chart fills (bars, donuts, heatmap cells, area paths, dots).
     Add rules here to style all chart elements at once, e.g. opacity: 0.8; */
  .bento-fill { opacity: 0.7; }
    /*   .bento-fill { opacity: 0.4; } */
  .bento-selected { filter: contrast(2.5); }

  /* Shared bar lighting — apply to any horizontal bar fill for a top-light sheen. */
  .bar-lit { position: relative; }

  /* Shared lighting utility — apply to any chart element for a subtle top-left highlight
     and bottom ambient shadow. Uses ::after so the element must not need ::after itself.
     border-radius: inherit means it adapts to circles, rounded rects, or any shape. */
  .chart-lit { position: relative; }
  .chart-lit::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: inherit;
    background:
      radial-gradient(ellipse at 38% 22%, rgba(255,255,255,0.18) 0%, transparent 55%),
      radial-gradient(ellipse at 50% 88%, rgba(0,0,0,0.22) 0%, transparent 55%);
    pointer-events: none;
    z-index: 1;
  }

  /* Tooltip */
  #tooltip {
    position: fixed;
    z-index: 9999;
    background: #1a1a1a;
    color: #ddd;
    font-size: var(--t-data);
    line-height: 1.6;
    padding: 6px 10px;
    border-radius: 4px;
    border: 1px solid #444;
    white-space: pre-line;
    /* Every existing tooltip is short enough that this never visibly
       changes them — added for the new .pbar-info-icon metric definitions
       (real sentences up to ~180 characters), which without a cap would
       render as one line spanning most of the viewport instead of wrapping
       into a compact paragraph. */
    max-width: 260px;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.12s;
    text-align: left;
  }
  #tooltip.visible { opacity: 1; }

  /* Charts */
  .line-svg { width: 100%; height: 100%; margin-top: 10px; flex: 1; }
  .legend-line-svg { display: inline-block; width: 18px; height: 8px; vertical-align: middle; margin-right: 4px; }
  .ring-chart { width: 80px; height: 80px; border-radius: 50%; display: flex; align-items: center; justify-content: center; position: relative; }
  .ring-inner { width: 64px; height: 64px; background: var(--block-color); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: var(--t-title); font-weight: bold; color: #fff; }

  /* Legends */
  .legend { display: flex; gap: 12px; justify-content: center; margin-top: 8px; font-size: var(--t-micro); color: #aaa; }
  .legend-item { display: flex; align-items: center; gap: 4px; }
  .legend-color { width: 10px; height: 10px; border-radius: 2px; }

  /* Resize Handle */
  .resize-handle {
    position: absolute;
    bottom: 0;
    right: 0;
    width: 36px;
    height: 36px;
    cursor: nwse-resize;
    touch-action: none;
  }
  body.is-resizing, body.is-resizing * {
    cursor: nwse-resize !important;
  }
  /* Real root cause, finally found by actually rendering the app headlessly
     and diffing computed styles rather than guessing another CSS number:
     this wildcard rule (body.is-moving-module *) forced `z-index: 10` onto
     *every single element on the page* the whole time — a `body` + `*`
     compound selector outranks a plain single-class selector like
     `.snap-indicator` or `.placed-module` on specificity alone, so no
     number written on either of those two rules could ever have won
     against this one, regardless of how large. With every element flattened
     to the same z-index during a drag, DOM order decided the real winner —
     .placed-module elements are appended to #workspace *after*
     #snap-indicator already sits in the static HTML, so a stationary
     target always painted over the indicator, no matter what its own
     z-index said. The `z-index: 10` here was never actually needed for
     this rule's real purpose (forcing a `grabbing` cursor everywhere during
     a drag) — removed outright, not tuned. */
  body.is-dragging-module, body.is-dragging-module *,
  body.is-moving-module, body.is-moving-module * {
    cursor: grabbing !important;
    touch-action: none;
  }
  
  .resize-handle::after {
    content: '';
    position: absolute;
    bottom: 5px;
    right: 5px;
    width: 8px;
    height: 8px;
    border-right: 2px solid #666;
    border-bottom: 2px solid #666;
  }

  /* Mobile drag handle — hidden on desktop */
  .mobile-drag-handle { display: none; }

  /* Placement mode — in-flow line that scrolls with content, snaps between modules */
  #mobile-placement-line {
    display: none;
    width: 100%;
    height: 48px;
    background: rgba(255,255,255,0.05);
    border: 2px dashed rgba(255,255,255,0.3);
    border-radius: 8px;
    box-sizing: border-box;
    flex-shrink: 0;
    transform-origin: center;
  }
  #mobile-placement-overlay {
    display: none;
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.55);
    z-index: 195;
    pointer-events: all;
  }
  #mobile-placement-overlay.active {
    display: block;
  }
  #mobile-placement-line.active {
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    z-index: 196;
    transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
  }
  #mobile-placement-line span {
    color: rgba(46,204,113,0.85);
    font-size: var(--t-data);
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    pointer-events: none;
  }
  
  /* Hide mobile-only elements on desktop */
  #mobile-bottom-sheet, #mobile-sheet-backdrop, #mobile-picker, #mobile-picker-backdrop,
  #mobile-welcome-overlay { display: none; }

  /* ── Mobile Layout ──────────────────────────────────── */
  @media (max-width: 768px) {
    /* Hide desktop-only elements */
    #hover-zone, #welcome-screen, .autonomous-ghost, .snap-indicator,
    .resize-handle, #zoom-widget, #side-panel { display: none !important; }
    .preset-group, #theme-toggle, .top-bar-controls > span { display: none !important; }

    /* Mobile welcome overlay */
    #mobile-welcome-overlay {
      position: fixed;
      inset: 0;
      z-index: 300;
      background: #111;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      padding: 32px 28px;
      text-align: center;
      transition: opacity 0.4s ease;
    }
    #mobile-welcome-overlay.hidden {
      opacity: 0;
      pointer-events: none;
    }
    #mobile-welcome-canvas {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      pointer-events: none;
      z-index: 0;
    }
    .mobile-welcome-content {
      position: relative;
      z-index: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      width: 100%;
    }
    #mobile-welcome-overlay h1 {
      margin: 0 0 14px;
      color: #fff;
      font-size: var(--t-hero);
      font-weight: 600;
      letter-spacing: -0.5px;
    }
    .mobile-welcome-content p {
      margin: 0;
      color: #aaa;
      font-size: var(--t-title);
      line-height: 1.4;
    }
    #mobile-welcome-overlay .welcome-byline {
      font-family: 'Barlow Condensed', sans-serif;
      font-size: var(--t-title);
      color: #aaa;
      letter-spacing: 0.12em;
      margin-top: 28px;
    }
    #mobile-welcome-tour-btn {
      background: #2ecc71;
      color: #111;
    }
    #mobile-welcome-dismiss-btn {
      background: rgba(255,255,255,0.12);
      color: #ccc;
      border: 1px solid rgba(255,255,255,0.15) !important;
    }

    /* Mobile menu button — hidden on desktop */
    #mobile-menu-btn {
      display: flex;
      align-items: center;
      justify-content: center;
      flex-shrink: 0;
      width: 44px;
      height: 50px;
      background: none;
      border: none;
      color: #aaa;
      cursor: pointer;
      padding: 0 10px;
    }
    #mobile-menu-btn:active { color: #fff; }

    /* Top bar: full width, hamburger left + selects filling the rest */
    #top-bar {
      left: 0;
      padding: 0;
      justify-content: flex-start;
      gap: 0;
      z-index: 200;
    }
    .top-bar-controls {
      flex: 1;
      display: flex;
      align-items: center;
      gap: 6px;
      padding: 0 10px 0 4px;
      min-width: 0;
    }
    /* Mobile menu panel — slides down from top bar */
    #mobile-menu-btn { display: flex; }
    #mobile-menu-panel {
      position: fixed;
      top: 50px; left: 0; right: 0;
      background: #161616;
      border-bottom: 1px solid rgba(255,255,255,0.08);
      z-index: 190;
      display: flex;
      flex-direction: column;
      gap: 10px;
      padding: 12px 12px 14px;
      transform: translateY(-100%);
      transition: transform 0.25s ease;
    }
    #mobile-menu-panel.open { transform: translateY(0); }
    .mobile-menu-label {
      font-size: var(--t-label);
      font-weight: 600;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: #555;
      margin-bottom: 5px;
      display: block;
    }
    .mobile-menu-boards {
      display: flex;
      gap: 6px;
      flex-wrap: wrap;
    }
    .mobile-menu-actions {
      display: flex;
      gap: 6px;
      flex-wrap: wrap;
    }
    #mobile-menu-panel .preset-btn {
      font-size: var(--t-body);
      font-weight: 400;
      letter-spacing: 0;
      padding: 3px 9px;
      border-radius: 4px;
    }
    #mobile-menu-panel .preset-btn.icon-btn {
      padding: 3px 8px;
      gap: 5px;
    }

    /* Darker greyscale palette on mobile */
    body { background: #080808; }
    :root {
      --block-color: #161616;
      --border-color: #242424;
    }

    html { background-image: none; }

    /* Workspace: flex-wrap two-column */
    #workspace {
      width: 100% !important;
      height: auto !important;
      min-height: calc(100vh - 50px);
      margin: 0 !important;
      padding: 58px 6px 100px;
      background: transparent;
      display: flex;
      flex-wrap: wrap;
      align-items: flex-start;
      align-content: flex-start;
      gap: 16px;
      overflow-anchor: none;
      box-sizing: border-box;
      transform: none !important;
    }

    .module-body {
      overflow-x: auto;
    }

    /* All modules: full width, height by row count */
    .placed-module {
      position: relative !important;
      left: auto !important;
      top: auto !important;
      transform: none !important;
      zoom: unset !important;
      flex-shrink: 0;
      width: 90% !important;
      height: 250px !important;
      margin-left: auto;
      margin-right: auto;
      transition: height 0.28s ease;
    }
    .placed-module[data-base-rows="3"] { height: 340px !important; }
    .placed-module[data-base-rows="4"] { height: 430px !important; }
    .placed-module[data-base-rows="5"] { height: 520px !important; }
    .placed-module[data-base-rows="6"] { height: 360px !important; }

    /* Bottom tray — always visible */
    #mobile-tray {
      position: fixed;
      bottom: 0; left: 0; right: 0;
      height: 48px;
      background: #161616;
      border-top: 1px solid #2ecc71;
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      z-index: 200;
      user-select: none;
      -webkit-user-select: none;
    }
    #mobile-tray-label {
      font-size: var(--t-data);
      font-weight: 600;
      letter-spacing: 0.1em;
      text-transform: uppercase;
      color: #2ecc71;
      pointer-events: none;
    }
    #mobile-tray.placement-cancel #mobile-tray-label { color: #e74c3c; }

    /* Workspace clearance for the tray */
    #workspace { padding-bottom: 48px; }

    /* Bottom sheet backdrop */
    #mobile-sheet-backdrop {
      display: block;
      position: fixed; inset: 0;
      background: rgba(0,0,0,0.55);
      z-index: 150;
      opacity: 0; pointer-events: none;
      transition: opacity 0.3s ease;
    }
    #mobile-sheet-backdrop.visible { opacity: 1; pointer-events: auto; }

    /* Bottom sheet — sits above the tray */
    #mobile-bottom-sheet {
      display: flex;
      flex-direction: column;
      position: fixed;
      bottom: 48px; left: 0; right: 0;
      background: #1c1c1c;
      border-radius: 16px 16px 0 0;
      z-index: 160;
      transform: translateY(100%);
      transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
      max-height: 72vh;
    }
    #mobile-bottom-sheet.open { transform: translateY(0); }
    #mobile-bottom-sheet.collapsed { transform: translateY(calc(100% - 56px)); transition: transform 0.2s ease; }

    .mobile-sheet-handle {
      width: 36px; height: 4px;
      background: #444; border-radius: 2px;
      margin: 10px auto 6px; flex-shrink: 0;
    }
    .mobile-sheet-title {
      font-size: var(--t-data); font-weight: 600; color: #888;
      text-align: center; padding-bottom: 10px; flex-shrink: 0;
    }
    .mobile-sheet-search-wrap {
      display: flex;
      align-items: center;
      gap: 6px;
      padding: 8px 10px 6px;
      flex-shrink: 0;
    }
    #mobile-sheet-search {
      flex: 1;
      background: #2a2a2a;
      border: 1px solid #3a3a3a;
      border-radius: 8px;
      color: #eee;
      font-size: 16px;
      padding: 7px 10px;
      outline: none;
    }
    #mobile-sheet-search::placeholder { color: #888; font-size: var(--t-body); }
    #mobile-sheet-search-clear {
      background: none;
      border: none;
      color: #555;
      font-size: var(--t-title);
      cursor: pointer;
      padding: 4px 6px;
      line-height: 1;
      display: none;
    }
    #mobile-sheet-search-clear.visible { display: block; }
    .mobile-sheet-tile.hidden { display: none; }

    .mobile-sheet-tiles {
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      gap: 8px;
      padding: 0 10px 10px;
      overflow-y: auto;
      flex: 1;
      min-height: 0;
    }
    .mobile-sheet-group-label {
      grid-column: 1 / -1;
      font-size: var(--t-label);
      font-weight: 600;
      color: #666;
      text-transform: uppercase;
      letter-spacing: 0.1em;
      padding: 10px 0 2px;
    }
    .mobile-sheet-group-label.hidden { display: none; }
    .mobile-sheet-tile {
      background: #2d2d2d;
      border-radius: 10px;
      display: flex; flex-direction: column;
      align-items: center; justify-content: center;
      gap: 5px; padding: 12px 6px;
      font-size: var(--t-label); color: #ccc;
      text-align: center; line-height: 1.3;
      cursor: pointer;
      user-select: none; -webkit-user-select: none;
      touch-action: manipulation;
      transition: opacity 0.15s ease;
    }
    .mobile-sheet-tile:active { opacity: 0.6; }
    .mobile-sheet-tile span {
      display: flex;
      align-items: flex-start;
      justify-content: center;
      min-height: calc(1.3em * 2);
    }
    .mobile-sheet-tile svg { width: 18px; height: 18px; stroke: #888; flex-shrink: 0; }

    /* Header: only the drag handle zone is the drag target on mobile */
    .module-header {
      touch-action: auto;
      cursor: default;
      user-select: none;
      -webkit-user-select: none;
    }

    /* Ringfence the action buttons — restore normal touch behaviour and
       give each button a generously sized tap target */
    .module-header .header-actions {
      touch-action: auto;
      cursor: default;
      flex-shrink: 0;
      gap: 0;
    }
    .module-header .module-pin { display: none; }
    .module-header .module-close {
      min-width: 44px;
      min-height: 44px;
      display: flex;
      align-items: center;
      justify-content: flex-end;
      font-size: var(--t-hero);
    }
    .module-header .mobile-drag-handle {
      justify-content: flex-start;
    }

    /* Drag handle — visible on mobile */
    .mobile-drag-handle {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 32px;
      flex-shrink: 0;
      color: #999;
      cursor: grab;
      touch-action: none;
      user-select: none;
      -webkit-user-select: none;
    }

    /* Module being reordered */
    .placed-module.mobile-drag-hidden {
      display: none !important;
    }

    .placed-module.mobile-lifting {
      transform: scale(1.02);
      box-shadow: 0 20px 56px rgba(0,0,0,0.8);
      z-index: 100;
      transition: transform 0.12s ease-out, box-shadow 0.12s ease-out;
      will-change: transform;
    }

    @keyframes mobile-drop-settle {
      0%   { transform: scale(1.04); }
      50%  { transform: scale(0.984); }
      75%  { transform: scale(1.005); }
      100% { transform: scale(1.0); }
    }

    .placed-module.mobile-drop-settle {
      animation: mobile-drop-settle 0.38s ease-out forwards;
      will-change: transform;
    }

    @keyframes mobile-module-pop-in {
      0%   { transform: scale(0.65); opacity: 0;   box-shadow: inset 0 0 0    0px rgba(0,0,0,0); }
      22%  { transform: scale(1.04); opacity: 1;   box-shadow: inset 0 0 40px 80px rgba(0,0,0,0.85); }
      55%  { transform: scale(0.97);               box-shadow: inset 0 0 0    0px rgba(0,0,0,0); }
      100% { transform: scale(1);                  box-shadow: inset 0 0 0    0px rgba(0,0,0,0); }
    }
    .placed-module.mobile-pop-in {
      animation: mobile-module-pop-in 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
      will-change: transform, opacity;
    }

    @keyframes mobile-placeholder-flash {
      0%   { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.3); }
      35%  { background: rgba(255,255,255,0.22); border-color: rgba(255,255,255,0.9); }
      100% { background: rgba(255,255,255,0.0);  border-color: rgba(255,255,255,0.0); }
    }

    .mobile-reorder-placeholder.flashing {
      animation: mobile-placeholder-flash 0.22s ease-out forwards;
    }

    .mobile-reorder-placeholder {
      position: relative;
      z-index: 196;
      width: 100%;
      height: 48px;
      background: rgba(255,255,255,0.05);
      border: 2px dashed rgba(255,255,255,0.3);
      border-radius: 8px;
      box-sizing: border-box;
      flex-shrink: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      transform-origin: center;
      transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
    }
    /* ── Mobile dropdown picker ──────────────────────────────────────────────── */
    #mobile-picker-backdrop {
      position: fixed;
      inset: 0;
      background: rgba(0,0,0,0.55);
      z-index: 309;
      opacity: 0;
      pointer-events: none;
      transition: opacity 0.25s ease;
    }
    #mobile-picker-backdrop.active { opacity: 1; pointer-events: auto; }

    #mobile-picker {
      position: fixed;
      bottom: 0; left: 0; right: 0;
      background: #1c1c1c;
      border-radius: 16px 16px 0 0;
      z-index: 310;
      transform: translateY(100%);
      transition: transform 0.35s cubic-bezier(0.32, 0.72, 0, 1);
      max-height: 60vh;
      display: flex;
      flex-direction: column;
    }
    #mobile-picker.open { transform: translateY(0); }

    .mobile-picker-handle {
      width: 36px; height: 4px;
      background: #444; border-radius: 2px;
      margin: 10px auto 6px; flex-shrink: 0;
    }
    .mobile-picker-title {
      font-size: var(--t-data);
      font-weight: 600;
      color: #888;
      text-align: center;
      padding-bottom: 6px;
      flex-shrink: 0;
    }
    .mobile-picker-list {
      overflow-y: auto;
      flex: 1;
      -webkit-overflow-scrolling: touch;
      padding-bottom: env(safe-area-inset-bottom, 16px);
    }
    .mobile-picker-option {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 15px 20px;
      font-size: var(--t-body);
      color: #ccc;
      border-bottom: 1px solid #2a2a2a;
      -webkit-tap-highlight-color: transparent;
    }
    .mobile-picker-option:last-child { border-bottom: none; }
    .mobile-picker-option:active { background: #252525; }
    .mobile-picker-option.selected { color: #2ecc71; }
    .mobile-picker-option.selected::after { content: '✓'; color: #2ecc71; }

    /* Inline picker — expands between sub-controls and visualization */
    .mobile-inline-picker {
      width: 100%;
      overflow: hidden;
      max-height: 0;
      transition: max-height 0.28s ease;
      background: #181818;
      border-bottom: 1px solid #2a2a2a;
      flex-shrink: 0;
    }
    .mobile-inline-picker-option {
      padding: 14px 15px;
      font-size: var(--t-body);
      color: #bbb;
      border-bottom: 1px solid #222;
      -webkit-tap-highlight-color: transparent;
    }
    .mobile-inline-picker-option:last-child { border-bottom: none; }
    .mobile-inline-picker-option:active { background: #222; }
    .mobile-inline-picker-option.selected { color: #2ecc71; }
    .mobile-inline-picker-option.selected::after { content: ' ✓'; }

    .mobile-inline-picker-search-wrap {
      padding: 8px 10px;
      border-bottom: 1px solid #222;
    }
    .mobile-inline-picker-search {
      width: 100%;
      box-sizing: border-box;
      background: #141414;
      border: 1px solid #333;
      border-radius: 4px;
      color: #ccc;
      font-size: var(--t-body);
      padding: 8px 10px;
      outline: none;
    }
    .mobile-inline-picker-search::placeholder { color: #4a4a4a; }
    /* Caps the list instead of letting the module grow unbounded — needed once
       an option list can run to hundreds/thousands of rows (e.g. real players). */
    .mobile-inline-picker.searchable .mobile-inline-picker-list {
      max-height: 200px;
      overflow-y: auto;
    }

    .mobile-reorder-placeholder span {
      color: rgba(46,204,113,0.85);
      font-size: var(--t-data);
      font-weight: 600;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      pointer-events: none;
    }
  }

/* Modular architecture helpers */
.module-loading,
.module-error {
  width: 100%;
  min-height: 80px;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  color: #777;
  font-size: var(--t-body);
}
.module-error {
  color: #e74c3c;
  line-height: 1.4;
}

/* ===== LIGHT THEME ===== */
[data-theme="light"] body { color: #111; }

/* Top bar & sidebar */
[data-theme="light"] #top-bar { border-bottom-color: var(--border-color); }
[data-theme="light"] #hover-zone { border-right-color: var(--border-color); }
[data-theme="light"] #side-panel { box-shadow: 5px 0 15px rgba(0,0,0,0.1); }
[data-theme="light"] .menu-title { color: var(--text-muted); }
[data-theme="light"] .menu-icon { stroke: var(--text-muted); }

/* Module templates in sidebar */
[data-theme="light"] .module-template { border-color: var(--border-color); color: var(--text-secondary); }
[data-theme="light"] .module-template:hover { border-color: rgba(46, 204, 113, 0.75); background-color: rgba(46, 204, 113, 0.06); box-shadow: 0 0 10px rgba(46, 204, 113, 0.18), 0 0 22px rgba(46, 204, 113, 0.07); }

/* Preset buttons */
[data-theme="light"] .preset-btn { background: var(--bg-input); color: var(--text-secondary); border-color: var(--border-color); }
[data-theme="light"] .preset-btn:hover { background: var(--bg-elevated); color: #111; }

/* Module search */
[data-theme="light"] #module-search { background: var(--bg-input); border-color: var(--border-color); color: #111; }
[data-theme="light"] #module-search::placeholder { color: var(--text-dim); }
[data-theme="light"] #module-search-clear { color: var(--text-muted); }
[data-theme="light"] .search-empty { color: var(--text-dim); }

/* Module cards */
[data-theme="light"] .placed-module { background: linear-gradient(150deg, #f4f4f8 0%, #dcdce0 100%); border-color: var(--border-color); box-shadow: 0 4px 10px var(--shadow-md); }
[data-theme="light"] .module-header { border-bottom-color: var(--border-subtle); background-color: rgba(0,0,0,0.04); }
[data-theme="light"] .module-title { color: var(--text-secondary); }
[data-theme="light"] .module-sub-controls { background-color: rgba(0,0,0,0.04); border-bottom-color: var(--border-subtle); }
[data-theme="light"] .module-controls { background: var(--bg-input); color: #333; border-color: var(--border-color); }
[data-theme="light"] .module-sub-controls .module-controls { background: var(--bg-elevated); border-color: var(--border-color); }
[data-theme="light"] .module-body { color: #333; }
[data-theme="light"] .module-body::-webkit-scrollbar-thumb { background: #bbb; }
[data-theme="light"] .module-close { color: var(--text-dim); }
[data-theme="light"] .module-pin { color: var(--text-dim); }
[data-theme="light"] .module-pin:hover { color: #555; }
[data-theme="light"] .resize-handle::after { border-right-color: #bbb; border-bottom-color: #bbb; }
[data-theme="light"] .placeholder { border-color: #bbb !important; }
[data-theme="light"] .dragging-ghost { box-shadow: 0 10px 30px rgba(0,0,0,0.2); }

/* Workspace grid lines */
[data-theme="light"] html {
  background-image:
    linear-gradient(to right, #e0e0e4 1px, transparent 1px),
    linear-gradient(to bottom, #e0e0e4 1px, transparent 1px);
}

/* Data tables */
[data-theme="light"] .data-row { border-bottom-color: var(--border-color); }
[data-theme="light"] .data-row:not(.header):not(.highlighted):nth-child(even) { background: rgba(0,0,0,0.03); }
[data-theme="light"] .data-row.header { color: var(--text-muted); border-bottom-color: var(--border-color); }
[data-theme="light"] .data-row.header .sortable:hover { color: #111; }
[data-theme="light"] .data-row.highlighted .col-team { color: #111; }
[data-theme="light"] .col-team { color: #111; }

/* Charts / viz text */
[data-theme="light"] .legend { color: var(--text-muted); }

/* Bar/track backgrounds in light mode */
[data-theme="light"] .bar-bg { background: var(--bg-elevated); }

/* Tooltip */
[data-theme="light"] #tooltip { background: #fff; color: #333; border-color: var(--border-color); }

/* Zoom widget */
[data-theme="light"] .zoom-line { background: var(--border-color); }
[data-theme="light"] .zoom-dot { background: var(--text-dim); }

/* Theme toggle hidden */
#theme-toggle { display: none !important; }
#theme-toggle .theme-icon-sun { display: none; }
[data-theme="light"] #theme-toggle .theme-icon-moon { display: none; }
[data-theme="light"] #theme-toggle .theme-icon-sun { display: block; }

/* Mobile */
@media (max-width: 768px) {
  [data-theme="light"] #side-panel { background-color: #fff; border-bottom-color: var(--border-color); }
  [data-theme="light"] #top-bar { border-bottom-color: var(--border-color); }
}

/* ── Tour overlay ─────────────────────────────────────────────────────────── */
#tour-overlay {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 8000;
  pointer-events: auto;
  background: rgba(0, 0, 0, 0.18);
}
#tour-overlay.active { display: block; }

#tour-panel {
  position: fixed;
  bottom: 32px;
  left: 50%;
  transform: translateX(-50%);
  width: 500px;
  max-width: calc(100vw - 32px);
  background: #1c1c1c;
  border: 1px solid #383838;
  border-radius: 14px;
  padding: 22px 56px 20px 24px;
  pointer-events: all;
  box-shadow: 0 12px 48px rgba(0, 0, 0, 0.7);
}

#tour-step-indicator {
  font-size: var(--t-label);
  color: #555;
  margin-bottom: 8px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

#tour-text {
  margin: 0 0 18px;
  font-size: 0.85rem;
  color: #ccc;
  line-height: 1.55;
}

#tour-cancel {
  position: absolute;
  top: 10px;
  right: 10px;
  background: rgba(255,255,255,0.07);
  border: 1px solid rgba(255,255,255,0.12);
  color: #888;
  font-size: 1.5rem;
  cursor: pointer;
  width: 32px;
  height: 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
  border-radius: 8px;
  transition: color 0.15s, background 0.15s, border-color 0.15s;
  font-family: inherit;
}
#tour-cancel:hover { color: #e74c3c; background: rgba(231,76,60,0.12); border-color: rgba(231,76,60,0.3); }

.tour-actions {
  display: flex;
  gap: 10px;
  justify-content: flex-end;
  align-items: center;
}

#tour-replay {
  display: none;
  background: rgba(255,255,255,0.07);
  color: #888;
  border: 1px solid rgba(255,255,255,0.12);
  border-radius: 8px;
  padding: 9px 18px;
  font-size: var(--t-body);
  font-weight: 500;
  cursor: pointer;
  font-family: inherit;
  transition: opacity 0.15s;
}
#tour-replay:hover:not(:disabled) { opacity: 0.75; }
#tour-replay:disabled { opacity: 0.2; cursor: default; pointer-events: none; }

#tour-next {
  background: #2ecc71;
  color: #000;
  border: none;
  border-radius: 8px;
  padding: 9px 20px;
  min-width: 90px;
  font-size: var(--t-body);
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
  transition: background 0.15s, opacity 0.15s;
}
#tour-next:hover { background: #27ae60; }
#tour-next:disabled { opacity: 0.2; cursor: default; pointer-events: none; }

/* Fake cursor */
#tour-cursor {
  display: none;
  position: fixed;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: rgba(46, 204, 113, 0.25);
  border: 2px solid rgba(46, 204, 113, 0.9);
  box-shadow: 0 0 14px rgba(46, 204, 113, 0.5);
  pointer-events: none;
  z-index: 8500;
  transform: translate(-50%, -50%);
  transition: left 0.6s cubic-bezier(0.4, 0, 0.2, 1),
              top  0.6s cubic-bezier(0.4, 0, 0.2, 1),
              background 0.2s, border-color 0.2s, box-shadow 0.2s;
}
#tour-cursor.grabbing {
  background: rgba(241, 196, 15, 0.25);
  border-color: rgba(241, 196, 15, 0.9);
  box-shadow: 0 0 14px rgba(241, 196, 15, 0.5);
  transform: translate(-50%, -50%) scale(1.2);
}
#tour-cursor.pointer {
  background: rgba(52, 152, 219, 0.25);
  border-color: rgba(52, 152, 219, 0.9);
  box-shadow: 0 0 14px rgba(52, 152, 219, 0.5);
}
#tour-cursor.active { display: block; }

@media (max-width: 768px) {
  #tour-panel {
    bottom: 64px;
    border-radius: 12px;
    padding: 18px 18px 16px;
    max-width: 80%;
  }
  #tour-cancel {
    position: static;
    float: right;
    margin: 0 0 8px 12px;
  }
  #tour-overlay {
    transition: background 0.3s ease;
  }
  #tour-overlay.dimmed {
    background: rgba(0, 0, 0, 0.72);
  }
  .tour-actions {
    gap: 8px;
  }
  #tour-replay,
  #tour-next {
    padding: 9px 14px;
    min-width: 0;
  }
}

/* --- Player Comparison (js/renderers-comparison.js) ------------------- */

.cmp-wrap { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 10px; }
.cmp-axis-controls { display: flex; flex-wrap: wrap; gap: 10px 14px; padding: 0 15px; }
.cmp-axis-control { display: flex; flex-direction: column; gap: 3px; font-size: 0.62rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-dim); }
.cmp-metric-select { min-width: 130px; max-width: 190px; }
.cmp-season-select { min-width: 72px; }
.cmp-wrap .filter-bar { padding: 0 15px; }
.cmp-wrap .population-readout { margin: 0 15px; }
.cmp-wrap .stats-empty { padding: 8px 15px; }
.cmp-area { flex: 1; min-height: 0; padding: 0 15px 12px; display: flex; flex-direction: column; }

/* Genuinely fills whatever real rectangle the module actually is — per
   direct instruction ("it should fill the available space"), no fixed
   aspect-ratio any more (see js/renderers-comparison.js's own comment on
   DOT_DIAMETER_MIN_PX for the full real bug this replaced: a forced 1:1
   box left the SVG's own "meet" scaling to letterbox an even smaller
   square inside it, while the percentage-positioned tick labels — aligned
   to the square viewBox, not the mismatched real box — drifted away from
   where that letterboxed content actually landed). All three layers below
   (gridline SVG, dot overlay, label overlay) stack absolutely and share
   this exact same box, so they can never drift apart from each other
   regardless of its real shape. */
/* flex:1 + min-height:0, not a plain height:100% — .cmp-area is a flex
   column that also holds .cmp-legend below this, and a plain 100% height
   here would ignore how much real space the legend's own content needs,
   the same "claim whatever's left after siblings" pattern already used
   throughout this app (e.g. .event-pitch-wrap). */
.cmp-chart-inner { position: relative; width: 100%; flex: 1; min-height: 0; }
/* Real bug, reported a second time ("we still have a needless massive gap
   here"): the margin reserved for the Y axis was a *percentage* of the
   container, so once the chart genuinely filled a wide module that same
   percentage became a huge real gap, even though the label text itself
   never needed more room just because the chart got wider. .cmp-plot-box
   reserves that room in real, fixed pixels instead (matching
   PLOT_LEFT_PX/etc in js/renderers-comparison.js exactly) — the standard
   "D3 margin convention" every real chart library uses for this — so the
   gap stays a small constant regardless of how wide the module is
   resized. Everything data-related (gridlines, dots) lives inside this
   box at a plain 0-100%; only the tick/title labels below sit just
   outside it, positioned with a small fixed-pixel offset via transform. */
.cmp-plot-box { position: absolute; left: 46px; right: 10px; top: 10px; bottom: 26px; }
.cmp-chart-svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.cmp-axis-line { stroke: rgba(255,255,255,0.35); stroke-width: 0.4; vector-effect: non-scaling-stroke; }
.cmp-grid-line { stroke: rgba(255,255,255,0.08); stroke-width: 0.3; vector-effect: non-scaling-stroke; }
/* Real HTML elements (position:absolute, left/top %, a real pixel width/
   height from dotDiameterPx), not SVG <circle>s — a percentage position is
   already independent per axis, but a shared SVG viewBox stretched non-
   uniformly (preserveAspectRatio="none" above) would render a single-
   radius circle as a visibly squashed ellipse the moment the container
   isn't square, which is now the normal case rather than the exception. */
.cmp-dots-overlay { position: absolute; inset: 0; pointer-events: none; }
.cmp-dot {
  position: absolute; transform: translate(-50%, -50%); border-radius: 50%;
  border: 1px solid rgba(0,0,0,0.35);
  opacity: 0.88; transition: opacity 0.15s ease, width 0.2s ease, height 0.2s ease;
}
/* Positioned relative to .cmp-plot-box (inset:0, same box as the dots and
   gridlines), not .cmp-chart-inner — overflow is deliberately allowed
   (nothing here sets overflow:hidden) since every label needs to render
   just *outside* this box, into the fixed-pixel margin .cmp-plot-box's own
   inset reserved for exactly this. */
.cmp-labels-overlay { position: absolute; inset: 0; pointer-events: none; }
.cmp-tick {
  position: absolute; font-size: 8px; color: var(--text-secondary);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.cmp-tick-x { top: 100%; transform: translate(-50%, 5px); }
.cmp-tick-y { left: 0; transform: translate(calc(-100% - 6px), -50%); }
.cmp-axis-title {
  position: absolute; font-size: 10px; font-weight: 600; color: var(--text-secondary);
  white-space: nowrap;
}
.cmp-axis-title-x { top: 100%; transform: translate(-50%, 17px); }
/* Real bug, reported directly ("this is still a mess") — left:0 anchors
   this span's own *left edge* at the plot's edge, but rotate()'s default
   transform-origin pivots around the element's own *centre*, computed from
   its real (wide, unrotated) text-box dimensions — for "Interceptions" at
   ~70-80px wide, that centre sits ~35-40px to the *right* of left:0, deep
   inside the plot rather than out in the margin, no matter how large a
   fixed-pixel translate tried to drag it back afterward (that -32px was
   nowhere near enough to counteract a ~35-40px built-in rightward offset
   it never accounted for). Fixed the same correct way Radar's own tick
   labels already anchor a point: translate(-50%,-50%) first, using the
   text's own real width/height to move its true centre onto the anchor
   point *before* rotating in place — the anchor itself then moves into the
   margin via a real fixed-pixel `left` (not baked into the translate),
   which is what genuinely controls how far left the label sits now. */
.cmp-axis-title-y { left: -36px; top: 50%; transform: translate(-50%, -50%) rotate(-90deg); }

.cmp-legend { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 14px; padding: 8px 4px 0; font-size: 0.68rem; color: var(--text-secondary); }
.cmp-legend-group { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.cmp-legend-chip { display: inline-flex; align-items: center; gap: 5px; }
.cmp-legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.cmp-legend-label { font-weight: 600; color: var(--text-color); margin-right: 2px; }
.cmp-legend-gradient { gap: 6px; }
.cmp-legend-gradient-bar {
  width: 70px; height: 8px; border-radius: 4px;
  background: linear-gradient(to right, #d73027, #fee08b, #1a9850);
}
.cmp-legend-size { gap: 10px; }
/* min-width, not a fixed width — legendBubblePx (js/renderers-comparison.js)
   sizes the bubble itself up to 26px, so a fixed narrower wrap would clip
   the largest real sample. */
.cmp-legend-bubble-wrap { display: inline-flex; flex-direction: column; align-items: center; gap: 2px; min-width: 30px; }
.cmp-legend-bubble { background: rgba(77, 139, 245, 0.7); border: 1px solid rgba(0,0,0,0.35); border-radius: 50%; flex-shrink: 0; }
.cmp-legend-bubble-value { font-size: 0.6rem; color: var(--text-dim); }

/* --- Heat Map (js/renderers-heatmap.js) -------------------------------- */
/* Real Katie McCabe-only location data (window.CatalystData.shotPassDetail
   — see js/renderers-heatmap.js's own header comment), reusing the exact
   .event-pitch-wrap/.event-pitch-svg/.event-stats-bar/.event-filter-bar/
   .pitch-frame chrome Shot Map and Pass Map already established — only the
   pieces genuinely unique to this module (the Action/Style pickers and the
   three real heat-rendering layers) get their own rules here. */
.heatmap-wrap { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 10px; }
.heatmap-controls { display: flex; flex-wrap: wrap; gap: 10px 14px; flex-shrink: 0; }
.heatmap-control { display: flex; flex-direction: column; gap: 3px; font-size: 0.62rem; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-dim); }
.heatmap-action-select { min-width: 170px; }
.heatmap-style-select { min-width: 100px; }

/* .heatmap-layer paints *before* .pitch-frame inside the shared pitch SVG
   (see heatmapPitchShellHtml's own comment on why this deliberately
   inverts Shot/Pass Map's own paint order) — a real drop-shadow halo on
   the frame is what keeps the pitch markings legibly on top regardless of
   which of the three real colour scales happens to sit underneath at any
   given point, rather than the lines needing to fight for contrast with
   whatever colour a fixed low-opacity stroke happened to land on. */
.heatmap-svg .pitch-frame { filter: drop-shadow(0 0 1px rgba(0,0,0,0.85)); }
.heatmap-svg .pitch-line { stroke: rgba(255,255,255,0.75); }
.heatmap-svg .pitch-spot { fill: rgba(255,255,255,0.75); }

/* Grid style — real per-cell counts, blurred by the shared <feGaussianBlur>
   (#heatmap-blur, defined once per module instance in heatmapPitchShellHtml)
   into one smooth gradient rather than discrete hard-edged squares. */
.heatmap-grid rect { shape-rendering: crispEdges; }

/* Hex style — real hand-rolled axial hex bins (js/renderers-heatmap.js),
   each polygon already sized/coloured per-element in JS; a thin dark seam
   between adjacent hexes reads as intentional cells rather than a single
   blurred blob, matching the reference hexbin look. */
.heatmap-hexes polygon { stroke: rgba(0,0,0,0.25); stroke-width: 0.15; }

/* Contour style — real d3.contourDensity() isoline polygons; no stroke of
   their own, adjacent bands should read as one continuous smooth gradient
   the way a real weather-map contour plot does, not a set of outlined
   shapes. */
.heatmap-contours path { stroke: none; }

/* --- Notes (js/renderers-notes.js) -------------------------------------- */
/* .module-body's align-items:center shrinks an unsized child to fit its own
   content instead of spanning the module — the same recurring fix as
   .stats-wrap/.radar-wrap/.event-map-wrap elsewhere in this app. Column
   flex with min-height:0 on the flexible middle row is what lets the
   editor genuinely scroll within whatever height the module currently is,
   rather than pushing the footer off the bottom on a short module. */
.notes-wrap { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 8px; }
.notes-title {
  width: 100%; box-sizing: border-box; flex-shrink: 0;
  font-size: var(--t-body); color: var(--text-color); background: #1a1a1a;
  border: 1px solid #333; border-radius: 4px; padding: 6px 8px;
}
.notes-title::placeholder { color: var(--text-dim); }
.notes-title:focus { outline: none; border-color: #555; }

.notes-toolbar {
  display: flex; align-items: center; gap: 4px; flex-shrink: 0;
  padding: 5px 6px; background: #1a1a1a; border: 1px solid #333; border-radius: 4px;
}
.notes-btn {
  display: flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; padding: 0; border-radius: 3px;
  background: transparent; border: none; color: var(--text-secondary);
  font-size: 0.78rem; cursor: pointer;
}
.notes-btn:hover { background: rgba(255,255,255,0.08); color: #fff; }
.notes-btn-italic i { font-style: italic; }
.notes-toolbar-divider { width: 1px; height: 16px; background: #3a3a3a; margin: 0 3px; flex-shrink: 0; }
.notes-color-swatch {
  width: 16px; height: 16px; border-radius: 50%; padding: 0; flex-shrink: 0;
  border: 1px solid rgba(0,0,0,0.35); cursor: pointer;
}
.notes-color-swatch:hover { transform: scale(1.15); }

.notes-editor {
  flex: 1; min-height: 0; overflow-y: auto; width: 100%; box-sizing: border-box;
  background: transparent; color: var(--text-color); font: inherit; font-size: var(--t-body);
  line-height: 1.5; padding: 2px 4px; outline: none;
}
.notes-editor a { color: #4d8bf5; text-decoration: underline; }
/* contenteditable has no native placeholder attribute — :empty + a ::before
   is the standard vanilla substitute, hidden the instant any real content
   (even a single typed character) exists since :empty stops matching. */
.notes-editor:empty::before { content: attr(data-placeholder); color: var(--text-dim); pointer-events: none; }

.notes-footer {
  display: flex; justify-content: flex-end; gap: 10px; flex-shrink: 0;
  padding-top: 6px; border-top: 1px solid var(--border-subtle);
}
.notes-btn-cancel, .notes-btn-save {
  padding: 5px 14px; border-radius: 5px; font-size: var(--t-body); cursor: pointer;
  border: 1px solid #333; background: transparent; color: var(--text-secondary);
}
.notes-btn-cancel:hover { color: #fff; border-color: #555; }
.notes-btn-save { background: rgba(46, 204, 113, 0.12); border-color: rgba(46, 204, 113, 0.4); color: #2ecc71; }
.notes-btn-save:hover { background: rgba(46, 204, 113, 0.2); }

/* Post-it styling for the Notes module itself — .module-notes is added by
   createModule's own slug-class logic (js/main.js), not anything specific
   to this file, so no renderer change was needed to hook into it. Every
   other module stays on the shared dark card chrome; this is a scoped
   override, not a change to .placed-module itself. Text/icon colors below
   are all inverted to dark so they stay legible against the yellow fill —
   the light-on-dark palette the rest of the app uses would be unreadable
   here otherwise. */
.module-notes {
  background: linear-gradient(145deg, #fff6b8 0%, #ffe066 100%);
  border-color: #d8bf4d;
  /* Notes is the one module allowed to sit on top of others (js/main.js's
     isAreaClear exempts it from the overlap-collision check, in that
     direction only) — a z-index above the shared .placed-module baseline
     (6) keeps it visually on top whenever it overlaps something, not just
     legally free to be dropped there. Still well under the temporary
     z-index (50/51) any module — Notes included — gets while actively being
     dragged, so mid-drag stacking is untouched. */
  z-index: 15;
}
.module-notes .module-header {
  background-color: rgba(0, 0, 0, 0.06);
  border-bottom-color: rgba(0, 0, 0, 0.18);
}
.module-notes .module-title { color: #3a3016; }
.module-notes .module-pin { color: rgba(0, 0, 0, 0.45); }
.module-notes .module-pin:hover { color: rgba(0, 0, 0, 0.75); }
.module-notes .module-close { color: rgba(0, 0, 0, 0.45); }
.module-notes .module-close:hover { color: #e74c3c; }
.module-notes .resize-handle::after {
  border-right-color: rgba(0, 0, 0, 0.35);
  border-bottom-color: rgba(0, 0, 0, 0.35);
}
.module-notes .notes-title {
  background: rgba(255, 255, 255, 0.4);
  color: #2b2508;
  border-color: rgba(0, 0, 0, 0.2);
}
.module-notes .notes-title::placeholder { color: rgba(0, 0, 0, 0.4); }
.module-notes .notes-title:focus { border-color: rgba(0, 0, 0, 0.4); }
.module-notes .notes-toolbar {
  background: rgba(255, 255, 255, 0.35);
  border-color: rgba(0, 0, 0, 0.2);
}
.module-notes .notes-btn { color: rgba(0, 0, 0, 0.55); }
.module-notes .notes-btn:hover { background: rgba(0, 0, 0, 0.1); color: #000; }
.module-notes .notes-toolbar-divider { background: rgba(0, 0, 0, 0.2); }
.module-notes .notes-editor { color: #2b2508; }
.module-notes .notes-editor a { color: #1a4fb4; }
.module-notes .notes-editor:empty::before { color: rgba(0, 0, 0, 0.4); }
.module-notes .notes-footer { border-top-color: rgba(0, 0, 0, 0.2); }
.module-notes .notes-btn-cancel { color: rgba(0, 0, 0, 0.55); border-color: rgba(0, 0, 0, 0.25); }
.module-notes .notes-btn-cancel:hover { color: #000; border-color: rgba(0, 0, 0, 0.45); }

/* --- Saved Notes (side panel, js/main.js's savedPresets/loadPreset) ---- */
/* Not a .module-template — dragging one doesn't create a module at the
   drop point, it loads a whole saved dashboard (js/main.js's own
   isPresetDrag branch calls loadPreset, not createModule) — but it still
   drags on desktop exactly like a .module-template does (grab cursor,
   same is-lifting press-down feedback), per direct instruction that a
   click-to-load button looked out of place next to every other draggable
   tile in this panel. Mobile keeps the plain tap-to-load .click handler
   (js/main.js), since there's no drag-and-drop placement flow on mobile
   at all to hook into. */
.saved-note-card {
  display: flex; flex-direction: column; gap: 4px;
  padding: 10px 12px; border-radius: 8px; cursor: grab;
  background: rgba(255,255,255,0.03); border: 1px solid var(--border-subtle);
  transition: background 0.15s, border-color 0.15s, transform 0.08s ease;
  user-select: none;
}
.saved-note-card:hover { background: rgba(255,255,255,0.06); border-color: #555; }
.saved-note-card.is-lifting { transform: scale(0.96); }
.saved-note-card-title { font-size: 0.78rem; font-weight: 600; color: var(--text-color); }
.saved-note-card-desc { font-size: 0.68rem; color: var(--text-muted); line-height: 1.4; }

/* The ghost shown while dragging a Saved Notes card — a labelled preview
   ("which dashboard is this?") rather than the plain empty rectangle every
   other module-template ghost is, since a preset drag has no real module
   shape of its own to preview. */
.dragging-ghost.preset-drag-ghost {
  display: flex; align-items: center; justify-content: center;
  padding: 8px 14px; text-align: center;
  font-size: 0.78rem; font-weight: 600; color: var(--text-color);
  border: 1px dashed rgba(46, 204, 113, 0.6);
}

/* --- Matches (js/renderers-matches.js) ---------------------------------- */
/* .module-body's align-items:center shrinks an unsized child to fit its own
   content instead of spanning the module — the same recurring fix as
   .stats-wrap/.radar-wrap elsewhere in this app. */
.matches-wrap { width: 100%; height: 100%; display: flex; flex-direction: column; gap: 8px; min-height: 0; }

.matches-filters { display: flex; flex-wrap: wrap; gap: 6px; flex-shrink: 0; }
.matches-filter-chip {
  display: flex; align-items: center; gap: 6px;
  padding: 4px 10px; border-radius: 20px; cursor: pointer;
  background: rgba(255,255,255,0.03); border: 1px solid var(--border-subtle);
  color: var(--text-secondary); font-size: 0.68rem;
  transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.matches-filter-chip:hover { border-color: #555; color: var(--text-color); }
.matches-filter-chip.active { background: rgba(46, 204, 113, 0.12); border-color: rgba(46, 204, 113, 0.5); color: var(--text-color); }
.matches-filter-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }

.matches-chart-wrap { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }

.matches-axis { position: relative; height: 16px; flex-shrink: 0; margin-left: 100px; }
.matches-tick {
  position: absolute; transform: translateX(-50%);
  font-size: 0.6rem; color: var(--text-dim);
}
.matches-tick-left { transform: translateX(0); }
.matches-tick-right { transform: translateX(-100%); }

.matches-rows { display: flex; flex-direction: column; gap: 5px; }
.matches-row { display: flex; align-items: center; gap: 8px; }
.matches-opponent {
  width: 92px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: flex-end; gap: 3px;
  font-size: 0.64rem; color: var(--text-muted);
}
/* The team name truncates on its own (min-width:0 is what lets a flex child
   actually shrink below its content size) — the (H)/(A) suffix is a
   sibling, not part of the same truncating span, so a long real name (e.g.
   "Brighton & Hove Albion WFC") never hides it. */
.matches-opponent-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.matches-opponent-ha { flex-shrink: 0; color: var(--text-dim); }
.matches-track {
  position: relative; flex: 1; height: 18px;
  background: rgba(255,255,255,0.04); border-radius: 3px; cursor: default;
}
.matches-half-line {
  position: absolute; top: 0; bottom: 0; width: 0;
  border-left: 1px dashed rgba(255,255,255,0.3);
}
.matches-ft-line {
  position: absolute; top: 0; bottom: 0; width: 0;
  border-left: 1px solid rgba(255,255,255,0.15);
}
.matches-bar {
  position: absolute; top: 0; bottom: 0;
  background: #2ecc71; border-radius: 3px;
  transition: filter 0.15s;
}
.matches-track:hover .matches-bar { filter: brightness(1.15); }
.matches-dot {
  position: absolute; top: 50%; width: 7px; height: 7px; border-radius: 50%;
  transform: translate(-50%, -50%);
  box-shadow: 0 0 0 1.5px rgba(0,0,0,0.5);
  transition: transform 0.1s;
}
.matches-dot:hover { transform: translate(-50%, -50%) scale(1.5); }

/* Player Similarity module (js/renderers-similarity.js) — reuses almost
   everything visually: RadarKit's own chart CSS (.radar-*, unscoped so it
   already applies here verbatim, giving a full-width chart the same way
   Radar's own single-player .radar-wrap does), .compare-legend/.compare-chip
   (Radar's own overlay legend, reused verbatim for "player name + season
   above the chart" once a candidate is overlaid — no Similarity-specific
   copy needed since those classes are already bare/unscoped), .filter-chip/
   .add-filter-wrap/.value-picker/.filter-range-picker (Stats' filter-chip
   system), .data-wrapper/.data-row/.col-* (table-sort.js's sortable-table
   convention), .stats-empty, and .module-switch-btn/.radar-edit-axes-btn.
   Only the module's own layout/report classes below are new. */
/* Real bug fixed twice over, both reported directly with screenshots —
   worth recording both attempts since the second one only makes sense in
   light of why the first was wrong. Attempt 1: gave .radar-svg-wrap
   flex:1 (to fill available vertical space) and capped .radar-chart-inner
   with max-height:100%/aspect-ratio, reasoning it should behave like
   .event-pitch-svg's own max-width/max-height fix elsewhere in this app.
   That introduced a NEW bug instead of fixing the old one ("now it doesn't
   scale up properly either") — percentage heights chained through a flex
   item whose own size is itself derived from an aspect-ratio (a genuine
   circular sizing dependency: the flex item's flex-basis is computed
   before its percentage-height descendant can resolve against it) made the
   chart render tiny regardless of how large the module actually was.
   The real fix is simpler and matches what Radar's own single-player
   .radar-wrap actually does — which never has this problem in the first
   place, because it never fights for a fixed height budget at all: its
   chart is purely WIDTH-driven (.radar-chart-inner's own width:90%,
   .radar-svg's own width:100%/height:auto, both from the shared base
   rules, untouched here), sized in normal block flow with no flex:1
   anywhere, so a wide module gets a bigger (still fully in-flow) chart and
   a tall-but-not-wide module just leaves the extra space blank below the
   content — exactly like this module now behaves. The *original* overlap
   bug (a wide module's width-driven chart visually covering the filter
   bar/list/report below it) wasn't really a sizing problem, it was a flow
   problem: those siblings sit in normal flex-column flow already, so they
   can only ever be pushed down by a taller preceding sibling, never
   covered by one — unless that preceding sibling is *itself* also forced
   into a too-small flex:1 box while its actual rendered content ignores
   that box's bounds, which is exactly what attempt 1's own flex:1 rule
   caused. Simply never giving the chart area flex:1 (a plain, non-growing
   flex-column child, sized by its own real width-driven content) removes
   both bugs at once. overflow-y:auto on the wrap itself is the safety net
   for the rare case where the width-driven chart plus the two fixed-height
   sections genuinely don't fit the module's current height — the whole
   wrap scrolls as one then, same as Radar's own .module-body default
   already does when Radar itself is resized wide-but-short. */
.similarity-wrap { width: 100%; height: 100%; min-height: 0; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
.similarity-wrap > .stats-empty { flex-shrink: 0; }
.similarity-wrap > .radar-svg-wrap { flex-shrink: 0; }
.similarity-wrap > .compare-legend { flex-shrink: 0; }
.similarity-wrap > .filter-bar { flex-shrink: 0; }
.similarity-list { flex-shrink: 0; height: 160px; overflow-y: auto; }
/* table-sort.js (window.initSortableTable) unconditionally injects a #
   .col-rank cell into every row — no opt-out built into that shared
   utility — so it's suppressed here the same way this app already opts a
   column out of *sorting* via .no-sort: hide it with CSS rather than fork
   the shared function. display:none removes it from the flex row entirely
   (not just visually), so the remaining columns aren't left with a gap. */
.similarity-list .col-rank { display: none; }
.similarity-row-clickable { cursor: pointer; }
.similarity-row-clickable:hover { background: rgba(255,255,255,0.06); }
/* Real bug fixed, reported directly with a screenshot: .col-team/.col-num's
   own shared flex ratios (4 and 1 respectively) gave Player and Club equal,
   very wide shares while Pos/Age/Similarity were squeezed — real player
   names rarely need that much room, leaving Player mostly blank while Pos
   read as compressed right next to it. Rebalanced for this table's own
   real column content (own classes alongside the shared .col-team/.col-num,
   same specificity but later in the stylesheet so they win by source order
   — no !important needed): Club gets the most room (real club names run
   longer than player names — "RC Deportivo de la Coruña"), Player less
   than before but still comfortable, Age/Similarity narrow (short numeric
   values). Pos was widened once for "Attacking Midfielder" specifically,
   then narrowed back down after that one long label got its own short form
   instead (shortPositionLabel, js/renderers-similarity.js — "Att Mid") —
   every real position now fits comfortably at this width with no ellipsis. */
.similarity-col-player { flex: 2; }
.similarity-col-pos { flex: 0.9; }
.similarity-col-club { flex: 2.4; }
.similarity-col-age { flex: 0.8; }
/* Per direct instruction ("show where a player appears to be an upgrade
   (green) vs a downgrade (red)... a player who is an 80% match, but that's
   because they're such a significant upgrade the numbers don't match") —
   the similarity percentage alone is deliberately shape-only/quality-
   agnostic (js/renderers-similarity.js's own compareTwoPlayers comment), so
   an 80%-similar player who's actually a big step up would otherwise read
   identically to an 80%-similar player who's just different. First shipped
   as three colour classes on the number itself (green/red/muted); per
   direct follow-up instruction reworked into this shape instead — a plain
   white number plus a small green up-arrow/red down-arrow icon
   (.similarity-trend-icon, built in js/renderers-similarity.js's own
   trendArrowSvg) appended after it, no icon at all for a 'lateral' (neither
   clearly better nor worse) match. flex/gap here (not text-align, which
   .col-num's own shared rule already sets but can't align a number+icon
   pair as one unit) lays the two out as a single group — right-aligned
   (justify-content:flex-end) per direct instruction, applying to both the
   header label and every row's value since both share this one class. */
.similarity-score-col {
  flex: 1.1; font-weight: 600; color: var(--text-color);
  display: flex; align-items: center; justify-content: flex-end; gap: 4px;
}
/* Header keeps the original accent colour — it's a column label, not a
   per-row upgrade/downgrade signal, so the white-number change above
   doesn't apply to it. */
.data-row.header .similarity-score-col { color: #2ecc71; }
.similarity-trend-icon { flex-shrink: 0; }
/* Real spacing issue, reported directly with a screenshot: .similarity-wrap's
   own flex gap (10px, shared uniformly between every section) read as too
   tight specifically between the ranked list and the report header below
   it — the list's own last row and "Notable differences" sat almost flush.
   Added here as an additional margin-top (on top of, not instead of, the
   shared 10px gap) rather than raising the wrap's gap globally, since that
   would have also pushed the chart/legend/filter-bar further apart, which
   wasn't reported as a problem. */
.similarity-report { flex-shrink: 0; height: 170px; overflow-y: auto; margin-top: 8px; }
.similarity-report-header { font-size: var(--t-body); font-weight: 600; color: var(--text-color); margin-bottom: 6px; }
.similarity-report-section { margin-bottom: 8px; }
.similarity-report-section-label {
  font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin-bottom: 3px;
}
.similarity-diff-list { margin: 0; padding-left: 18px; }
.similarity-diff-list li { font-size: var(--t-body); color: #ccc; margin-bottom: 4px; }
.similarity-report-empty { font-size: var(--t-body); color: var(--text-muted); }
.similarity-caveat { font-size: var(--t-body); color: #ccc; }
.similarity-caveat-warning { color: #e2a13a; }
