/* ===== CSS Variables ===== */
:root {
    --color-bg: #faf9f7;
    --color-bg-alt: #f0efeb;
    --color-text: #1a1a1a;
    --color-text-muted: #5a5a5a;
    --color-accent: #2d5a7b;
    --color-accent-light: #e8f0f5;
    --color-accent-warm: #c44536;
    --color-border: #e0ddd8;
    --color-stage-bg: #1a1a1a;
    --color-stage-fg: #d8d8d8;
    --font-display: 'Instrument Serif', Georgia, serif;
    --font-body: 'Source Sans 3', -apple-system, sans-serif;
    --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
    --max-width: 1100px;
    --space-xs: 0.5rem;
    --space-sm: 1rem;
    --space-md: 2rem;
    --space-lg: 4rem;
    --space-xl: 6rem;
    /* Block titles ("Rare events happen in daily life", "Random Walker",
       "Local Competition (Visualization Only)", "Key findings", etc.) —
       every `.viz-block-title` reads its font size from this single
       variable so the whole site adjusts in one place. Was 1.3 rem;
       baseline bumped slightly so back-of-room legibility improves.
       Tune by editing this one value. */
    --viz-block-title-size: 1.55rem;
}

/* ===== Reset & Base ===== */
*,
*::before,
*::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

html {
    scroll-behavior: smooth;
    /* Anchor jumps offset for the fixed header so titles aren't hidden. */
    scroll-padding-top: 80px;
}

body {
    font-family: var(--font-body);
    font-size: 1rem;
    font-weight: 400;
    line-height: 1.7;
    color: var(--color-text);
    background: var(--color-bg);
    -webkit-font-smoothing: antialiased;
}

img {
    max-width: 100%;
    display: block;
}

a {
    color: var(--color-accent);
    text-decoration: none;
    transition: opacity 0.2s ease;
}

a:hover {
    opacity: 0.7;
}

/* ===== Typography ===== */
h1,
h2,
h3 {
    font-family: var(--font-display);
    font-weight: 400;
    line-height: 1.2;
}

h1 {
    font-size: clamp(2.5rem, 6vw, 4rem);
}

h2 {
    font-size: clamp(1.75rem, 4vw, 2.5rem);
    margin-bottom: var(--space-md);
}

h3 {
    font-size: 1.25rem;
}

.section-label {
    font-family: var(--font-body);
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.15em;
    text-transform: uppercase;
    color: var(--color-accent);
    margin-bottom: var(--space-xs);
    display: block;
}

/* ===== §10 References — clean numbered citation list =====
   Stand-alone block appended after §09 (not in SECTIONS). Numbered
   ol with a hanging indent so wrapped lines align under the body
   text, not under the number. Body face (Source Sans 3) at ~1 rem
   so it reads as an academic bibliography rather than a hero text
   block. Italics on journal names come from <em> inside each <li>. */
.references-list {
    list-style: decimal;
    padding-left: 2.2rem;
    margin-top: var(--space-md);
    max-width: 880px;
}
.references-list__item {
    font-family: var(--font-body);
    font-size: 1rem;
    line-height: 1.55;
    color: var(--color-text);
    margin-bottom: 0.9rem;
    padding-left: 0.2rem;
}
.references-list__item::marker {
    color: var(--color-text-muted);
    font-weight: 600;
}

/* ===== Layout ===== */
.container {
    width: 100%;
    max-width: var(--max-width);
    margin: 0 auto;
    padding: 0 var(--space-md);
}

section {
    /* Adaptive height: section is exactly as tall as its content needs. */
    padding: var(--space-xl) 0;
}

/* ===== Hero Section (title + subtitle landing) ===== */
.hero {
    /* Full-viewport landing; 100dvh adapts to mobile address bar. */
    min-height: 100vh;
    min-height: 100dvh;
    display: flex;
    align-items: center;
    padding-top: 70px;
    position: relative;
}

.hero-content {
    text-align: center;
    margin: 0 auto;
}

.hero-eyebrow {
    font-family: var(--font-body);
    font-size: 0.85rem;
    font-weight: 600;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: var(--color-accent);
    margin-bottom: var(--space-sm);
}

.hero h1 {
    margin-bottom: var(--space-md);
}

.hero-tagline {
    font-size: 1.45rem;
    max-width: 720px;
    margin: 0 auto;
    color: var(--color-text-muted);
}
.hero-tagline-link {
    color: var(--color-text);
    font-weight: 600;
    text-decoration: underline;
    text-decoration-color: rgba(45, 90, 123, 0.35);
    text-underline-offset: 3px;
    transition: text-decoration-color 200ms ease, color 200ms ease;
}
.hero-tagline-link:hover {
    color: var(--color-accent);
    text-decoration-color: var(--color-accent);
}

/* Institutional banner — pinned to the bottom of the hero section
   (.hero is `position: relative` so absolute positioning here lands
   inside it). Slightly muted opacity + rounded corner mask so the
   logos read as a "presented by" card rather than a hero element.
   Centred via left:50% + translateX(-50%) so the max-width responds
   without losing the horizontal anchor. */
.hero-logos {
    position: absolute;
    left: 50%;
    bottom: 48px;
    transform: translateX(-50%);
    max-width: min(672px, 60%);
    width: 100%;
    height: auto;
    opacity: 1;
    border: 0;
    border-radius: 0;
    box-shadow: none;
}

/* Alternating section background for visual rhythm. */
section.alt-bg {
    background: var(--color-bg-alt);
}

/* ===== Header & Navigation ===== */
header {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 1000;
    background: rgba(250, 249, 247, 0.9);
    backdrop-filter: blur(10px);
    border-bottom: 1px solid var(--color-border);
    /* Smart-hide: assets/script.js toggles `.is-hidden` on scroll-down
       past the hero (and removes it on scroll-up or while the hero is
       in view). transform is GPU-cheap and animates smoothly via this
       transition. */
    transition: transform 0.3s ease;
}
header.is-hidden {
    transform: translateY(-100%);
}

nav {
    display: flex;
    justify-content: space-between;
    align-items: center;
    height: 70px;
}

.nav-logo {
    font-family: var(--font-display);
    font-size: 1.25rem;
    color: var(--color-text);
}

.nav-links {
    display: none;
    list-style: none;
    gap: var(--space-md);
}

.nav-links a {
    font-size: 0.9rem;
    font-weight: 500;
    color: var(--color-text);
    position: relative;
}

.nav-links a::after {
    content: '';
    position: absolute;
    bottom: -4px;
    left: 0;
    width: 0;
    height: 1px;
    background: var(--color-accent);
    transition: width 0.3s ease;
}

.nav-links a:hover::after {
    width: 100%;
}

.nav-toggle {
    display: flex;
    flex-direction: column;
    gap: 5px;
    cursor: pointer;
    padding: 5px;
    z-index: 1001;
}

.nav-toggle span {
    width: 24px;
    height: 2px;
    background: var(--color-text);
    transition: all 0.3s ease;
}

.nav-toggle.active span:nth-child(1) {
    transform: rotate(45deg) translate(5px, 5px);
}

.nav-toggle.active span:nth-child(2) {
    opacity: 0;
}

.nav-toggle.active span:nth-child(3) {
    transform: rotate(-45deg) translate(5px, -5px);
}

.nav-mobile {
    position: fixed;
    top: 0;
    right: -100%;
    width: 100%;
    height: 100vh;
    background: var(--color-bg);
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    gap: var(--space-md);
    transition: right 0.4s ease;
    z-index: 999;
}

.nav-mobile.active {
    right: 0;
}

.nav-mobile a {
    font-family: var(--font-display);
    font-size: 2rem;
    color: var(--color-text);
}

/* ===== Back-to-top ===== */
.back-to-top {
    position: fixed;
    bottom: 30px;
    right: 30px;
    width: 50px;
    height: 50px;
    border-radius: 50%;
    background: var(--color-bg-alt);
    border: 2px solid var(--color-border);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    opacity: 0;
    transition: opacity 0.3s ease, background 0.3s ease, transform 0.3s ease;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    z-index: 100;
    pointer-events: none;
}

.back-to-top.visible {
    opacity: 1;
    pointer-events: auto;
}

/* When the §07 floating σ-panel is visible, its bottom edge overlaps
   the back-to-top's vertical region — and on narrow viewports it can
   also reach into the right corner. Push the button up so they read as
   stacked, not collided. Same shift applies to the left-bottom mirror. */
body:has(.sim-float-controls.visible) .back-to-top,
body:has(.sim-float-controls.visible) .left-bottom-btn {
    bottom: 100px;
}

/* ===== Section-number badge (presentation "page number") =====
   Fixed-position chip at top-right showing "NN / TT · Title" — the
   audience's "slide number" equivalent so they can say "can we go
   back to section 04". JS (setupSectionBadge) tracks which section
   owns the viewport via IntersectionObserver and updates the text.
   Visibility follows the same hero-anchored pattern as back-to-top
   and the TOC: hidden on the hero, visible everywhere else (managed
   by setupFloatingAnchors in script.js). Glass-effect chip — soft
   white with backdrop blur — matches the §07 σ-panel + §08 tolerance
   panel chrome so floating UI reads as one family. */
.section-badge {
    position: fixed;
    top: 96px;
    right: 30px;
    z-index: 90;
    padding: 0.48rem 0.98rem;
    background: rgba(255, 255, 255, 0.92);
    -webkit-backdrop-filter: blur(8px);
    backdrop-filter: blur(8px);
    border: 1px solid var(--color-border);
    border-radius: 999px;
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
    font-family: var(--font-body);
    font-size: 0.92rem;
    color: var(--color-text);
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
    opacity: 0;
    transform: translateY(-6px);
    transition: opacity 0.25s ease, transform 0.25s ease;
    pointer-events: none;
    user-select: none;
    white-space: nowrap;
    max-width: min(35vw, 380px);
    overflow: hidden;
    text-overflow: ellipsis;
}
.section-badge.visible {
    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;
}
/* Click-to-roadmap affordance: the badge doubles as a roadmap trigger
   (rq-popup-trigger), so give it a subtle bg lift on hover/focus so
   the audience knows it's tappable. The base .rq-popup-trigger rule
   already handles cursor:pointer and focus outline. */
.section-badge.rq-popup-trigger:hover {
    background: rgba(255, 255, 255, 1);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
    filter: none;
}
.section-badge__num {
    font-weight: 600;
    color: var(--color-accent);
    letter-spacing: 0.02em;
    font-variant-numeric: tabular-nums;
}
.section-badge__sep {
    color: var(--color-text-muted);
}
.section-badge__title {
    color: var(--color-text);
    font-weight: 500;
    overflow: hidden;
    text-overflow: ellipsis;
}
/* When the smart top-nav auto-hides (header.is-hidden), there's no
   reason to leave the badge floating 96 px down with empty space
   above. Slide it up to a clean 28 px top so it occupies the same
   region the header used to. Uses :has() (same pattern as the
   sim-float-controls bottom-shift rule above). */
body:has(header.is-hidden) .section-badge {
    top: 28px;
}

/* ----- Background-color toggle (placeholder) -----
   Sits directly under the section badge. Reuses .section-badge's glass
   pill chrome (same base class) but occupies its own row below. When
   the top nav auto-hides, the section badge slides up to top:28px, so
   this chip follows to top:76px (badge height ~36px + 12px gap). No
   click behavior yet — this is a UI-only placeholder pending the
   publication-mode wiring. */
.section-badge.bg-toggle {
    top: 148px;
    right: 30px;
    max-width: min(35vw, 380px);
    cursor: pointer;
}
body:has(header.is-hidden) .section-badge.bg-toggle {
    top: 76px;
}
.section-badge.bg-toggle:hover {
    background: rgba(255, 255, 255, 1);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
}
/* Two-tone swatch hints at the cream→white flip. Default: split cream /
   white so the audience reads it as "toggle background". In paper mode:
   flat solid white with just a border, echoing the flattened state. */
.bg-toggle__swatch {
    display: inline-block;
    width: 14px;
    height: 14px;
    border-radius: 4px;
    background: linear-gradient(135deg,
                                var(--color-bg-alt) 0 50%,
                                #ffffff 50% 100%);
    border: 1px solid var(--color-border);
    flex: 0 0 auto;
    align-self: center;
    transition: background 0.2s ease;
}
.bg-toggle__label {
    color: var(--color-text);
    font-weight: 500;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* ----- Paper mode: whitewash the page background -----
   Toggled by a click handler on #bgToggle (assets/script.js). The
   whole flip is done via CSS variable override + a couple of surface
   rules so nothing else in the sheet has to change. Persisted across
   page loads via localStorage in the same handler.
   Known limitation: figures that bake `paper_bgcolor="#faf9f7"` into
   their Plotly layout (e.g. the weight heatmap and the per-block
   probability chart) keep their cream tint here — a Plotly.restyle
   pass is needed for those separately. */
body.paper-mode {
    --color-bg: #ffffff;
    --color-bg-alt: #ffffff;
    background: #ffffff;
}
body.paper-mode .bg-toggle__swatch {
    background: #ffffff;
}
/* In paper mode the tiles no longer stand out from the page, so their
   chrome (border + shadow) reads as clutter. Soften both. */
body.paper-mode .viz-tile {
    border-color: rgba(0, 0, 0, 0.08);
    box-shadow: none;
}

/* ===== Left-bottom TOC =====
   Wrapper is the always-visible anchor button at bottom-left (mirrors
   .back-to-top). Inside: a circular toggle (.toc-toggle, the three-
   dot pill that's the resting state) and a vertical rail (.toc-rail)
   that unrolls upward when .left-bottom-btn.is-expanded.
   Visibility: script.js / setupTocRail flips .visible on scroll past
   20 % document height, same trigger as backToTop. */
.left-bottom-btn {
    /* Wrapper holds the toggle pill (fixed bottom-left, mirror of
       back-to-top) plus the absolutely-positioned TOC rail above.
       z-index lifted to 1100 — page header sits at 1000 with
       position:fixed, so anything below 1000 gets covered when the
       rail extends up the viewport. */
    position: fixed;
    bottom: 30px;
    left: 30px;
    width: 50px;
    height: 50px;
    z-index: 1100;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s ease, bottom 0.3s ease;
}

.left-bottom-btn.visible {
    opacity: 1;
    pointer-events: auto;
}

/* Anchor toggle — the bottom-most circular pill. Resting state shows
   three dots; the rail above is what unrolls on click. */
.toc-toggle {
    appearance: none;
    width: 50px;
    height: 50px;
    border-radius: 50%;
    background: var(--color-bg-alt);
    border: 2px solid var(--color-border);
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    padding: 0;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    transition: background 0.3s ease, transform 0.3s ease,
                box-shadow 0.3s ease;
}

.toc-toggle:hover {
    background: var(--color-accent);
    transform: translateY(-3px);
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}

.toc-toggle:hover .dots-three {
    color: white;
}

/* Three horizontal lines — hamburger glyph. Class kept as `.dots-three`
   so the Python markup doesn't need a touch; the element itself draws
   the middle line, ±6 px vertical box-shadows paint the flanking pair.
   currentColor lets the parent's hover recolour cascade in (same
   pattern as the previous dots version). */
.dots-three {
    width: 18px;
    height: 2px;
    border-radius: 1px;
    background: currentColor;
    color: var(--color-text-muted);
    box-shadow: 0 -6px 0 0 currentColor,
                0  6px 0 0 currentColor;
    transition: color 0.3s ease;
}

/* TOC rail. Independent position:fixed so the rail's offset to the
   toggle is explicit, not flex-flow-dependent. Anchored bottom edge
   at 92 px (toggle bottom 30 + toggle height 50 + 12 px gap) and
   grows upward via max-height animation. Items in flex-column
   order: §01 at top of rail, §09 just above the toggle. Cap at
   calc(100vh - 130 px) and allow internal scroll so short viewports
   stay navigable instead of clipping content silently. */
.toc-rail {
    /* Positioned inside the wrapper (.left-bottom-btn is position:fixed
       so it's a containing block for absolutely-positioned descendants).
       Rail's bottom edge sits 100 px above the toggle TOP: wrapper is
       50 px tall, so bottom:150 = (50 toggle) + (100 gap). Rail moves
       with the wrapper automatically when sigma-panel visibility shifts
       it — no separate offset rule needed. z-index above the header
       (which sits at 1000) so the rail never renders behind it as the
       rail extends up the page. */
    position: absolute;
    /* Was 150 px (= 100 px above toggle top). Lifted higher because the
       taller rail (after the gap bump) was extending its tail below
       the visible region. */
    bottom: 325px;
    /* Toggle centre is at wrapper-relative x=25. Dot is 10 px wide →
       dot-left = 20 puts its centre at x=25, aligned with the toggle. */
    left: 20px;
    z-index: 1100;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 26px;
    list-style: none;
    margin: 0;
    padding: 0;
    opacity: 0;
    transform: translateY(6px);
    pointer-events: none;
    transition: opacity 220ms cubic-bezier(0.4, 0, 0.2, 1),
                transform 220ms cubic-bezier(0.4, 0, 0.2, 1);
    visibility: hidden;
}

/* Slim vertical pill behind just the dot column — matches the chip
   bg on visible labels for one consistent material. ::before is sized
   to wrap the dots (incl. active halo) plus 10 px breathing room top
   and bottom; left/width center it on the dot axis (dot column
   spans x=0..10 in rail coords, pill at x=-10..20 = width 30
   centred at 5). z-index:-1 sends it behind the dots; the rail's
   own positioning + z-index 1100 establishes the stacking context
   so the negative z stays within. */
.toc-rail::before {
    content: "";
    position: absolute;
    left: -10px;
    /* Manual 3× height (was calc(100% + 20px) = ~417 px). Fixed
       1251 px overrides the parent-derived calc which was
       under-computing inside the flex parent. */
    top: -10px;
    height: 340px;
    width: 30px;
    background: rgba(245, 243, 240, 0.78);
    backdrop-filter: blur(10px) saturate(140%);
    -webkit-backdrop-filter: blur(10px) saturate(140%);
    border-radius: 999px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10),
                0 0 0 1px rgba(255, 255, 255, 0.5) inset;
    z-index: -1;
    pointer-events: none;
}

.left-bottom-btn.is-expanded .toc-rail {
    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;
    visibility: visible;
}

/* Each dot row: a circle + a hover/current label. The circle has a
   fixed footprint via padding so the label aligns regardless of
   active size. */
.toc-rail__dot {
    display: flex;
    align-items: center;
    gap: 12px;
    text-decoration: none;
    color: var(--color-text);
    position: relative;
    padding: 0;
    /* tight row so labels don't inflate vertical space; 13 px font
       at line-height 1 sits the label flush with the circle. */
    line-height: 1;
    min-height: 12px;
}

/* Dashed connector between consecutive dots. Drawn via ::before above
   each dot (skip first). Aligned to the dot's centre (5 px from left).
   Height matches the rail's gap so the line meets both circles. */
.toc-rail__dot + .toc-rail__dot::before {
    content: "";
    position: absolute;
    left: 5px;
    top: -26px;
    height: 26px;
    border-left: 1px dashed rgba(0, 0, 0, 0.25);
}

.toc-rail__circle {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    background: var(--color-accent);
    flex-shrink: 0;
    transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1),
                height 200ms cubic-bezier(0.4, 0, 0.2, 1),
                margin 200ms cubic-bezier(0.4, 0, 0.2, 1),
                box-shadow 200ms ease;
}

/* Current section — larger dot + soft glow. The wider dot is centered
   on the same axis as the smaller ones via negative margin (so the
   dashed connector line still aligns). */
.toc-rail__dot.is-current .toc-rail__circle {
    width: 16px;
    height: 16px;
    margin: 0 -3px;        /* re-centre on the 10 px column */
    box-shadow: 0 0 0 4px rgba(45, 90, 123, 0.15);
}

.toc-rail__label {
    font-size: 13px;
    font-weight: 500;
    color: var(--color-text);
    white-space: nowrap;
    /* Default state: every section label is rendered alongside its dot
       as soon as the rail expands. Non-current sections sit at a muted
       opacity so the eye lands on the current one first; current
       section + hover lift to full brightness via the rules below. */
    opacity: 0.5;
    transform: translateX(0);
    transition: opacity 180ms ease;
    /* Chip background — translucent cream pill with blur keeps text
       readable against any page content the rail floats over. */
    padding: 4px 10px;
    border-radius: 999px;
    background: rgba(245, 243, 240, 0.78);
    backdrop-filter: blur(10px) saturate(140%);
    -webkit-backdrop-filter: blur(10px) saturate(140%);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08),
                0 0 0 1px rgba(255, 255, 255, 0.5) inset;
}

/* Current section: fully opaque + bolder weight so it leads the eye. */
.toc-rail__dot.is-current .toc-rail__label {
    opacity: 1;
    font-weight: 600;
}

/* Hover / keyboard focus on a dim section lifts it to full brightness
   so pointer feedback still feels alive. */
.toc-rail__dot:hover .toc-rail__label,
.toc-rail__dot:focus-visible .toc-rail__label {
    opacity: 1;
}

.toc-rail__dot:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 4px;
    border-radius: 4px;
}

.back-to-top:hover {
    background: var(--color-accent);
    transform: translateY(-3px);
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}

.back-to-top:hover .chevron-up {
    border-color: white;
}

.chevron-up {
    width: 16px;
    height: 16px;
    border-left: 2px solid var(--color-text-muted);
    border-bottom: 2px solid var(--color-text-muted);
    transform: rotate(135deg);
    transition: border-color 0.3s ease;
}

/* ===== §07 floating noise controls =====
   Bottom-center docked pill that surfaces only while the audience is
   near the simulation section (IntersectionObserver in script.js
   flips .visible). The pill itself is self-collapsible — the left
   chevron toggles .is-expanded, hiding the slider so just the σ*
   shortcut remains. A single σ value drives BOTH the Over and Under
   workers (no per-block targeting).

   Sizing tokens — bumped relative to the first pass; the panel reads
   as a deliberate piece of UI furniture rather than a side-knob.
   Transition timing: the visibility (slide-in/out) keeps the springy
   curve for personality, but the collapse animation uses a smooth
   Material-standard ease so the pill width / children fades don't
   feel jittery. */
.sim-float-controls {
    position: fixed;
    left: 50%;
    bottom: 28px;
    transform: translate(-50%, 18px);
    z-index: 95;
    display: flex;
    align-items: center;
    gap: 18px;
    background: rgba(231, 228, 224, 0.66);
    backdrop-filter: blur(14px) saturate(140%);
    -webkit-backdrop-filter: blur(14px) saturate(140%);
    border: 1px solid rgba(255, 255, 255, 0.42);
    border-radius: 999px;
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14),
                0 2px 8px rgba(0, 0, 0, 0.06);
    padding: 14px 30px 14px 20px;
    opacity: 0;
    pointer-events: none;
    max-width: 510px;
    /* Clip the slider tail when the pill is contracted. The slider
       below holds a fixed 190 px width at all times so the slider can
       lay its track out properly; we just hide the overflow when the
       panel itself is narrow. */
    overflow: hidden;
    transition: opacity 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
                transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
                padding 520ms cubic-bezier(0.4, 0, 0.2, 1),
                gap 520ms cubic-bezier(0.4, 0, 0.2, 1),
                max-width 520ms cubic-bezier(0.4, 0, 0.2, 1);
}

.sim-float-controls.visible {
    opacity: 1;
    pointer-events: auto;
    transform: translate(-50%, 0);
}

/* Collapsed pill — tight padding, less gap, and a narrow max-width so
   only the chevron + σ* fit. The slider/divider/readout still sit in
   the flow at their natural widths; they're just clipped by overflow:
   hidden. Combined with the opacity fade below this reads as a smooth
   contraction. */
.sim-float-controls:not(.is-expanded) {
    padding: 12px 18px 12px 16px;
    gap: 12px;
    max-width: 200px;
}

/* Toggle chevron. Pure CSS shape (two borders rotated 45°). When the
   panel is expanded the chevron points left (< == "collapse me"); when
   collapsed it points right (> == "expand me"). */
.sim-float-toggle {
    appearance: none;
    background: transparent;
    border: none;
    width: 36px;
    height: 36px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    padding: 0;
    color: var(--color-text-muted);
    transition: color 200ms ease;
    flex-shrink: 0;
}

.sim-float-toggle:hover {
    color: var(--color-text);
}

.sim-float-chevron {
    width: 13px;
    height: 13px;
    border-top: 2.8px solid currentColor;
    border-right: 2.8px solid currentColor;
    transform: rotate(-135deg);
    transition: transform 520ms cubic-bezier(0.4, 0, 0.2, 1);
    margin-left: 4px;
}

.sim-float-controls:not(.is-expanded) .sim-float-chevron {
    transform: rotate(45deg);
    margin-left: -4px;
}

/* σ*-button — pre-defined "optimal" σ shortcut. Slightly darker pill
   inside the outer container so it reads as an embedded chip. */
.sim-float-star {
    /* --sim-star-blend is set per-frame by a clientside callback in
       app.py based on |slider - optimal|. 0 = full grey, 1 = full
       accent-blue; color-mix() interpolates background + text colour
       continuously between the two ends. */
    --sim-star-blend: 0;
    appearance: none;
    border: none;
    background: color-mix(
        in srgb,
        rgba(205, 202, 195, 0.82),
        var(--color-accent) calc(var(--sim-star-blend) * 100%)
    );
    color: color-mix(
        in srgb,
        var(--color-text),
        white calc(var(--sim-star-blend) * 100%)
    );
    font-family: "Times New Roman", Georgia, serif;
    font-style: italic;
    font-size: 1.35rem;
    font-weight: 600;
    padding: 9px 30px;
    border-radius: 999px;
    cursor: pointer;
    line-height: 1.1;
    flex-shrink: 0;
    transition: background 200ms ease, transform 200ms ease,
                color 200ms ease, box-shadow 200ms ease;
}

.sim-float-star:hover {
    /* hover only adds the lift; background colour is owned by the
       blend variable so we don't override it here. */
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14);
    filter: brightness(0.96);
}

.sim-float-star:active {
    transform: scale(0.96);
}

/* Active state — slider sits exactly on the pre-defined optimal σ*.
   Background colour is already pegged to full accent-blue by the
   --sim-star-blend variable; this rule only owns the pulse ring so
   the eye gets drawn to the active button. Kept under 12 px so the
   ring doesn't clip against the panel's overflow: hidden boundary. */
.sim-float-star.is-active {
    animation: sim-float-star-pulse 0.9s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

@keyframes sim-float-star-pulse {
    0% {
        box-shadow: 0 0 0 0 rgba(45, 90, 123, 0.55);
    }
    100% {
        box-shadow: 0 0 0 5px rgba(45, 90, 123, 0);
    }
}

/* Animated children. They each hold a fixed width — rc-slider needs
   a real container width on mount or its track won't lay out. The
   pill's overflow: hidden clips them when the panel is narrow; we
   just fade their opacity so they don't appear smeared mid-clip. */
.sim-float-divider,
.sim-float-slider-input,
.sim-float-readout {
    transition: opacity 240ms cubic-bezier(0.4, 0, 0.2, 1);
    flex-shrink: 0;
}

/* Vertical rule between the σ* shortcut and the slider half. */
.sim-float-divider {
    width: 1px;
    height: 28px;
    background: rgba(0, 0, 0, 0.22);
    margin: 0 4px;
}

/* Dash slider — fixed width so the slider can lay its rail out
   properly. When the pill collapses, the rail isn't shrunk; it's
   simply clipped by the panel's overflow. */
.sim-float-slider-input {
    width: 190px;
}

.sim-float-slider-input .rc-slider {
    margin: 0;
    width: 100%;
    height: 18px;
}

/* Dash 4 ships dcc.Slider with an inline editable number field
   (.dash-range-slider-input) next to the rail. We have our own
   readout to the right of the pill, so hide Dash's. */
.sim-float-slider-input .dash-input-container,
.sim-float-slider-input .dash-range-slider-input {
    display: none !important;
}

/* Dash 4.0 ships dcc.Slider as a Radix UI slider. Override the
   default purple to the site accent. Track = the unfilled rail
   (neutral); range = the filled portion before the thumb; the thumb
   is a Radix span with role="slider". Using !important to defeat the
   inline / scoped styles the Radix primitive injects. */
.sim-float-slider-input .dash-slider-track {
    background-color: rgba(0, 0, 0, 0.18) !important;
}

.sim-float-slider-input .dash-slider-range {
    background-color: var(--color-accent) !important;
}

.sim-float-slider-input [role="slider"] {
    background-color: #ffffff !important;
    border: 2px solid var(--color-accent) !important;
    box-shadow: none !important;
}

.sim-float-slider-input [role="slider"]:hover,
.sim-float-slider-input [role="slider"]:focus,
.sim-float-slider-input [role="slider"]:focus-visible {
    border-color: var(--color-accent) !important;
    box-shadow: 0 0 0 5px rgba(45, 90, 123, 0.2) !important;
    outline: none !important;
}

/* Numeric readout — tabular-nums so the width doesn't jitter as the
   value changes. */
.sim-float-readout {
    font-variant-numeric: tabular-nums;
    font-size: 1.1rem;
    font-weight: 600;
    color: var(--color-text);
    text-align: right;
    width: 70px;
}

/* Collapsed: fade the hidden children. They keep their box space
   (clipped by the panel's overflow) so rc-slider's internal layout
   never sees a 0-width parent. */
.sim-float-controls:not(.is-expanded) .sim-float-divider,
.sim-float-controls:not(.is-expanded) .sim-float-slider-input,
.sim-float-controls:not(.is-expanded) .sim-float-readout {
    opacity: 0;
    pointer-events: none;
}

/* ===== §08 Results floating tolerance-threshold panel =====
   Same pill chrome as the §07 σ panel. The σ*-button slot is reused
   as an envelope on/off toggle; the slider drives the threshold
   percentile (0.05..0.30, default 0.20). Visibility tracks any
   `.tol-anchor` element in the viewport — script.js sets `.visible`.
   Future blocks (g_beta, etc.) that wrap their tolerance row in a
   `.tol-anchor` div opt into the same panel automatically. */
.tol-float-controls {
    position: fixed;
    left: 50%;
    bottom: 28px;
    transform: translate(-50%, 18px);
    z-index: 95;
    display: flex;
    align-items: center;
    gap: 18px;
    background: rgba(231, 228, 224, 0.66);
    backdrop-filter: blur(14px) saturate(140%);
    -webkit-backdrop-filter: blur(14px) saturate(140%);
    border: 1px solid rgba(255, 255, 255, 0.42);
    border-radius: 999px;
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14),
                0 2px 8px rgba(0, 0, 0, 0.06);
    padding: 14px 30px 14px 20px;
    opacity: 0;
    pointer-events: none;
    max-width: 510px;
    overflow: hidden;
    transition: opacity 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
                transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
                padding 520ms cubic-bezier(0.4, 0, 0.2, 1),
                gap 520ms cubic-bezier(0.4, 0, 0.2, 1),
                max-width 520ms cubic-bezier(0.4, 0, 0.2, 1);
}
.tol-float-controls.visible {
    opacity: 1;
    pointer-events: auto;
    transform: translate(-50%, 0);
}
.tol-float-controls:not(.is-expanded) {
    padding: 12px 18px 12px 16px;
    gap: 12px;
    max-width: 210px;
}

/* Chevron toggle — identical to sim-float-toggle. */
.tol-float-toggle {
    appearance: none;
    background: transparent;
    border: none;
    width: 36px;
    height: 36px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    padding: 0;
    color: var(--color-text-muted);
    transition: color 200ms ease;
    flex-shrink: 0;
}
.tol-float-toggle:hover { color: var(--color-text); }
.tol-float-chevron {
    width: 13px;
    height: 13px;
    border-top: 2.8px solid currentColor;
    border-right: 2.8px solid currentColor;
    transform: rotate(-135deg);
    transition: transform 520ms cubic-bezier(0.4, 0, 0.2, 1);
    margin-left: 4px;
}
.tol-float-controls:not(.is-expanded) .tol-float-chevron {
    transform: rotate(45deg);
    margin-left: -4px;
}

/* Envelope on/off button — occupies the σ*-button slot. The inner
   swatch is a CSS-drawn 18 px tile with the same `/` hatch pattern as
   the heatmap envelope so the button visually maps to what it controls.
   `.is-off` modifier (toggled by the clientside callback) fades the
   pill and swatch — visual signal that the envelope is hidden in the
   plot. */
.tol-float-envelope {
    appearance: none;
    border: none;
    background: rgba(205, 202, 195, 0.82);
    color: var(--color-text);
    padding: 9px 22px;
    border-radius: 999px;
    cursor: pointer;
    line-height: 1.1;
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background 200ms ease, transform 200ms ease,
                opacity 200ms ease, box-shadow 200ms ease;
}
.tol-float-envelope:hover {
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.14);
    filter: brightness(0.96);
}
.tol-float-envelope:active { transform: translateY(1px); }
.tol-float-envelope-swatch {
    width: 18px;
    height: 18px;
    border-radius: 4px;
    border: 1px solid rgba(0, 0, 0, 0.22);
    /* Diagonal hatch — same shape as Plotly's fillpattern shape="/". */
    background:
        repeating-linear-gradient(
            45deg,
            rgba(80, 80, 80, 0.78) 0,
            rgba(80, 80, 80, 0.78) 2px,
            rgba(255, 255, 255, 0.85) 2px,
            rgba(255, 255, 255, 0.85) 6px
        );
    transition: filter 200ms ease, opacity 200ms ease;
}
.tol-float-envelope.is-off {
    background: rgba(205, 202, 195, 0.55);
    opacity: 0.55;
}
.tol-float-envelope.is-off .tol-float-envelope-swatch {
    filter: grayscale(1);
    opacity: 0.55;
}

/* Slider + divider + readout — identical structure to the σ panel.
   Selectors are independent so the two panels stay decoupled even
   though they share visual language. */
.tol-float-divider,
.tol-float-slider-input,
.tol-float-readout {
    transition: opacity 240ms cubic-bezier(0.4, 0, 0.2, 1);
    flex-shrink: 0;
}
.tol-float-divider {
    width: 1px;
    height: 28px;
    background: rgba(0, 0, 0, 0.22);
    margin: 0 4px;
}
.tol-float-slider-input { width: 190px; }
.tol-float-slider-input .rc-slider {
    margin: 0;
    width: 100%;
    height: 18px;
}
.tol-float-slider-input .dash-input-container,
.tol-float-slider-input .dash-range-slider-input {
    display: none !important;
}
.tol-float-slider-input .dash-slider-track {
    background-color: rgba(0, 0, 0, 0.18) !important;
}
.tol-float-slider-input .dash-slider-range {
    background-color: var(--color-accent) !important;
}
.tol-float-slider-input [role="slider"] {
    background-color: #ffffff !important;
    border: 2px solid var(--color-accent) !important;
    box-shadow: none !important;
}
.tol-float-slider-input [role="slider"]:hover,
.tol-float-slider-input [role="slider"]:focus,
.tol-float-slider-input [role="slider"]:focus-visible {
    border-color: var(--color-accent) !important;
    box-shadow: 0 0 0 5px rgba(45, 90, 123, 0.2) !important;
    outline: none !important;
}
.tol-float-readout {
    font-variant-numeric: tabular-nums;
    font-size: 1.05rem;
    font-weight: 600;
    color: var(--color-text);
    text-align: right;
    width: 56px;
}

/* Collapsed: fade hidden children. They keep their box space
   (clipped by the panel's `overflow: hidden`). */
.tol-float-controls:not(.is-expanded) .tol-float-divider,
.tol-float-controls:not(.is-expanded) .tol-float-slider-input,
.tol-float-controls:not(.is-expanded) .tol-float-readout {
    opacity: 0;
    pointer-events: none;
}

/* ===== §03 Task-design N-slider floating panel =====
   Same chrome / mechanics as the σ + tolerance panels. The σ*-button
   slot is repurposed as a static "N=5/10/15" value badge so the
   audience always knows which chunk_size is active. Visibility is
   gated by IntersectionObserver on #task-detail-block — only the
   three slider-swappable cards trigger the panel, not the whole §03
   section. The slider itself is a 3-mark discrete picker (step=None,
   marks={5,10,15}). */
.task-n-float-controls {
    position: fixed;
    left: 50%;
    bottom: 28px;
    transform: translate(-50%, 18px);
    z-index: 95;
    display: flex;
    align-items: center;
    gap: 18px;
    background: rgba(231, 228, 224, 0.66);
    backdrop-filter: blur(14px) saturate(140%);
    -webkit-backdrop-filter: blur(14px) saturate(140%);
    border: 1px solid rgba(255, 255, 255, 0.42);
    border-radius: 999px;
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.14),
                0 2px 8px rgba(0, 0, 0, 0.06);
    padding: 14px 30px 14px 20px;
    opacity: 0;
    pointer-events: none;
    /* Expanded pill needs to fit: chevron + "20% Rare" badge +
       divider + slider track (200 px) + "CHUNK SIZE" readout.
       Generous max-width so the readout never clips against the
       pill's right edge regardless of OS font-rendering. */
    max-width: 640px;
    overflow: hidden;
    transition: opacity 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
                transform 280ms cubic-bezier(0.34, 1.56, 0.64, 1),
                padding 520ms cubic-bezier(0.4, 0, 0.2, 1),
                gap 520ms cubic-bezier(0.4, 0, 0.2, 1),
                max-width 520ms cubic-bezier(0.4, 0, 0.2, 1);
}
.task-n-float-controls.visible {
    opacity: 1;
    pointer-events: auto;
    transform: translate(-50%, 0);
}
.task-n-float-controls:not(.is-expanded) {
    padding: 12px 18px 12px 16px;
    gap: 12px;
    /* "20% Rare" / "10% Rare" / "6% Rare" — the longest variant fits
       comfortably inside a 240px collapsed pill alongside the chevron. */
    max-width: 240px;
}
.task-n-float-toggle {
    appearance: none;
    background: transparent;
    border: none;
    width: 36px;
    height: 36px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    padding: 0;
    color: var(--color-text-muted);
    transition: color 200ms ease;
    flex-shrink: 0;
}
.task-n-float-toggle:hover { color: var(--color-text); }
.task-n-float-chevron {
    width: 13px;
    height: 13px;
    border-top: 2.8px solid currentColor;
    border-right: 2.8px solid currentColor;
    transform: rotate(-135deg);
    transition: transform 520ms cubic-bezier(0.4, 0, 0.2, 1);
    margin-left: 4px;
}
.task-n-float-controls:not(.is-expanded) .task-n-float-chevron {
    transform: rotate(45deg);
    margin-left: -4px;
}
/* N=… badge — sits in the σ*-button slot, but as a static pill not
   a clickable button. Uses the site's strong-pill recipe so the
   "N=5" text reads as a value, not chrome. */
.task-n-float-badge {
    background: #2a2724;
    color: #f1ede5;
    padding: 7px 16px;
    border-radius: 999px;
    font-family: var(--font-mono, monospace);
    font-size: 0.98rem;
    font-weight: 700;
    letter-spacing: 0.02em;
    line-height: 1.1;
    flex-shrink: 0;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
}
.task-n-float-divider,
.task-n-float-slider-input,
.task-n-float-readout {
    transition: opacity 240ms cubic-bezier(0.4, 0, 0.2, 1);
    flex-shrink: 0;
}
.task-n-float-divider {
    width: 1px;
    height: 28px;
    background: rgba(0, 0, 0, 0.22);
    margin: 0 4px;
}
.task-n-float-slider-input { width: 200px; }
.task-n-float-slider-input .rc-slider {
    margin: 0;
    width: 100%;
    height: 18px;
}
.task-n-float-slider-input .dash-input-container,
.task-n-float-slider-input .dash-range-slider-input {
    display: none !important;
}
.task-n-float-slider-input .dash-slider-track {
    background-color: rgba(0, 0, 0, 0.18) !important;
}
.task-n-float-slider-input .dash-slider-range {
    background-color: var(--color-accent) !important;
}
.task-n-float-slider-input [role="slider"] {
    background-color: #ffffff !important;
    border: 2px solid var(--color-accent) !important;
    box-shadow: none !important;
}
.task-n-float-slider-input [role="slider"]:hover,
.task-n-float-slider-input [role="slider"]:focus,
.task-n-float-slider-input [role="slider"]:focus-visible {
    border-color: var(--color-accent) !important;
    box-shadow: 0 0 0 5px rgba(45, 90, 123, 0.2) !important;
    outline: none !important;
}
.task-n-float-readout {
    font-size: 0.86rem;
    color: var(--color-text-muted);
    text-align: right;
    /* Needs to fit "CHUNK SIZE" (10 chars + uppercase + 0.04em
       letter-spacing) without clipping. flex-basis floor + nowrap
       so the text stays on one line and the pill's overflow:hidden
       never trims the trailing characters. */
    flex: 0 0 auto;
    min-width: 130px;
    white-space: nowrap;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    font-weight: 600;
}
.task-n-float-controls:not(.is-expanded) .task-n-float-divider,
.task-n-float-controls:not(.is-expanded) .task-n-float-slider-input,
.task-n-float-controls:not(.is-expanded) .task-n-float-readout {
    opacity: 0;
    pointer-events: none;
}

/* ===== §03 N-variants — CSS swap only =====
   Three .task-n-variant siblings render at boot inside
   .task-n-variant-stack; the floating slider flips `.is-active`
   between them via a clientside callback. Hidden variants are
   `display: none` so they don't occupy layout space — important
   because each variant has its own 2×2 grid and stacking them
   visibly would distort §03's vertical rhythm. */
.task-n-variant-stack {
    display: block;
}
.task-n-variant {
    display: none;
    /* Even though display:none drops the variant from layout/paint, the
       browser still keeps the DOM nodes "active" for things like CSS
       animations + transitions. content-visibility: hidden tells the
       browser to skip rendering work for the subtree until the property
       is removed — large gains given the hidden N=10/N=15 grids hold
       ~5200 matrix-cell divs combined. */
    content-visibility: hidden;
}
.task-n-variant.is-active {
    display: block;
    content-visibility: visible;
}
/* Width-containment chain. Without this, the matrix grid at n=40 /
   n=60 wants to be wider than the 1fr column allows (label text has
   intrinsic width). That pushes the left task-stack wider, and the
   `1fr 1fr` outer grid mirrors that on the right column — so the
   markov-chain + per-event-probability cards LOOK like they extend
   too, even though their heights are pinned. min-width: 0 lets each
   grid item shrink to its 1fr share, locking all three cards at the
   n=5 outer pixel size across the slider range. */
.task-n-variant .viz-grid--2col > .task-stack {
    min-width: 0;
}
.task-n-variant .task-stack > .task-block,
.task-n-variant .task-stack > .task-block--titled {
    min-width: 0;
    max-width: 100%;
}
.task-n-variant .task-graph-card,
.task-n-variant .task-matrix-card {
    min-width: 0;
    max-width: 100%;
    width: 100%;
    box-sizing: border-box;
}
.task-n-variant .task-matrix-axes,
.task-n-variant .task-matrix-grid,
.task-n-variant .task-matrix-axes__top,
.task-n-variant .task-matrix-axes__left {
    min-width: 0;
    min-height: 0;
}
/* Axis labels at n=40 / n=60: cells become too small to host any
   readable digit, so we hide the label tracks entirely. The Python
   side already emits empty divs, but `display: none` on the divs
   collapses the grid track auto-min so the cell grid below uses the
   full width. */
.task-n-variant[data-n="10"] .task-matrix-axes__label--top,
.task-n-variant[data-n="10"] .task-matrix-axes__label--left,
.task-n-variant[data-n="15"] .task-matrix-axes__label--top,
.task-n-variant[data-n="15"] .task-matrix-axes__label--left {
    display: none;
}
/* Cell breathing-pulse is the most expensive thing on the page at
   N≥40 (1600 / 3600 cells). Scope the keyframe so it only runs on
   active variants AND only on N=5 (audience reads "denser, sparser,
   all-the-way-sparse" structure visually without the pulse helping).
   Hidden variants pay zero animation cost; active N=10 / N=15 stay
   un-animated so the slider drag remains responsive. */
.task-n-variant[data-n="10"] .task-matrix__cell.is-edge,
.task-n-variant[data-n="15"] .task-matrix__cell.is-edge,
.task-n-variant:not(.is-active) .task-matrix__cell.is-edge {
    animation: none;
}

/* ===== Placeholder figure (dark stage panel) ===== */
.placeholder-figure {
    margin-top: var(--space-md);
    padding: var(--space-lg) var(--space-md);
    background: var(--color-stage-bg);
    color: var(--color-stage-fg);
    border-radius: 10px;
    text-align: center;
    font-family: var(--font-mono);
    font-size: 0.9rem;
    min-height: 260px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-xs);
    box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
}

.placeholder-figure .ph-label {
    font-size: 0.7rem;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: #888;
}

.placeholder-figure .ph-title {
    font-family: var(--font-display);
    font-size: 1.4rem;
    color: #f0efeb;
}

.placeholder-figure .ph-hint {
    font-size: 0.85rem;
    color: #888;
    max-width: 440px;
}

/* ===== §01 Background — Block-2 placeholder variants =====
   Warm terracotta variant of `.placeholder-figure` used by §01
   Block 2 (Under / Good / Over estimation columns). Reads as a
   distinct concept from Block 1's default blue. `--short` halves
   the min-height so two stacked cards fit comfortably within one
   column without towering over the title row. */
.placeholder-figure--terracotta {
    background: var(--color-accent-warm);
    color: #ffffff;
}
.placeholder-figure--short {
    min-height: 130px;
    padding: var(--space-md);
}

/* §01 Block 1 — vertical stack: manga card on top, prose below.
   Simple flex column with the section-level gap between children. */
.bg-block1-stack {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
}
.bg-block1-text {
    padding: 0;
}
/* Bump the inline-typewriter prose only inside §01 Block 1. The
   base size (`.rq-card--inline .rq-typed`, clamp 1.05–1.35 rem) is
   tuned for tight tile captions; here the box sits on its own at
   full block width, so it needs more presence to read from the
   back of a lecture room. Override is scoped to .bg-block1-text
   so the §05 BCPNN block 1 captions stay at their original size. */
.bg-block1-text .rq-card--inline .rq-typed {
    font-size: clamp(1.3rem, 2vw, 1.75rem);
    line-height: 1.55;
}

/* White card wrapper for Block 1's manga slot — same chrome as
   Block 2's column cards so the two blocks read as one card family.
   The image inside fills it edge-to-edge. */
.bg-block1-card {
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05),
                0 1px 3px rgba(0, 0, 0, 0.03);
    padding: var(--space-md);
    display: flex;
    align-items: center;
    justify-content: center;
}
.bg-block1-manga {
    width: 100%;
    height: auto;
    display: block;
    border-radius: 8px;
    object-fit: contain;
}

/* §01 Block 2 — 3-column grid (Under / Good / Over). Each column is a
   white parent card wrapping two stacked terracotta plot tiles + a
   bullet list. Card chrome (background, border, soft shadow, padding)
   ties the three children together as one unit. Collapses to single
   column under 900 px. */
.bg-est-grid {
    grid-template-columns: 1fr 1fr 1fr;
}
.bg-est-column {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05),
                0 1px 3px rgba(0, 0, 0, 0.03);
    padding: var(--space-md);
    /* Spotlight hover effect: all three cards transition smoothly when
       the cursor enters / leaves one of them. opacity drives the
       dim-siblings effect (driven by the :has rule below); transform
       lifts the hovered card; box-shadow deepens to read as raised. */
    transition: opacity 220ms ease,
                transform 220ms ease,
                box-shadow 220ms ease;
}
/* Spotlight hover — only fires when an actual card is hovered (not on
   the grid gap). :has() reaches back up to the parent grid; the
   `:not(:hover)` filter scopes the dim effect to the *other* cards
   so the hovered one keeps full opacity without a specificity war.
   The hovered card also lifts + deepens its shadow. The inner
   terracotta tiles brighten (rule below) so the hovered card reads
   as "alive" against the dimmed siblings. */
.bg-est-grid:has(.bg-est-column:hover) .bg-est-column:not(:hover) {
    opacity: 0.45;
}
.bg-est-column:hover {
    transform: translateY(-2px);
    box-shadow: 0 10px 28px rgba(0, 0, 0, 0.12),
                0 2px 6px rgba(0, 0, 0, 0.06);
}
/* Inner placeholder tiles brighten when their parent card is hovered.
   `filter: brightness` keeps the colour family identical (just a
   touch lighter) so the hovered card stays palette-consistent with
   the dimmed siblings. */
.bg-est-column:hover .placeholder-figure--terracotta {
    filter: brightness(1.08);
    transition: filter 220ms ease;
}
.bg-est-column .placeholder-figure--terracotta {
    transition: filter 220ms ease;
}
/* §01 Block 2 card title — dark pill at the top of each white card.
   Mirrors §08's Mechanism-plots corner badges (`.wheel-item-badge`):
   same #1a1a1a fill, cream text, Source Sans 3 600, pill radius,
   soft shadow. The wheel-badge version is `position: absolute` to
   sit in a corner; this version is a normal-flow flex child that
   centres horizontally via `align-self: center` (the parent column
   is flex-direction: column). */
.bg-est-card-title {
    align-self: center;
    background: #1a1a1a;
    color: #f7f4ee;
    font-family: 'Source Sans 3', system-ui, sans-serif;
    font-size: 1.35rem;
    font-weight: 800;
    letter-spacing: 0.01em;
    line-height: 1.1;
    padding: 8px 15px;
    border-radius: 999px;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.20);
}

/* The two real plot images per column. Display block so they don't
   inherit the inline baseline gap; full-width with auto height so
   they preserve their intrinsic aspect ratio. Soft rounded corners
   tie them to the parent card's chrome. */
.bg-est-plot {
    width: 100%;
    height: auto;
    display: block;
    border-radius: 8px;
}
/* Tighten the gap between the TWO stacked images only. The flex
   `gap` on `.bg-est-column` is used between every pair of children
   (title → image 1, image 1 → image 2, image 2 → bullets); the
   negative top margin here cancels most of the flex gap just for
   image-to-image, so the two plots read as a paired set. */
.bg-est-plot + .bg-est-plot {
    margin-top: calc(-1 * var(--space-md) + 0.4rem);
}
/* Inner-tile brightness hover lift now targets the real images
   instead of the terracotta placeholder fills. Keeps the spotlight
   effect alive after the real data swap. */
.bg-est-column:hover .bg-est-plot {
    filter: brightness(1.04);
}
.bg-est-plot {
    transition: filter 220ms ease;
}

.bg-est-bullet {
    margin: 0;
    padding-left: 1.4rem;
    list-style: disc;
    font-family: var(--font-body);
    font-size: 1.15rem;
    line-height: 1.55;
    color: var(--color-text);
    font-style: italic;
}
.bg-est-bullet li {
    margin-bottom: 0.45rem;
}
@media (max-width: 900px) {
    .bg-est-grid {
        grid-template-columns: 1fr;
    }
}

/* ===== Visualization blocks (per-section content) ===== */
/* Block is purely structural — provides the title and grid spacing for a
   group of related tiles. No background, so each tile reads as the card. */
.viz-block {
    margin-top: var(--space-lg);
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
}
/* Long thin in-block divider — visual seam inside a single .viz-block
   when it groups two related-but-distinct subjects (e.g. §04 Block 2:
   event-raster sequence ↑↑ probability summaries ↓↓). Spans the full
   block width; vertical rhythm comes from the parent flex gap rather
   than this rule, so the margins stay 0 here. */
.viz-divider {
    border: 0;
    border-top: 1px solid var(--color-border);
    width: 100%;
    margin: 0;
}
/* Stack-card: outer white card that hosts two stacked .viz-tile
   children with a thin horizontal divider between them. Vertical
   counterpart of .viz-pair-card. The inner tiles shed their own
   chrome (so the stack reads as one card); the divider naturally
   inherits the card's content-area width via the flex column. */
.viz-stack-card {
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
    padding: var(--space-md);
    display: flex;
    flex-direction: column;
    row-gap: var(--space-md);
}
.viz-stack-card .viz-tile {
    background: transparent;
    border: 0;
    box-shadow: none;
    padding: 0;
    border-radius: 0;
}

/* Pair-card: outer white card that hosts two side-by-side .viz-tile
   children with a thin inset vertical divider between them. The
   inner tiles shed their own chrome inside this wrapper so the pair
   reads as one card. Two width variants:
     --1-2  →  left=1fr / right=2fr (e.g. PvF | F-ratio)
     --2-1  →  left=2fr / right=1fr (e.g. Per-bucket | tx4)
   The divider sits in its own auto-width grid track between the
   two tiles, vertically inset via top/bottom margin so it doesn't
   touch the card edges. */
.viz-pair-card {
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
    padding: var(--space-md);
    display: grid;
    column-gap: var(--space-md);
    align-items: stretch;
}
.viz-pair-card--1-2 {
    grid-template-columns: 1fr auto 2fr;
}
.viz-pair-card--2-1 {
    grid-template-columns: 2fr auto 1fr;
}
.viz-pair-card .viz-tile {
    background: transparent;
    border: 0;
    box-shadow: none;
    padding: 0;
    border-radius: 0;
}
.viz-pair-divider {
    width: 1px;
    background: var(--color-border);
    margin: var(--space-md) 0;
    align-self: stretch;
}
/* Shared caption row inside a pair-card. Spans all three grid
   columns (tile · divider · tile) so a single caption can cover
   both plots. The auto-placed grid track gets a top margin to
   separate it from the plot row above. Used in §04 Performance
   Measurements Lv1 row.

   Height is LOCKED so the typewriter cycle (text length oscillates
   between 0 and full each loop) doesn't pump the pair-card. Two
   lines at the inline-rq max font (~1.35rem * 1.5 line-height ≈
   32px each = 64px total) fit comfortably inside 4.5rem with a
   little breathing room above/below. */
.viz-pair-caption {
    grid-column: 1 / -1;
    margin-top: var(--space-sm);
    padding: 0 0.5rem;
    height: 4.5rem;
    overflow: hidden;
}
.viz-pair-caption .rq-stage--inline {
    margin-top: 0;
}

.viz-block-title {
    font-family: var(--font-display);
    font-size: var(--viz-block-title-size);
    color: var(--color-text);
    margin: 0;
    padding-bottom: var(--space-xs);
    border-bottom: 1px solid var(--color-border);
}

/* Two-column wrapper for stacking sibling viz-blocks side-by-side at
   desktop widths. Used by §06 Training to put Blocks 1 + 2 in a row,
   with Block 3 (collapsible) underneath at full width. */
.viz-block-row {
    margin-top: var(--space-lg);
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-md);
}

.viz-block-row > .viz-block {
    /* row already provides top margin; kill the block-level top margin */
    margin-top: 0;
}

@media (max-width: 768px) {
    .viz-block-row {
        grid-template-columns: 1fr;
    }
}

/* ===== §06 Training — plot placeholders + collapsible Block 3 ===== */
.training-plot-placeholder {
    width: 100%;
    aspect-ratio: 4 / 3;
    background: var(--color-accent);
    border-radius: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #ffffff;
    font-family: var(--font-display);
    font-size: 1.5rem;
    margin-top: var(--space-sm);
}

/* Block 3 stretches wider than blocks 1 & 2, so the plot inside it
   takes a much wider aspect — gives the W matrix / bias bars room to
   spread the 20 MCs horizontally. */
.viz-block--collapsible .training-plot-placeholder {
    aspect-ratio: 4 / 1;
}

/* Live panel hosts (data-bcpnn-scene). The orchestrator dynamic-imports
   /assets/bcpnn/panels/<scene>.js and mounts a canvas or SVG into the
   host. While the panel is loading the accent-blue placeholder + label
   are still visible (matching the static placeholder); once mounted,
   the panel adds .plasticity-a-host / .plasticity-b-host which strips
   the accent backdrop so canvas / curves read on a neutral surface. */
.training-plot-host {
    cursor: default;
    position: relative;  /* containing block for overlay buttons */
    overflow: hidden;
}

.training-plot-host.plasticity-a-host {
    background: var(--color-bg-alt);
    color: transparent;
}

.training-plot-host.plasticity-b-host {
    background: var(--color-bg-alt);
    color: transparent;
    /* Two-row flex layout so .plot-b-top (curves) and .plot-b-bot
       (supports chart) can transition heights independently during
       C6 Part 3 split. Padding stays 0 — inner divs own padding. */
    display: flex;
    flex-direction: column;
    padding: 0;
    /* Keep the 4:3 aspect from .training-plot-placeholder so Block 1
       and Block 2 line up. The SVG's natural 600×320 viewBox is
       centered with preserveAspectRatio="xMidYMid meet" — gives a
       little vertical breathing room above/below the curves. */
}

/* Plot B1 subplot split (C6 Part 3). Default: top owns 100%, bot owns 0%
   (collapsed, no supports chart visible). When the panel toggles
   .is-split the heights swap to 50/50 over 1000 ms — matches the C6
   Part 3 window the curve regeneration is keyed off of. Paddings
   mirror scratch's .plot-b-top / .plot-b-bot rules. */
.plasticity-b-host .plot-b-top,
.plasticity-b-host .plot-b-bot {
    width: 100%;
    min-height: 0;
    overflow: hidden;
    transition: height 1000ms ease-in-out;
}
.plasticity-b-host .plot-b-top {
    height: 100%;
    padding: 12px 14px;
    box-sizing: border-box;
}
.plasticity-b-host .plot-b-bot {
    height: 0;
    padding: 0 14px 8px;
    box-sizing: border-box;
}
.plasticity-b-host.is-split .plot-b-top { height: 50%; }
.plasticity-b-host.is-split .plot-b-bot { height: 50%; }
/* Force the supports canvas to fill its host (mirrors scratch's
   #plot-b-supports-host canvas rule): prevents inline pixel sizes from
   lagging during the .is-split height transition and leaving a white
   gap inside the bottom subplot. */
.plot-b-supports-host {
    width: 100%;
    height: 100%;
}
.plot-b-supports-host canvas {
    display: block;
    width: 100% !important;
    height: 100% !important;
}

/* Plot C1 (W matrix). Default height matches scratch's #plot-c (360 px,
   wide-flat for the held-1 view). During C6 Part 1 the wrapper picks
   up .is-fullsize and the host grows to 720 px (transition matches the
   C6 Part 1 window ~1000 ms) so the matrix landscape can be inspected
   in top-down view. aspect-ratio: auto overrides the 4:1 cap from the
   placeholder rule, since this host is height-driven, not ratio-driven. */
.training-plot-host.w-matrix-host {
    background: var(--color-bg-alt);
    color: transparent;
    aspect-ratio: auto;
    height: 360px;
    transition: height 1000ms ease-in-out;
}
.viz-block--collapsible.is-fullsize .training-plot-host.w-matrix-host {
    height: 720px;
}

.training-plot-host canvas,
.training-plot-host svg {
    display: block;
    border-radius: 20px;
}

.training-caption {
    font-style: italic;
    color: var(--color-text-muted);
    text-align: left;
}
/* `_training_caption(disabled=True)` keeps the node in the DOM (so the
   §06 layout can be revisited without retyping prose) but hides it
   visually. `display: none` also collapses its space — flip back by
   removing the `disabled=True` flag in app.py. */
.training-caption.is-disabled {
    display: none;
}

/* ===== §06 Block 4 — GT vs Learned comparison ===== */
/* 2-col × 3-row CSS grid: header row labels each column (GT / Learned)
   once, then row 1 stacks the two 20×20 connectivity matrices side by
   side, and row 2 stacks the two bar plots. Auto-flow `row` so the DOM
   order (col-label × 2, then matrix × 2, then bars × 2) lands cleanly
   without explicit grid-area placement. */
.gt-learned-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-md);
    align-items: start;
    grid-auto-flow: row;
}
/* Column headers borrow the keyword-pill look (dark bg + cream text)
   from the .rq-key style used by the typewriter system — same visual
   language, no animation. The outer .gt-learned-col-label cell stays
   full-width + center-aligned; the inner <span> picks up the pill
   chrome so it hugs the text. */
.gt-learned-col-label {
    text-align: center;
    padding: 0.2rem 0 0.6rem;
    font-family: var(--font-body);
    font-size: clamp(1.1rem, 1.45vw, 1.3rem);
    font-weight: 700;
    text-transform: uppercase;
    letter-spacing: 0.08em;
}
.gt-learned-col-label > span {
    display: inline-block;
    background: #1a1a1a;
    color: #f6f1e6;
    padding: 0.22em 0.7em;
    border-radius: 0.45em;
    box-shadow: 0 3px 9px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18);
}
@media (max-width: 880px) {
    .gt-learned-grid {
        grid-template-columns: 1fr;
    }
}

/* ===== §07 Simulation — placeholder tile grid ====================== */
/* Two case-study blocks stacked vertically. Each block holds two rows
   (.sim-row) of two tiles. Row 1 can take an asymmetric ratio modifier
   (2-1 puts the raster on the left, 1-2 on the right) so the larger
   raster always claims 2/3 of the row's width. Tile height is locked
   to 240 px — matches RASTER_HEIGHT in §02 Input Generation so the
   eventual Plotly raster drops in without a layout shift. */
.sim-stack {
    margin-top: var(--space-md);
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
}

.sim-row {
    display: grid;
    gap: var(--space-md);
}

.sim-row--1-1 { grid-template-columns: 1fr 1fr; }
.sim-row--2-1 { grid-template-columns: 2fr 1fr; }
.sim-row--1-2 { grid-template-columns: 1fr 2fr; }

/* Half-height row variant — used for the f-ratio + buckets bottom row.
   Shorter tiles let the larger raster on the top row carry the focus
   while the supporting fidelity metrics stay legible underneath. */
.sim-row--short .sim-tile { height: 240px; }

/* Asymmetric top grid — two columns (3fr | 2fr), two rows (480 | 240).
   Left column splits into raster (top) + short text box (bottom). Right
   column is a single tall text-box cell that spans both rows. Mirror
   variant flips column 1 <-> column 2 for the under-estimation block. */
.sim-grid-top {
    display: grid;
    grid-template-columns: 2fr 1fr;
    grid-template-rows: 480px 144px;
    gap: var(--space-md);
}
.sim-grid-top__cell { min-width: 0; min-height: 0; }
.sim-grid-top__cell--left-top    { grid-column: 1; grid-row: 1; }
.sim-grid-top__cell--left-bottom { grid-column: 1; grid-row: 2; }
.sim-grid-top__cell--right       { grid-column: 2; grid-row: 1 / span 2; }
.sim-grid-top--mirror            { grid-template-columns: 1fr 2fr; }
.sim-grid-top--mirror .sim-grid-top__cell--left-top    { grid-column: 2; }
.sim-grid-top--mirror .sim-grid-top__cell--left-bottom { grid-column: 2; }
.sim-grid-top--mirror .sim-grid-top__cell--right       { grid-column: 1; }
.sim-grid-top .sim-tile { height: 100%; }

/* Title row for each simulation block: block label on the left, the
   Play / Reset controls right-aligned. The border-bottom that normally
   sits on .viz-block-title moves up here so the underline spans the
   full row (title + buttons), not just the title text. */
.sim-block-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-md);
    padding-bottom: var(--space-xs);
    border-bottom: 1px solid var(--color-border);
}

.sim-block-head .viz-block-title {
    border-bottom: none;
    padding-bottom: 0;
}

.sim-block-controls {
    display: flex;
    align-items: center;
    gap: var(--space-xs);
    flex-shrink: 0;
}

/* Modern icon-only round buttons. The icon comes from a CSS-masked SVG
   in /assets/icons/ — mask-image + background-color: currentColor lets
   us swap the colour with the .is-playing state and animate transforms
   without touching glyph metrics. Single shared base class
   (.sim-icon-btn) gives the circular geometry + transition curve;
   modifier classes (--play / --reset) wire in the icon URL and colour
   story. */
.sim-icon-btn {
    width: 40px;
    height: 40px;
    padding: 0;
    border-radius: 50%;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
    transition:
        transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1),
        background-color 0.25s ease,
        border-color 0.25s ease,
        color 0.25s ease,
        box-shadow 0.25s ease;
}

/* The icon itself: pseudo-element masked by an SVG. background-color
   takes currentColor so the icon picks up the button's text colour
   (white on the play button, accent-blue on hover for reset, etc.). */
.sim-icon-btn::before {
    content: '';
    display: block;
    width: 16px;
    height: 16px;
    background-color: currentColor;
    -webkit-mask: var(--sim-icon, none) center / contain no-repeat;
            mask: var(--sim-icon, none) center / contain no-repeat;
}

.sim-icon-btn--play             { --sim-icon: url('icons/play.svg'); }
.sim-icon-btn--play.is-playing  { --sim-icon: url('icons/pause.svg'); }
.sim-icon-btn--play::before     { width: 20px; height: 20px; }
.sim-icon-btn--reset            { --sim-icon: url('icons/reset.svg'); }
.sim-icon-btn--reset::before    { width: 18px; height: 18px; }
.sim-icon-btn--fit              { --sim-icon: url('icons/fit.svg'); }
.sim-icon-btn--fit::before      { width: 18px; height: 18px; }

/* Fit button — outlined (same hue story as reset) but with a small
   inward-pinch animation on hover instead of the reset spin. The
   four corner brackets in the icon naturally suggest "snap to fit",
   and the slight scale-down hint reinforces it. */
.sim-icon-btn--fit {
    background: var(--color-bg);
    color: var(--color-text-muted);
    border: 1px solid var(--color-border);
}
.sim-icon-btn--fit:hover {
    transform: translateY(-2px) scale(0.94);
    background: var(--color-accent-light);
    border-color: var(--color-accent);
    color: var(--color-accent);
    box-shadow: 0 6px 18px rgba(45, 90, 123, 0.14);
}
.sim-icon-btn--fit:active {
    transform: translateY(0) scale(0.88);
}
.sim-icon-btn:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}

/* Play / Pause — filled, accent-blue. */
.sim-icon-btn--play {
    background: var(--color-accent);
    color: #ffffff;
    border: none;
    box-shadow: 0 1px 2px rgba(45, 90, 123, 0.18),
                0 2px 6px rgba(45, 90, 123, 0.10);
}
.sim-icon-btn--play:hover {
    transform: translateY(-2px) scale(1.05);
    box-shadow: 0 6px 18px rgba(45, 90, 123, 0.30),
                0 2px 6px rgba(45, 90, 123, 0.16);
}
.sim-icon-btn--play:active {
    transform: translateY(0) scale(0.98);
    box-shadow: 0 1px 3px rgba(45, 90, 123, 0.30);
}
/* "Playing" state — flip to warm accent so a quick glance tells you the
   sim is live. The shadow recolours to match. */
.sim-icon-btn--play.is-playing {
    background: var(--color-accent-warm);
    box-shadow: 0 1px 2px rgba(196, 69, 54, 0.20),
                0 2px 6px rgba(196, 69, 54, 0.12);
}
.sim-icon-btn--play.is-playing:hover {
    box-shadow: 0 6px 18px rgba(196, 69, 54, 0.32),
                0 2px 6px rgba(196, 69, 54, 0.18);
}

/* Reset — outlined / muted at rest; on hover the entire button spins
   half a turn (rotation = the visual cue for "reset") while lifting
   and picking up the accent colour. The springy timing curve on the
   base class gives the rotation a small overshoot. */
.sim-icon-btn--reset {
    background: var(--color-bg);
    color: var(--color-text-muted);
    border: 1px solid var(--color-border);
}
.sim-icon-btn--reset:hover {
    transform: translateY(-2px) rotate(-180deg);
    background: var(--color-accent-light);
    border-color: var(--color-accent);
    color: var(--color-accent);
    box-shadow: 0 6px 18px rgba(45, 90, 123, 0.14);
}
.sim-icon-btn--reset:active {
    transform: translateY(0) rotate(-180deg) scale(0.96);
}

/* One-line "passport" under each block title — declares which PTS file
   drives the simulation in that block, plus key training params. Uses
   muted text + monospace for the filename so it reads as metadata, not
   prose. */
.sim-block-meta {
    margin-top: var(--space-xs);
    font-size: 0.85rem;
    color: var(--color-text-muted);
    line-height: 1.4;
}
.sim-block-meta__label {
    font-weight: 600;
    color: var(--color-text);
}
.sim-block-meta__file {
    font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
    font-size: 0.82rem;
    word-break: break-all;
}
.sim-block-meta__missing {
    color: #c0392b;
}

.sim-tile {
    height: 480px;
    background: var(--color-accent);
    border-radius: 20px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: #ffffff;
    font-family: var(--font-display);
    font-size: 1.05rem;
    text-align: center;
    padding: 0 var(--space-md);
}

/* Commentary tile: sigma-driven prose card. Cream-on-near-black so
   it reads as a "panel commentary" panel rather than the section
   background — matches the dark pill aesthetic used elsewhere. The
   typewriter inside renders its own .rq-card chrome, but the wrapper
   handles the alignment + breathing room around the text. */
.sim-tile.sim-tile--commentary {
    background: var(--color-bg);
    color: var(--color-text);
    font-family: var(--font-body);
    text-align: left;
    align-items: flex-start;
    justify-content: flex-start;
    padding: var(--space-md);
    overflow: hidden;
    border: 1px solid rgba(0, 0, 0, 0.06);
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.sim-tile.sim-tile--commentary .rq-stage--inline {
    margin-top: 0;
}
.sim-tile.sim-tile--commentary .rq-card--inline .rq-typed {
    font-size: clamp(1.0rem, 1.4vw, 1.2rem);
    line-height: 1.5;
    color: var(--color-text);
}

/* Live-figure tile: drops the accent backdrop & label styling so a
   Plotly figure can sit on the section background (same trick the
   §06 plot hosts use). dcc.Graph injects its own internal Plotly
   container; we just have to stop wrapping it in our placeholder
   chrome. */
.sim-tile.sim-tile--graph {
    background: transparent;
    border-radius: 0;
    color: inherit;
    font: inherit;
    padding: 0;
    text-align: left;
    display: block;
    overflow: hidden;
    /* containing block for the absolute-positioned .viz-tile-legend */
    position: relative;
}
.sim-tile.sim-tile--graph .js-plotly-plot {
    width: 100% !important;
    height: 100% !important;
}

/* Topology-stack tile — fills the tall right cell of .sim-grid-top with
   two stacked Markov-graph panels: static GT (top) + live replay graph
   (bottom). No accent backdrop on the outer .sim-tile; each inner panel
   gets its own bordered card. */
.sim-tile.sim-tile--topo-stack {
    background: transparent;
    border-radius: 0;
    color: inherit;
    font: inherit;
    padding: 0;
    text-align: left;
    display: flex;
    flex-direction: column;
    /* .sim-tile base sets align-items: center + justify-content: center
       (so the placeholder text centers inside the accent box). For the
       topology stack we want the two panels to FILL the cell width — i.e.
       stretch on the cross-axis (horizontal, since flex-direction is
       column) and start at the top, not center vertically. */
    align-items: stretch;
    justify-content: flex-start;
    gap: var(--space-sm);
    overflow: hidden;
    width: 100%;
}
.sim-topo-panel {
    flex: 1 1 0;
    min-height: 0;
    min-width: 0;
    display: flex;
    flex-direction: column;
    /* Was hardcoded #faf9f7. Read from --color-bg so paper-mode
       (body.paper-mode overrides --color-bg to #ffffff) flips this
       panel chrome along with the rest of the page. */
    background: var(--color-bg);
    border: none;
    border-radius: 12px;
    padding: 6px 4px 2px;
    overflow: hidden;
}
.sim-topo-panel__label {
    font-family: var(--font-display);
    font-size: 1.05rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--color-text);
    text-transform: uppercase;
    flex-shrink: 0;
    margin-bottom: 4px;
}
/* dcc.Graph renders <div class="dash-graph"> wrapping
   <div class="js-plotly-plot"> wrapping <div class="plot-container">.
   All three need flex-grow + 100% sizes for the SVG inside to fill the
   panel — Plotly's `responsive: true` resize-observer reads the
   innermost size to draw the chart. */
.sim-topo-panel .dash-graph,
.sim-topo-panel .sim-graph {
    flex: 1 1 0;
    min-height: 0;
    min-width: 0;
    width: 100% !important;
    height: 100% !important;
    display: block;
}
.sim-topo-panel .js-plotly-plot,
.sim-topo-panel .plot-container,
.sim-topo-panel .svg-container {
    width: 100% !important;
    height: 100% !important;
}

/* Multi-tab variant of the top topology panel. Header (arrows + label)
   pinned top, dots pinned bottom, body fills the middle. Only the
   .is-active panel is visible; siblings are display:none so Plotly only
   measures the one in view. */
.sim-topo-panel--tabs {
    position: relative;
    padding: 6px 4px 4px;
}
.sim-topo-tabs__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-sm);
    flex-shrink: 0;
    padding: 0 4px;
    margin-bottom: 4px;
}
.sim-topo-tabs__arrow {
    appearance: none;
    background: rgba(0, 0, 0, 0.04);
    border: none;
    border-radius: 999px;
    width: 22px;
    height: 22px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    font-family: var(--font-display);
    font-size: 1.05rem;
    font-weight: 700;
    line-height: 1;
    color: var(--color-text);
    cursor: pointer;
    transition: background 120ms ease, transform 120ms ease;
    padding: 0;
    flex-shrink: 0;
}
.sim-topo-tabs__arrow:hover {
    background: rgba(0, 0, 0, 0.10);
}
.sim-topo-tabs__arrow:active {
    transform: scale(0.92);
}
.sim-topo-tabs__arrow:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}
.sim-topo-tabs__label {
    font-family: var(--font-display);
    font-size: 1.05rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--color-text);
    text-transform: uppercase;
    text-align: center;
    flex: 1 1 auto;
    min-width: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}
.sim-topo-tabs__body {
    position: relative;
    flex: 1 1 0;
    min-height: 0;
    min-width: 0;
    display: block;
}
.sim-topo-tabs__panel {
    display: none;
    width: 100%;
    height: 100%;
    flex-direction: column;
}
.sim-topo-tabs__panel.is-active {
    display: flex;
}
.sim-topo-tabs__placeholder {
    flex: 1 1 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--color-text-muted);
    font-style: italic;
    font-size: 0.95rem;
}
/* Two-cell split for the "Bias + values" tab. Top cell (bias) + bottom
   cell (enlarged values) share the panel 50/50 with a thin gap. Each
   cell wears its own small caption above its slot. */
.sim-topo-tabs__split {
    flex: 1 1 0;
    min-height: 0;
    display: flex;
    flex-direction: column;
    gap: 6px;
}
.sim-topo-tabs__split-cell {
    flex: 1 1 0;
    min-height: 0;
    display: flex;
    flex-direction: column;
    /* 2% dark overlay gives each Bayesian sub-cell (Prior / Likelihood
       / Posterior) a subtle card feel against the panel chrome in
       default mode. Stripped under paper mode below so the whole
       panel reads as flat white for export. */
    background: rgba(0, 0, 0, 0.02);
    border-radius: 8px;
    padding: 4px 6px 6px;
}
body.paper-mode .sim-topo-tabs__split-cell {
    background: transparent;
}
/* Sub-cell height proportions for tab 2: 40 / 30 / 30. Same total as
   `flex: 1 1 0` siblings, just non-uniform grow ratios. */
.sim-topo-tabs__split-cell--prior      { flex: 4 1 0; }
.sim-topo-tabs__split-cell--likelihood { flex: 3 1 0; }
.sim-topo-tabs__split-cell--posterior  { flex: 3 1 0; }
.sim-topo-tabs__split-label {
    font-family: var(--font-display);
    font-size: 0.88rem;
    font-weight: 700;
    letter-spacing: 0.02em;
    color: var(--color-text-muted);
    flex-shrink: 0;
    margin-bottom: 2px;
}
.sim-topo-tabs__split-slot {
    flex: 1 1 0;
    min-height: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--color-text-muted);
    font-style: italic;
    font-size: 0.88rem;
}
/* Variant that hosts the Likelihood row card — drop the italic / muted
   placeholder styling AND swap the centred flex behaviour for stretch
   on both axes so the card can fill the sub-cell vertically. Without
   this, the parent's `align-items: center` keeps the card height to
   text-content only, collapsing the cell backgrounds. */
.sim-topo-tabs__split-slot--values {
    color: inherit;
    font-style: normal;
    font-size: inherit;
    align-items: stretch;
    justify-content: stretch;
}
/* Likelihood row card — one head row of post-id labels above one body
   row of dark-teal value tiles. Row label sits to the left of both. */
.sim-topo-likelihood {
    flex: 1 1 0;
    min-height: 0;
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 2px 4px;
}
.sim-topo-likelihood__head,
.sim-topo-likelihood__body {
    display: flex;
    align-items: stretch;
    gap: 6px;
}
.sim-topo-likelihood__head {
    flex: 0 0 auto;
    align-items: end;
}
.sim-topo-likelihood__body {
    flex: 1 1 0;
    min-height: 0;
}
.sim-topo-likelihood__corner,
.sim-topo-likelihood__row-label {
    flex: 0 0 22px;
    text-align: right;
    padding-right: 4px;
    font-size: 0.95rem;
    color: var(--color-text);
    font-weight: 700;
    display: flex;
    align-items: center;
    justify-content: flex-end;
}
.sim-topo-likelihood__col {
    flex: 1 1 0;
    min-width: 0;
    text-align: center;
    font-size: 0.82rem;
    color: var(--color-text);
    font-weight: 500;
    line-height: 1;
    padding-bottom: 2px;
}
.sim-topo-likelihood__cell {
    flex: 1 1 0;
    min-width: 0;
    min-height: 28px;
    background: #111;
    border-radius: 4px;
    color: #f5f5f0;
    font-size: 0.82rem;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    letter-spacing: 0.02em;
}
.sim-topo-likelihood__cell--blank {
    background: transparent;
    box-shadow: none;
}
/* MAP-winner highlight — fills the (pre, post) cell that drives the
   posterior arrow in the sub-cell below. Dark red against the default
   black so the audience reads the pair at a glance. `--winner-2` is
   the runner-up: even darker red, used for the under-est case where
   the audience needs to see both the strongest cell AND the second
   strongest (which is what the model actually selects). */
.sim-topo-likelihood__cell--winner {
    background: #8b1a1a;
}
.sim-topo-likelihood__cell--winner-2 {
    background: #4a0808;
}
/* Posterior sub-cell visual: source-circle → arrow → target-circle.
   Pure HTML+CSS; the arrow body is a thin rule and the head is a CSS
   triangle pseudo-element. Centred horizontally + vertically within
   the sub-cell. */
.sim-topo-posterior {
    flex: 1 1 0;
    min-height: 0;
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
}
.sim-topo-posterior__row {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    width: 100%;
    max-width: 220px;
}
.sim-topo-posterior__node {
    flex: 0 0 auto;
    width: 38px;
    height: 38px;
    border-radius: 50%;
    background: #111;
    color: #f5f5f0;
    font-weight: 700;
    font-size: 0.95rem;
    display: flex;
    align-items: center;
    justify-content: center;
}
.sim-topo-posterior__arrow {
    flex: 1 1 0;
    height: 2px;
    background: #333;
    position: relative;
    min-width: 24px;
}
.sim-topo-posterior__arrow::after {
    content: '';
    position: absolute;
    right: -1px;
    top: 50%;
    transform: translateY(-50%);
    border-left: 9px solid #333;
    border-top: 5px solid transparent;
    border-bottom: 5px solid transparent;
}
.sim-topo-tabs__dots {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    flex-shrink: 0;
    margin-top: 4px;
}
.sim-topo-tabs__dot {
    width: 6px;
    height: 6px;
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.18);
    transition: background 120ms ease, transform 120ms ease;
}
.sim-topo-tabs__dot.is-active {
    background: var(--color-text);
    transform: scale(1.2);
}

@media (max-width: 768px) {
    .sim-row { grid-template-columns: 1fr; }
    .sim-grid-top,
    .sim-grid-top--mirror {
        grid-template-columns: 1fr;
        grid-template-rows: 480px 144px auto;
    }
    .sim-grid-top__cell--left-top    { grid-column: 1; grid-row: 1; }
    .sim-grid-top__cell--left-bottom { grid-column: 1; grid-row: 2; }
    .sim-grid-top__cell--right       { grid-column: 1; grid-row: 3; }
    .sim-grid-top--mirror .sim-grid-top__cell--left-top    { grid-column: 1; }
    .sim-grid-top--mirror .sim-grid-top__cell--left-bottom { grid-column: 1; }
    .sim-grid-top--mirror .sim-grid-top__cell--right       { grid-column: 1; }
}

/* Controlled collapse (not native <details>). The title div is the
   click target — JS handler in assets/script.js toggles .is-expanded
   on the parent block. The inner .viz-block-collapse wrapper animates
   max-height for a smooth reveal that matches scratch's
   #plot-c-wrap.is-expanded behaviour. */
.viz-block-title--clickable {
    cursor: pointer;
    user-select: none;
    display: flex;
    align-items: center;
    justify-content: space-between;
}

.viz-block-title--clickable::after {
    content: "▾";
    font-size: 0.85em;
    color: var(--color-text-muted);
    transition: transform 0.2s ease;
    margin-left: var(--space-sm);
}

.viz-block-title--clickable:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 4px;
    border-radius: 4px;
}

.viz-block--collapsible.is-expanded > .viz-block-title--clickable::after {
    transform: rotate(180deg);
}

.viz-block-collapse {
    max-height: 0;
    overflow: hidden;
    transition: max-height 1500ms ease-out;
}

.viz-block--collapsible.is-expanded > .viz-block-collapse {
    /* Tall enough to fit the plot-c1 placeholder (4:1 aspect) + caption.
       Real plots will set their own intrinsic height; this is the cap
       on the animated reveal window. */
    max-height: 900px;
}

/* When the W matrix grows to fullsize (720 px), the inner collapse cap
   has to grow too or the bottom of the host gets clipped. Match the
   1000 ms transition so they stay in sync. */
.viz-block--collapsible.is-fullsize.is-expanded > .viz-block-collapse {
    max-height: 1400px;
    transition: max-height 1000ms ease-in-out;
}

/* Inside an expanded collapsible, the .viz-block flex gap is normally
   what spaces the children — but since .viz-block-collapse wraps both
   plot + caption, restore that gap inside the wrapper. */
.viz-block-collapse {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
}

/* §07 simulation blocks: the clickable title sits inside .sim-block-head
   (alongside the play / reset controls) rather than as a direct child of
   the collapsible block, so the base "> ::after" rotation rule misses
   it. This descendant variant handles the nested case. The title itself
   becomes a snug content-sized flex item — without overriding the
   base's display:flex space-between, it'd stretch the full row width
   and push the caret away from the title text. */
.viz-block--collapsible--sim .sim-block-head .viz-block-title--clickable {
    flex: 0 1 auto;
    justify-content: flex-start;
    gap: var(--space-xs);
}

.viz-block--collapsible--sim.is-expanded .viz-block-title--clickable::after {
    transform: rotate(180deg);
}

/* The §07 row stack (480 px raster row + 240 px f-ratio/buckets row +
   gap + legend overlays) easily clears the default 900 px max-height
   cap. Lift it so the expand reveal doesn't clip the bottom plots. */
.viz-block--collapsible--sim.is-expanded > .viz-block-collapse {
    max-height: 1200px;
}

/* §06 Block 4 — GT vs Learned 2×2 grid (column-header row + matrix
   row + bar-chart row). Comfortably clears 900 px once both rows are
   fully rendered, so let the reveal cap out at 2000 px (matrix tile
   ≈ 620 px + bar tile ≈ 360 px + headers + gaps + padding). The
   transition stays the default 1500 ms — only the cap changes. */
.viz-block--collapsible--gt-learned.is-expanded > .viz-block-collapse {
    max-height: 2000px;
}

/* §01 Block 2 — 3 cards each holding title + 2 stacked plot images
   + bullet list. Photos can be tall, so cap the reveal at 1600 px
   so the bottom bullet row doesn't get clipped on expand. */
.viz-block--collapsible--bg-est.is-expanded > .viz-block-collapse {
    max-height: 1600px;
}

/* §09 Biocounterparts — each block holds 2–3 sub-rows with stacked
   banner images plus multi-paragraph text. The tallest (block 3 with
   two stacked images and a 3-paragraph case study) can run beyond
   3000 px, so cap the reveal generously to avoid mid-image
   clipping. */
.viz-block--collapsible--bio.is-expanded > .viz-block-collapse {
    max-height: 4000px;
}

.viz-grid {
    display: grid;
    gap: var(--space-md);
}

.viz-grid--2col {
    grid-template-columns: 1fr 1fr;
}

.viz-grid--1-2 {
    grid-template-columns: 1fr 2fr;
}

@media (max-width: 768px) {
    .viz-grid--2col,
    .viz-grid--1-2 {
        grid-template-columns: 1fr;
    }
}

/* Light tile — white card on either alt or non-alt section bg. */
.viz-tile {
    background: #ffffff;
    color: var(--color-text);
    border: 1px solid var(--color-border);
    border-radius: 12px;
    padding: var(--space-md);
    min-height: 300px;
    display: flex;
    flex-direction: column;
    gap: var(--space-xs);
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
}

.viz-tile-label {
    font-size: 0.7rem;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: var(--color-accent);
    font-weight: 600;
}

.viz-tile-title {
    font-family: var(--font-display);
    font-size: 1.15rem;
    color: var(--color-text);
}

.viz-tile-area {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--color-text-muted);
    font-family: var(--font-mono);
    font-size: 0.85rem;
    text-align: center;
    padding: var(--space-md);
    background: var(--color-bg-alt);
    border: 1px dashed var(--color-border);
    border-radius: 8px;
    margin-top: var(--space-xs);
    min-height: 180px;
}

/* Area variant when a real figure (e.g., dcc.Graph) lives inside —
   no dashed border, no padding, no offset background. Height is driven
   by the figure's own layout (Plotly `height=...`), so we drop min-height
   here to avoid a feedback loop where the container floor and Plotly's
   autosize fight on every callback re-render. `position: relative` so
   any floating overlay (legend chip) can absolute-anchor to the area. */
.viz-tile-area--filled {
    background: transparent;
    border: none;
    padding: 0;
    min-height: 0;
    position: relative;
}

/* ===== Floating legend chip (top-right of a viz-tile-area--filled) ===== */
.viz-tile-legend {
    position: absolute;
    top: 10px;
    right: 10px;
    display: flex;
    flex-direction: column;
    gap: 2px;
    padding: 5px 9px;
    background: rgba(255, 255, 255, 0.9);
    border: 1px solid var(--color-border);
    border-radius: 6px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
    backdrop-filter: blur(4px);
    z-index: 2;
    font-size: 0.72rem;
    line-height: 1.25;
    color: var(--color-text);
    pointer-events: none; /* don't block plot hover/zoom */
}

/* ===== §04 Task Design — 2-col layout (mockup placeholders) =====
   Outer 2-col grid (reuses .viz-grid--2col); each column is a vertical
   stack of two tiles with asymmetric heights — text-box small / matrix
   big on the left, markov-graph big / probability small on the right.
   Three of the four cells are dark-blue placeholders (heavy wireframe
   look matching the mockup); the markov-graph cell is a real white tile
   that hosts a vis.js groundtruth network via the existing
   .wheel-graph-mount[data-graph-id] selector wheel_vis.js picks up. */
.task-stack {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
}
/* Per-cell wrapper inside a .task-stack column. The bare .task-block
   is a transparent flex column (used for Block 1 / legend, which
   keeps its own .task-legend-card chrome). When titled, the wrapper
   itself becomes the visual card chrome and the inner card sheds
   its own — so the title and content share one visible boundary. */
.task-block {
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
    /* Anchor the hover-reveal download button in the top-right corner
       of every block. The button itself is position:absolute. */
    position: relative;
}
.task-block--titled {
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
    padding: 18px 22px 22px;
}
/* Strip chrome from inner cards when their parent is the titled
   wrapper. The cards keep their explicit heights and internal
   padding, so the title is purely additive height — the outer card
   grows by ~80 px (padding + title row) per titled block. */
.task-block--titled .task-graph-card,
.task-block--titled .task-matrix-card,
.task-block--titled .task-legend-card {
    background: transparent;
    border: 0;
    box-shadow: none;
}
/* Block title — Instrument Serif (display), near-black; sized for a
   first-class panel header rather than a nested .viz-tile subtitle.
   Font family is inherited via the .viz-tile-title rule already in
   place; this just promotes the size. */
.task-block-title {
    font-family: var(--font-display);
    color: var(--color-text);
    font-size: 1.3rem;
    line-height: 1.2;
}

/* ===== Hover-reveal per-block PNG download button =====
   One button injected inside every .task-block (see _task_block in
   app.py). Positioned top-right of the block. Fades in after a brief
   hover delay so it doesn't flicker on cursor pass-through; delegated
   click handler in assets/script.js dispatches by the block's
   data-download-kind attribute (plotly | visnet | dom). */
.block-download-btn {
    position: absolute;
    top: 10px;
    right: 10px;
    width: 30px;
    height: 30px;
    padding: 0;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    background: rgba(255, 255, 255, 0.9);
    border: 1px solid var(--color-border);
    border-radius: 8px;
    cursor: pointer;
    color: var(--color-text);
    /* Held invisible until the block has been hovered for ~0.4 s.
       transition-delay is ONLY applied on the fade-in, not the
       fade-out — override in the hover rule below. */
    opacity: 0;
    transform: translateY(-2px);
    transition: opacity 0.2s ease, transform 0.2s ease,
                background 0.15s ease, box-shadow 0.15s ease;
    pointer-events: none;
    z-index: 5;
}
.task-block:hover .block-download-btn,
.task-block:focus-within .block-download-btn {
    opacity: 0.55;
    transform: translateY(0);
    transition-delay: 0.4s;
    pointer-events: auto;
}
.block-download-btn:hover,
.block-download-btn:focus-visible {
    opacity: 1;
    background: #ffffff;
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
    transition-delay: 0s;
}
.block-download-btn:active {
    transform: translateY(0) scale(0.94);
}
/* Feedback while capture runs: dim the button + swap to a spinner
   state. Toggled via .is-capturing on the button in JS. */
.block-download-btn.is-capturing {
    opacity: 1;
    pointer-events: none;
    cursor: progress;
}
/* SVG download-arrow icon painted via mask-image so a single class
   inherits currentColor cleanly (avoids inline-SVG stroke churn on
   focus / hover). Data-URI is a 16px arrow-down-to-bar glyph. */
.block-download-btn__icon {
    display: inline-block;
    width: 16px;
    height: 16px;
    background: currentColor;
    -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M8 1v9m0 0l-3.2-3.2M8 10l3.2-3.2M2.5 13.5h11' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></svg>") no-repeat center / contain;
    mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><path d='M8 1v9m0 0l-3.2-3.2M8 10l3.2-3.2M2.5 13.5h11' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/></svg>") no-repeat center / contain;
}
.task-placeholder {
    background: #2d5a7b;
    color: #fff;
    border-radius: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
    text-align: center;
    padding: 28px;
    font-size: 1.05rem;
    font-weight: 500;
    line-height: 1.4;
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
}
/* Shortened from 200 → 140 (text-box use-case rarely needs that much
   vertical space). The 60 px saved is absorbed into the matrix card's
   vertical padding below, so the right column's baseline alignment is
   preserved (left: 140 + gap + 440 = right: 380 + gap + 200). */
.task-placeholder--sm { height: 140px; }
.task-placeholder--lg { height: 380px; }

/* Real markov-graph card — white tile that contains a .wheel-graph-mount.
   Uses display:flex so the mount's `flex: 1 1 auto` stretches it to fill,
   matching the size of the lg placeholder so columns line up. */
.task-graph-card {
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
    overflow: hidden;
    display: flex;
    padding: 0;
}
.task-graph-card--lg { height: 380px; }
.task-graph-card--sm { height: 200px; }

/* §03 Task Design — intro block. Left 50 %: live 3-state Markov toy
   (Rainy / Cloudy / Sunny) rendered as inline SVG by setupMarkovToy()
   in script.js. Right 50 %: prose placeholder. Grid is the same
   `viz-grid--2col` used elsewhere but the card aspect ratio is locked
   here so the SVG stays a familiar shape across viewports. */
.task-intro-block {
    margin-bottom: var(--space-md);
}
.task-intro-grid {
    align-items: stretch;
}
.markov-toy-card {
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
    padding: 1rem;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 380px;
}
.markov-toy-mount {
    width: 100%;
    height: 100%;
    max-width: 460px;
    margin: 0 auto;
}
.markov-toy-mount svg {
    display: block;
    width: 100%;
    height: 100%;
}
.markov-toy-text {
    background: transparent;
    border: none;
    box-shadow: none;
    padding: var(--space-md);
    display: flex;
    flex-direction: column;
    justify-content: center;
    min-height: 380px;
}
.markov-toy-placeholder {
    color: var(--color-text-muted);
    font-style: italic;
    margin: 0;
}
.task-chunking-placeholder {
    color: var(--color-text-muted);
    font-style: italic;
    padding: var(--space-md) 0;
}
/* §03 Chunking-problem block — three-column literature-reference grid.
   Each column carries one paper's figure plus a text placeholder under
   it (image on top, prose below). Matches the white-card chrome used
   elsewhere in §03. */
.chunking-grid {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
    margin-top: var(--space-sm);
}
.chunking-part-card {
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
    overflow: hidden;
    display: flex;
    flex-direction: column;
}
.chunking-part-fig {
    background: #ffffff;
    padding: 0.9rem 0.9rem 0.4rem;
    display: flex;
    align-items: center;
    justify-content: center;
}
.chunking-part-img {
    display: block;
    width: 100%;
    height: auto;
    border-radius: 8px;
}
.chunking-part-text {
    padding: var(--space-sm) var(--space-md) var(--space-md);
    color: var(--color-text);
    line-height: 1.55;
    font-size: 1.3rem;
}
/* First sentence of each chunking caption is wrapped in <strong>; render
   it as a cream-on-near-black pill (same recipe as §05 step caption
   header) so the keyword reads as a tag rather than a normal headline. */
.chunking-part-text strong,
.rq-popup-caption strong {
    display: inline;
    background: #1a1a1a;
    color: #f6f1e6;
    font-weight: 700;
    padding: 0.12em 0.5em;
    border-radius: 0.45em;
    box-shadow: 0 3px 9px rgba(0, 0, 0, 0.22),
                0 1px 2px rgba(0, 0, 0, 0.18);
    box-decoration-break: clone;
    -webkit-box-decoration-break: clone;
}
.chunking-part-text em {
    display: block;
    margin-top: 0.4em;
    font-size: 0.92em;
    color: var(--color-text-muted);
}
/* Inline numbered reference marker (e.g. [1], [2, 3]). Clickable anchor
   into §10 References so the audience can pull up the full citation. */
.ref-cite {
    display: inline;
    margin-left: 0.25em;
    color: var(--color-accent);
    font-weight: 600;
    text-decoration: none;
    vertical-align: baseline;
    font-size: 0.9em;
}
.ref-cite:hover,
.ref-cite:focus {
    text-decoration: underline;
}
/* Bullet list inside the "Why it's foundational" card under the
   Markov-chain intro block. Leading keyword (wrapped in <strong>) +
   em-dash + body — gives the audience a scannable benefit list. */
.task-foundational-list {
    margin: 0;
    padding-left: 1.2em;
    color: var(--color-text);
    font-size: 1.3rem;
    line-height: 1.55;
}
.task-foundational-list li + li {
    margin-top: 0.45em;
}
.task-foundational-list strong {
    color: var(--color-accent);
}
/* FAQ card sitting full-width below the 2-col task grid in §03's
   "Proposed:" block. Inherits `.task-block--titled` chrome from
   `_task_block(title, content)`; just adds breathing room above and
   styles the placeholder body. */
.task-detail-block .task-block--titled + .task-block--titled,
.task-detail-block > .task-block--titled:last-child {
    margin-top: var(--space-md);
}
.task-faq-placeholder {
    color: var(--color-text-muted);
    font-style: italic;
    padding: var(--space-sm) 0;
}
/* Collapsible FAQ — wraps the task-block chrome around the standard
   viz-block--collapsible mechanics. Default expanded max-height of
   900 px is too tight for the two Q&A items + TL;DR boxes; raise it
   to 2000 px so the reveal isn't clipped. */
.task-faq-block.is-expanded > .viz-block-collapse {
    max-height: 2000px;
}
/* The collapsed-state cursor + chevron live on .viz-block-title--
   clickable. When the title is also a .task-block-title (Instrument
   Serif), the chevron sits to its right via the existing
   flex+space-between rule. No further overrides needed here. */
/* FAQ entries inside the §03 "FAQs" task-block. Each .faq-item stacks:
   bigger Q headline → smaller bulleted answer → bigger TL;DR callout
   with an accent-blue left rail. The Q + TL;DR carry the audience-
   facing weight; the bullets are the supporting argument. */
.task-faq-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-md);
    padding: 0.4rem 0;
}
.faq-item {
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}
.faq-q {
    font-family: var(--font-display);
    font-size: 1.35rem;
    font-weight: 700;
    color: var(--color-text);
    line-height: 1.35;
}
.faq-a {
    margin: 0;
    padding-left: 1.4em;
    color: var(--color-text);
    font-size: 1rem;
    line-height: 1.55;
}
.faq-a li + li {
    margin-top: 0.4em;
}
.faq-a strong {
    color: var(--color-accent);
}
.faq-tldr {
    font-size: 1.2rem;
    font-weight: 500;
    line-height: 1.5;
    color: var(--color-text);
    border-left: 3px solid var(--color-accent);
    background: var(--color-accent-light);
    padding: 0.7rem 1rem;
    border-radius: 0 8px 8px 0;
}
.faq-tldr__badge {
    display: inline-block;
    margin-right: 0.5em;
    padding: 0.05em 0.55em;
    background: var(--color-accent);
    color: #ffffff;
    font-weight: 700;
    font-size: 0.9em;
    letter-spacing: 0.04em;
    border-radius: 0.4em;
    vertical-align: 0.05em;
}

/* --- Markov toy SVG primitives -------------------------------------- */
/* Pastel-green palette mirrors the GeeksforGeeks-style reference image.
   Nodes use a soft fill + accent ring; the active node intensifies its
   ring + adds a subtle glow. Edges are thin paths with arrowhead
   markers; the active-source edge briefly thickens during the token
   traversal. Token is a small solid dot — JS moves it via cx/cy. */
.mt-node-fill {
    fill: var(--color-accent-light);
    stroke: var(--color-accent);
    stroke-width: 2.5;
    transition: fill 0.25s ease, stroke 0.25s ease;
}
.mt-node-label {
    fill: var(--color-accent);
    font-family: var(--font-display);
    font-size: 22px;
    font-weight: 600;
    text-anchor: middle;
    dominant-baseline: central;
    pointer-events: none;
}
.mt-node.is-active .mt-node-fill {
    fill: var(--color-accent);
    stroke: #14334a;
    filter: drop-shadow(0 0 8px rgba(45, 90, 123, 0.55));
}
.mt-node.is-active .mt-node-label {
    fill: #ffffff;
}
.mt-edge {
    fill: none;
    stroke: var(--color-accent);
    stroke-width: 2;
    stroke-linecap: round;
    transition: stroke 0.2s ease, stroke-width 0.2s ease;
}
.mt-edge.is-active {
    stroke: #14334a;
    stroke-width: 3.5;
}
.mt-edge-label {
    fill: var(--color-accent);
    font-family: var(--font-body);
    font-size: 30px;
    font-weight: 800;
    text-anchor: middle;
    dominant-baseline: central;
    pointer-events: none;
}
.mt-token {
    fill: var(--color-accent-warm);
    stroke: #ffffff;
    stroke-width: 2;
    filter: drop-shadow(0 0 6px rgba(196, 69, 54, 0.55));
}

/* §04 Task Design — top-left legend tile.
   2 × 2 grid: chunk (top-left) / common events (top-right) /
   directed arrow (bottom-left) / rare events (bottom-right). Lives
   in a white card matching the other §04 cards (.task-graph-card
   chrome) but height-locked to 140 px so the left column's
   baseline alignment with the right column is preserved
   (140 + gap + 440 = 380 + gap + 200). */
.task-legend-card {
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
    height: 140px;
    padding: 16px 22px;
    display: grid;
    grid-template-columns: 1fr 1fr;
    grid-template-rows: 1fr 1fr;
    column-gap: 24px;
    row-gap: 10px;
    align-items: center;
}
.task-legend__item {
    display: flex;
    align-items: center;
    gap: 14px;
    font-size: 1.12rem;
    color: #2b2a28;
    font-family: var(--font-body);
}
/* Static 2x2 mode: the rare-events row carries the `--pill` modifier so
   its label renders as a cream-on-near-black pill, mirroring the
   highlight the carousel used to apply via the JS-injected .rq-key span.
   When the cycle was running, JS wrapped the typed text; now the cycle
   is disabled, this static rule re-creates the same look. */
.task-legend__item--pill .task-legend__label-text {
    display: inline-block;
    background: #1a1a1a;
    color: #f6f1e6;
    font-weight: 700;
    padding: 0.05em 0.45em;
    border-radius: 0.4em;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.20),
                0 1px 2px rgba(0, 0, 0, 0.18);
}
/* Cycling mode: setupTaskNotationCycle() in script.js adds .is-cycling
   to the card and toggles .is-current on one item at a time, fading
   each in/out sequentially. Card height is NOT grown — the 2x2 grid
   is replaced by a single stacked slot that uses the existing 140 px
   card area. Icon + label still scale up versus the cramped 2x2
   (only one item shows at a time, so they get the full row of space)
   but fit within the original card footprint. */
.task-legend-card.is-cycling {
    display: block;
    position: relative;
    padding: 0;
}
.task-legend-card.is-cycling .task-legend__item {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 22px;
    padding: 0 24px;
    font-family: var(--font-body);
    font-weight: 500;
    font-size: 1.55rem;
    letter-spacing: -0.005em;
    line-height: 1.2;
    opacity: 0;
    transform: translateX(40px);
    /* No transition in the default (off-right) state — that way when
       an item finishes leaving (.is-leaving removed) and resets to
       this state, it snaps off-right invisibly instead of animating
       backward across the card. Transitions are scoped to the active
       classes (.is-current / .is-leaving) so motion only happens
       during deliberate enter / exit. */
    transition: none;
    pointer-events: none;
}
.task-legend-card.is-cycling .task-legend__item.is-current,
.task-legend-card.is-cycling .task-legend__item.is-leaving {
    transition: opacity 0.85s ease, transform 0.85s ease;
}
.task-legend-card.is-cycling .task-legend__item.is-current {
    opacity: 1;
    transform: translateX(0);
}
.task-legend-card.is-cycling .task-legend__item.is-leaving {
    opacity: 0;
    transform: translateX(-40px);
}
.task-legend-card.is-cycling .task-legend__icon {
    width: 72px;
    height: 44px;
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: flex-start;
}
.task-legend-card.is-cycling .task-legend__svg {
    width: 100%;
    height: 100%;
}
.task-legend-card.is-cycling .task-legend__dot {
    width: 40px;
    height: 40px;
}
.task-legend-card.is-cycling .task-legend__label {
    flex: 0 1 auto;
}
/* Blinking caret per notation row — same terracotta pen-stroke as
   the §02 research-question caret (reuses @keyframes rq-blink), but
   thinner / shorter so it matches the smaller 1.55 rem font. Hidden
   by default (static 2x2 mode); only the cycling card shows it. */
.task-legend-cursor {
    display: none;
}
.task-legend-card.is-cycling .task-legend-cursor {
    display: inline-block;
    width: 4px;
    height: 1.1em;
    background: var(--color-accent-warm);
    margin-left: 0.12em;
    vertical-align: -0.18em;
    border-radius: 1px;
    animation: rq-blink 1.05s step-end infinite;
    box-shadow: 0 0 6px rgba(196, 69, 54, 0.4);
}
@media (prefers-reduced-motion: reduce) {
    /* Keep the static 2x2 grid — JS already skips adding .is-cycling
       under reduced-motion, but this rule guards against any stale
       class staying on the element. */
    .task-legend-card.is-cycling .task-legend__item {
        opacity: 1;
        position: static;
        transition: none;
    }
}
.task-legend__icon {
    width: 44px;
    height: 26px;
    flex-shrink: 0;
    display: flex;
    align-items: center;
    justify-content: center;
}
.task-legend__svg {
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
}
.task-legend__dot {
    width: 14px;
    height: 14px;
    border-radius: 50%;
}
.task-legend__dot--common { background: #2d5a7b; }
.task-legend__dot--rare   { background: #c44536; }
.task-legend__label { flex: 1; }

/* Connectivity matrix — 20×20 CSS grid of plain divs wrapped in an
   axes scaffold that paints pre-id labels down the left edge and
   post-id labels along the top. The axes container is a 2×2 grid
   (corner / top-labels / left-labels / grid); aspect-ratio: 1 on the
   outer keeps it square inside whatever card it lives in. Greyscale
   cells only — colour layering (reveal phasing, P/F stripes) can be
   added later via additional classes without touching this base.
   Inter-cell lines come from a single `gap` over a dim `background`
   so the grid is paintable in one declaration instead of 400 borders;
   the outer dark border is kept thick to frame the matrix as a unit. */
.task-matrix-card {
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 12px;
    box-shadow: 0 2px 14px rgba(0, 0, 0, 0.04);
    /* Card is taller than the placeholder default (440 vs 380); the
       extra height becomes vertical padding around the matrix:
       40 top + 50 bottom — the slightly-heavier bottom gives the
       matrix a touch more grounding against the card's lower edge.
       Horizontal padding is asymmetric (15 left / 25 right) to give
       the post-id labels along the top axis a touch more room on the
       right where the last labels ("18", "19") might otherwise crowd
       the card's right edge.
       Combined with the shorter text-box (140 px), the left column's
       total height (140 + gap + 440) still matches the right column
       (380 + gap + 200). */
    height: 440px;
    padding: 40px 25px 50px 15px;
    display: flex;
    align-items: center;
    justify-content: center;
}
.task-matrix-axes {
    display: grid;
    grid-template-columns: 26px 1fr;
    grid-template-rows: 22px 1fr;
    column-gap: 4px;
    row-gap: 4px;
    aspect-ratio: 1 / 1;
    height: 100%;
}
.task-matrix-axes__corner {
    /* Top-left corner — empty by default. Reserved for an optional
       "pre / post" caption if we want one later. */
}
.task-matrix-axes__top,
.task-matrix-axes__left {
    font-family: var(--font-mono, monospace);
    font-size: 10px;
    color: #b0aba0;
    user-select: none;
    font-feature-settings: "tnum";  /* tabular figures — equal-width digits */
}
.task-matrix-axes__top {
    display: grid;
    grid-template-columns: repeat(20, 1fr);
    align-items: end;
    justify-items: center;
    padding-bottom: 2px;
}
.task-matrix-axes__left {
    display: grid;
    grid-template-rows: repeat(20, 1fr);
    align-items: center;
    justify-items: end;
    padding-right: 4px;
}
.task-matrix-grid {
    display: grid;
    grid-template-columns: repeat(20, 1fr);
    grid-template-rows: repeat(20, 1fr);
    gap: 2px;
    /* Gap colour and the thin one-row gutter just inside the frame
       come from the same low-contrast warm grey, so the inter-cell
       lines whisper instead of shouting. Outer frame is softened
       from pure black to a charcoal with a hint of cool tint —
       still definitive but no longer stark. */
    background: #e5e2da;
    border: 1.5px solid #2c3540;
    padding: 2px;
    border-radius: 8px;             /* rounded matrix frame */
    box-sizing: border-box;
    /* Subtle inner highlight + drop shadow on the frame itself —
       gives the matrix a touch of elevation off the white card. */
    box-shadow:
        inset 0 0 0 1px rgba(255, 255, 255, 0.5),
        0 1px 4px rgba(44, 53, 64, 0.08);
}
.task-matrix__cell {
    /* Empty cells: warm cream, not pure white — pulls the matrix
       into the site's warm palette so it feels native to the page. */
    background: #f6f3ed;
    border-radius: 2px;             /* soft cell corners */
    transition: background 240ms ease, opacity 240ms ease;
}
.task-matrix__cell.is-edge {
    /* Common base — actual fill comes from the per-variant rules
       below. Inner top highlight is shared so all variants get the
       same paper-cut feel. */
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10);
    /* Synchronised "breathing" pulse — every filled cell crossfades
       between its full colour (blue or red) and a dark-grey resting
       phase. `grayscale(1)` desaturates the hue + `brightness(0.55)`
       dims it: blue and red both land near `#3f3f3f`, giving a
       uniform dark-grey trough rather than just a darker version of
       each cell's own hue. 3 s is the full breath (1.5 s out, 1.5 s
       back), with ease-in-out for an organic rise-and-fall. Filter
       applies at the layer level so the gradient bridge cells
       (is-pf / is-fp) crossfade cleanly without needing a separate
       background-stop animation. */
    animation: task-matrix-pulse 4s ease-in-out infinite;
}
@keyframes task-matrix-pulse {
    0%, 100% { filter: grayscale(0)  brightness(1);    }
    50%      { filter: grayscale(1)  brightness(0.75); }
}
@media (prefers-reduced-motion: reduce) {
    /* Respect the user's OS-level motion preference: a constant
       slow pulse can be tiring or trigger motion-sickness for some
       users. Disable the animation entirely; cells keep their full
       colour. */
    .task-matrix__cell.is-edge { animation: none; }
}
/* P → P cells (dense clusters 0-4 × 0-4 and 10-14 × 10-14): site
   accent blue (#2d5a7b — same value as --color-accent, NODE_COLOR_P
   on mechanism_live, the probability-bar P colour, and the recoloured
   Markov-graph P nodes). Keeps the whole §04 coherent: matrix,
   graph, and bars all share one blue. */
.task-matrix__cell.is-edge.is-pp {
    background: #2d5a7b;
}
/* F → F cells (chain interiors 5→6,…,8→9 and 15→16,…,18→19): site
   warm accent (#c44536 — same value as --color-accent-warm,
   NODE_COLOR_F, the probability-bar F colour, and the recoloured
   Markov-graph F nodes). */
.task-matrix__cell.is-edge.is-ff {
    background: #c44536;
}
/* P → F bridge cells (4→5, 14→15): diagonal split — top-left half
   is the source colour (blue), bottom-right half is the target
   colour (red). 135deg with hard stops at 50% produces a sharp "/"
   diagonal separating the two triangles, mirroring the cell's
   semantic of "from P, to F". */
.task-matrix__cell.is-edge.is-pf {
    background: linear-gradient(135deg, #2d5a7b 50%, #c44536 50%);
}
/* F → P bridge cells (9→10, 19→0): same diagonal, colours swapped —
   top-left red (F source), bottom-right blue (P target). */
.task-matrix__cell.is-edge.is-fp {
    background: linear-gradient(135deg, #c44536 50%, #2d5a7b 50%);
}

/* Alternate corners — default rules (top: 10px; right: 10px) anchor
   top-right; these modifiers re-anchor to other corners. Reset the
   axis being moved away from to `auto` so it doesn't fight the new
   anchor. */
.viz-tile-legend--top-left {
    top: 10px;
    right: auto;
    left: 10px;
}
.viz-tile-legend--bottom-right {
    top: auto;
    bottom: 10px;
    right: 10px;
}
.viz-tile-legend--bottom-left {
    top: auto;
    right: auto;
    bottom: 10px;
    left: 10px;
}

.viz-legend-item {
    display: flex;
    align-items: center;
    gap: 5px;
}

.viz-legend-mark {
    font-size: 0.85rem;
    line-height: 1;
    flex-shrink: 0;
}

/* Bigger legend chip — presentation-grade, readable from the back of
   a room. Opt in via `_legend_overlay(..., size="lg")` which adds
   `.viz-tile-legend--lg` to the chip; the modifier bumps padding +
   font on the chip itself and scales the per-item glyph + gap so the
   pair stays proportional. */
.viz-tile-legend--lg {
    padding: 8px 14px;
    gap: 4px;
    font-size: 1.05rem;
    border-radius: 8px;
}
.viz-tile-legend--lg .viz-legend-item {
    gap: 8px;
}
.viz-tile-legend--lg .viz-legend-mark {
    font-size: 1.2rem;
}

/* Glass variant — near-transparent chip with a stronger backdrop
   blur so the plot's grid / bars stay visible through the chip
   without crushing label legibility. Opt in via
   `_legend_overlay(..., glass=True)`. Stacks cleanly with `--lg`
   and any `--top-left / --bottom-*` placement modifier. */
.viz-tile-legend--glass {
    background: rgba(255, 255, 255, 0.4);
    border-color: rgba(255, 255, 255, 0.6);
    -webkit-backdrop-filter: blur(10px) saturate(140%);
    backdrop-filter: blur(10px) saturate(140%);
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.viz-tile-caption {
    font-size: 0.85rem;
    line-height: 1.55;
    color: var(--color-text-muted);
    margin-top: var(--space-sm);
    font-style: italic;
}

/* Presentation-grade section blurb — larger + italic so the framing
   copy reads from across a room. Applied per-section via this opt-in
   selector list (so the global `section .container > p` default stays
   intact for sections that don't want the bump). Currently covers
   §03 Task Design, §04 Input Generation, §05 BCPNN, §06 Training,
   and §07 Simulation; add new `#section-id` selectors here when
   porting the style to other sections. */
#background .container > p,
#task-design .container > p,
#inputs .container > p,
#bcpnn .container > p,
#training .container > p,
#simulation .container > p,
#results .container > p {
    font-size: clamp(1.15rem, 1.6vw, 1.4rem);
    line-height: 1.55;
    max-width: 880px;
    margin-bottom: 2.5rem;
    font-style: italic;
}

/* ===== §02 Research Questions — typewriter card ====================
   Presentation-grade headline: the research question is typed out
   character-by-character in the site's body sans (Source Sans 3)
   at medium weight (500). Clean and contemporary, matches the
   rest of the site without the editorial weight of a serif. The
   card is transparent with a thin left accent rail (--color-accent)
   for pull-quote anchoring. Big sans (clamp 1.85-2.9rem) sized
   for back-of-room seats; near-black on the cream alt-bg for max
   contrast. Caret is a thin 2px terracotta pen-stroke so it reads
   as "alive" without competing. The type / clear cycle is driven
   by setupResearchQuestions() in script.js (reads
   .rq-typed[data-rq-text]). The existing blurb <p> in this
   section is hidden because the typed card is the headline. */
#research-questions .container > p {
    display: none;
}
.rq-stage {
    margin-top: var(--space-md);
    display: flex;
    justify-content: center;
}
/* Project-Roadmap block sitting below the typewriter card in §02.
   Title is left-aligned (default viz-block-title behavior); the image
   sits inside a white card with the same chrome as §09's bio-subrow
   figures (border + soft shadow + padding) so the roadmap reads as a
   distinct figure card on the cream alt-bg. */
.rq-abstract-card {
    margin: var(--space-sm) auto 0;
    max-width: min(1100px, 100%);
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 14px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05),
                0 1px 3px rgba(0, 0, 0, 0.03);
    padding: 1.2rem;
}
.rq-abstract-img {
    display: block;
    margin: 0 auto;
    max-width: 100%;
    height: auto;
    border-radius: 10px;
}
.rq-card {
    width: min(100%, 920px);
    background: transparent;
    color: var(--color-text);
    border-radius: 0;
    padding: 1.2rem 0 1.2rem 2.2rem;
    box-shadow: none;
    border: 0;
    border-left: 2px solid var(--color-accent);
    font-family: var(--font-body);
    height: 290px;
    position: relative;
    overflow: hidden;
}
.rq-card__line {
    display: block;
    line-height: 1.55;
}
.rq-typed {
    font-size: clamp(1.7rem, 3.3vw, 2.4rem);
    font-weight: 500;
    letter-spacing: -0.005em;
    color: var(--color-text);
    white-space: pre-wrap;
    word-break: keep-all;
}
/* Highlighted-keyword pill. Sits inline inside .rq-typed; dark
   bg + cream text + rounded corners give a "tag" feel that picks
   out the three load-bearing nouns. Padding tuned so the pill is
   a touch taller than the cap-height; line-height on the parent
   (.rq-card__line) is raised to 1.55 so adjacent lines don't crowd
   the pill. */
.rq-key {
    background: #1a1a1a;
    color: #f6f1e6;
    padding: 0.08em 0.45em;
    border-radius: 0.55em;
    margin: 0 0.04em;
    font-weight: 500;
    box-shadow: 0 3px 9px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18);
    box-decoration-break: clone;
    -webkit-box-decoration-break: clone;
}
.rq-cursor {
    display: inline-block;
    width: 8px;
    height: 1.7em;
    background: var(--color-accent-warm);
    margin-left: 0.14em;
    vertical-align: -0.4em;
    border-radius: 2px;
    animation: rq-blink 1.05s step-end infinite;
    box-shadow: 0 0 14px rgba(196, 69, 54, 0.5);
}
@keyframes rq-blink {
    0%, 50% {
        opacity: 1;
    }
    50.01%, 100% {
        opacity: 0;
    }
}
@media (prefers-reduced-motion: reduce) {
    .rq-cursor {
        animation: none;
        opacity: 1;
    }
}
/* Inline typewriter variant — for in-tile captions (smaller text, no
   left rail, no card chrome, no fixed height). Same animation cycle,
   same DOM shape as the hero card; just sized down. Cards opt in via
   the .rq-card--inline + .rq-stage--inline modifiers built by the
   `_typewriter_block(..., inline=True)` helper. */
.rq-stage--inline {
    margin-top: 0.4rem;
    display: block;
    justify-content: initial;
}
.rq-card--inline {
    width: auto;
    background: transparent;
    color: var(--color-text-muted);
    padding: 0;
    border: 0;
    border-left: 0;
    box-shadow: none;
    height: auto;
    min-height: 0;
    overflow: visible;
}
.rq-card--inline .rq-card__line {
    line-height: 1.5;
}
.rq-card--inline .rq-typed {
    font-size: clamp(1.05rem, 1.55vw, 1.35rem);
    font-weight: 500;
    color: var(--color-text);
    letter-spacing: -0.005em;
}
.rq-card--inline .rq-cursor {
    width: 3px;
    height: 1.1em;
    vertical-align: -0.15em;
    margin-left: 0.1em;
    box-shadow: 0 0 6px rgba(196, 69, 54, 0.35);
}

/* ===== Info rows (image + prose, no white card) =====
   Two-column layout used inside a .viz-block when content should sit
   directly on the section background — image on one side, caption text
   on the other. DOM order is always media-first; CSS handles the visual
   swap so the mobile column stack is image-top, text-bottom in both
   variants. */
.info-row {
    display: flex;
    flex-direction: row;
    align-items: center;
    gap: var(--space-md);
}

.info-row--reverse {
    flex-direction: row-reverse;
}

.info-row__media,
.info-row__text {
    flex: 1 1 0;
    min-width: 0;
}

.info-row__text {
    color: var(--color-text-muted);
    line-height: 1.7;
    max-width: none;
    /* Flex column so a child with `margin-top: auto` (e.g. the BCPNN
       step toolbar in §05 Block 2) can be pinned to the bottom of the
       column. Single-child usages (Block 1 rows) are unaffected. */
    display: flex;
    flex-direction: column;
}

.info-row__text p {
    margin: 0;
}

.info-image-placeholder {
    width: 100%;
    aspect-ratio: 4 / 3;
    background: var(--color-bg-alt);
    border: 1px dashed var(--color-border);
    border-radius: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--color-text-muted);
    font-family: var(--font-mono);
    font-size: 0.85rem;
}

.info-row__image {
    width: 100%;
    height: auto;
    border-radius: 20px;
    display: block;
}

/* §05 BCPNN intro — replaces the old SECTIONS-blurb <p> with a split
   layout: prose on the left, the Pawel Herman (KTH Affiliated Faculty)
   + IRCN institutional banner on the right. Prose uses brighter body
   text + accent-blue bold so the keywords pop from across the room. */
.bcpnn-intro-block {
    margin-bottom: var(--space-md);
}
.bcpnn-intro-prose {
    margin: 0;
    color: var(--color-text);
    font-size: 1.2rem;
    line-height: 1.6;
}
.bcpnn-intro-prose strong {
    color: var(--color-accent);
}
.bcpnn-intro-block .info-row__image {
    border-radius: 14px;
    background: #ffffff;
    width: 80%;
    margin: 0 auto;
}
/* Inline attribution pill — uses the §02 `.rq-key` cream-on-near-black
   chrome. Sits at the end of the intro paragraph on its own line via
   leading <br><br>, and `display: inline-block` makes it shrink-wrap
   to the text length. */
.bcpnn-intro-attribution {
    display: inline-block;
    line-height: 1.4;
}
/* Collapsible §05 Block 1 — two stacked info-rows with images blow past
   the default 900 px collapse cap, so bump it specifically for this
   block. Default state is collapsed (no `.is-expanded`); user clicks
   the title to reveal. */
.bcpnn-minicolumn-block.is-expanded > .viz-block-collapse {
    max-height: 1600px;
}
.bcpnn-attractor-placeholder {
    color: var(--color-text-muted);
    font-style: italic;
    padding: var(--space-md) 0;
}

/* §05 BCPNN Block 1 — minicolumn images sit slightly smaller (0.9x)
   than the column width so the figures don't dominate the row. */
#bcpnn .info-row__image {
    width: 90%;
    margin: 0 auto;
}

/* §08 Results — bullet stack: two (or more) sibling _typewriter_block
   captions arranged vertically in an .info-row text slot. The first
   inline stage normally gets `margin-top: 0.4rem`; we drop it on the
   first child and tighten the inter-bullet rhythm so the bullets read
   as a list rather than two separate paragraphs. */
.results-bullet-stack {
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
}
.results-bullet-stack > .rq-stage--inline {
    margin-top: 0;
}

/* §08 Results: framed Plotly slot used in place of an .info-row__image.
   The frame blends into the section background (no card chrome) so the
   plot reads as part of the page rather than a slotted image — matches
   how the rest of §08 sits on `--color-bg-alt`. 4/3 aspect ratio gives
   Plotly autosize a stable container; the frame is also the
   positioning context for LRUD navigation arrows. */
.results-fig-frame {
    position: relative;
    width: 100%;
    aspect-ratio: 4 / 3;
    background: transparent;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    overflow: hidden;
}

/* §08 Block 2 (Full results) — segmented control: light-grey track
   with a sliding dark-blue thumb under the active option. The
   container class (`is-tau_p` / `is-gbeta` / `is-g_a` / `is-g_bayesian`
   / `is-g_w_overall`) drives both the thumb position (transform) and
   which label gets the white text colour. Single state lives in the
   `results-block2-active` dcc.Store. */
.seg-control {
    position: relative;
    display: flex;
    align-items: stretch;
    background: rgba(0, 0, 0, 0.08);
    border-radius: 999px;
    padding: 4px;
    margin: 6px 0 18px;
    /* Cap the width so the pill doesn't span the full block; left-
       align under the title. 820px fits six short-symbol tabs (τ_p,
       g_β, g_a, g_bayesian, g_w_overall, g_I †) at slideshow
       legibility. History: 460px (2 tabs) → 720px (5 tabs, June
       2026) → 820px (6 tabs w/ g_I diagnostic, July 2026). */
    max-width: 820px;
    user-select: none;
}
/* Sliding thumb — the only element that animates. Width = 1/N of the
   track (minus padding). translateX is expressed as a % of the thumb's
   own width, so translateX(100%) lands under option 2, 200% under 3,
   etc. — no width recomputation needed to move it. */
.seg-control--2 .seg-control-thumb {
    width: calc(50% - 4px);
}
.seg-control--5 .seg-control-thumb {
    width: calc(20% - 4px);
}
.seg-control--6 .seg-control-thumb {
    width: calc(16.6666% - 4px);
}
.seg-control-thumb {
    position: absolute;
    top: 4px;
    left: 4px;
    bottom: 4px;
    background: var(--color-accent);
    border-radius: 999px;
    box-shadow: 0 2px 6px rgba(45, 90, 123, 0.25);
    transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1);
    pointer-events: none;
    z-index: 0;
}
.seg-control.is-tau_p       .seg-control-thumb { transform: translateX(0%);   }
.seg-control.is-gbeta       .seg-control-thumb { transform: translateX(100%); }
.seg-control.is-g_a         .seg-control-thumb { transform: translateX(200%); }
.seg-control.is-g_bayesian  .seg-control-thumb { transform: translateX(300%); }
.seg-control.is-g_w_overall .seg-control-thumb { transform: translateX(400%); }
.seg-control.is-g_I         .seg-control-thumb { transform: translateX(500%); }
/* g_I is a diagnostic tab, not a peer sweep — thumb tinted warmer
   (rare-event accent) so the caveat register is legible even in a
   quick screenshot glance. */
.seg-control.is-g_I .seg-control-thumb {
    background: var(--color-accent-warm, #c44536);
    box-shadow: 0 2px 6px rgba(196, 69, 54, 0.28);
}

/* Pill options — flex children with equal weight. Sit above the thumb
   via `position: relative` + z-index. Default text colour = muted;
   the active one (resolved per container class below) flips to white. */
.seg-control-option {
    flex: 1 1 0;
    min-width: 0;
    position: relative;
    z-index: 1;
    appearance: none;
    background: transparent;
    border: none;
    cursor: pointer;
    padding: 9px 22px;
    font-family: inherit;
    font-size: 0.95rem;
    font-weight: 500;
    line-height: 1.2;
    color: var(--color-text);
    text-align: center;
    white-space: nowrap;
    transition: color 240ms ease;
    border-radius: 999px;
}
.seg-control.is-tau_p       #results-block2-tab-tau_p,
.seg-control.is-gbeta       #results-block2-tab-gbeta,
.seg-control.is-g_a         #results-block2-tab-g_a,
.seg-control.is-g_bayesian  #results-block2-tab-g_bayesian,
.seg-control.is-g_w_overall #results-block2-tab-g_w_overall,
.seg-control.is-g_I         #results-block2-tab-g_I {
    color: #ffffff;
    font-weight: 600;
}
/* Caveat tab — muted default state (dimmer than peer tabs so the eye
   registers it as demoted), warm accent when active (matches the
   warm-red thumb). */
.seg-control-option--caveat {
    color: #8a7e73;
    font-style: italic;
}
.seg-control-option:focus { outline: none; }
.seg-control-option:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
}
.results-block2-content { margin-top: 6px; }

/* §08 Block 2 description container — sits between the segmented
   control and the figure stack. Holds two text panels (one per mode);
   only the panel matching the parent's active-mode class is shown.
   Inner panels use `.results-bullet-stack` so the pill chrome lines
   up exactly with block 1's "Key findings". */
.results-block2-desc {
    margin: 10px 0 6px;
}
.results-block2-desc__panel {
    display: none;
}
.results-block2-desc.is-tau_p       .results-block2-desc__panel--tau_p,
.results-block2-desc.is-gbeta       .results-block2-desc__panel--gbeta,
.results-block2-desc.is-g_a         .results-block2-desc__panel--g_a,
.results-block2-desc.is-g_bayesian  .results-block2-desc__panel--g_bayesian,
.results-block2-desc.is-g_w_overall .results-block2-desc__panel--g_w_overall,
.results-block2-desc.is-g_I         .results-block2-desc__panel--g_I {
    display: flex;                /* matches .results-bullet-stack base */
}
/* Inline link to §09 bio-block-2 inside the pointer bullet. Picks up
   the accent colour and an underline so it reads as a hyperlink
   without clashing with the .rq-key pill chrome next to it. */
.results-block2-desc__link {
    color: var(--color-accent, #2d5a7b);
    text-decoration: underline;
    text-underline-offset: 2px;
    font-weight: 600;
}
.results-block2-desc__link:hover {
    color: var(--color-accent-strong, #1d4360);
    text-decoration-thickness: 2px;
}

/* §08 Block 2 figure stack — two full-grid figures stacked vertically
   under the segmented control. Top: optimal-vs-noise (lines). Bottom:
   heatmap + tolerance envelope (wrapped in .tol-anchor so the float
   tolerance UI surfaces when scrolled to). Same off-white card look
   as `.results-fig-frame` so block 2 reads as a sibling of block 1. */
.results-block2-fig-stack {
    display: flex;
    flex-direction: column;
    gap: 18px;
    margin-top: 6px;
}
.results-block2-fig {
    background: #fbfaf8;
    border: 1px solid rgba(0, 0, 0, 0.05);
    border-radius: 8px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04),
                0 1px 2px rgba(0, 0, 0, 0.03);
    padding: 10px 12px;
    overflow: hidden;
}
/* Paper mode — strip the off-white card chrome so the two full-grid
   figures sit on a flat-white page for publication export. Preserves
   padding so the plots don't reflow. Matches the §07 `.sim-topo-panel`
   and Bayesian sub-cells treatment. */
body.paper-mode .results-block2-fig {
    background: transparent;
    border-color: transparent;
    box-shadow: none;
}
.results-block2-fig-graph {
    width: 100%;
}
.results-block2-fig-graph .js-plotly-plot,
.results-block2-fig-graph .plot-container {
    width: 100% !important;
}

/* §08 Block 3 — Markov-chain revolver wheel. Each side of the block
   hosts one wheel: a 440 px-tall card with three plot items absolutely
   positioned at slot positions (prev = top peek, main = centre, next
   = bottom peek). A faint arc backdrop pivots off-canvas to the
   outside so the items look like beads on a curved rail. Clicking a
   peek rotates the wheel one step; the actual motion is CSS-driven
   (transform + opacity transition on the .wheel-item class change). */
/* §08 Block 3 — tighten the two-column gap below the default so the
   left + right wheels sit close together. The widened wheel-item
   percentages below also let each plot fill more of its column. */
.markov-wheel-grid {
    gap: 0;
}

.markov-wheel {
    position: relative;
    width: 100%;
    height: 580px;
    overflow: hidden;
    background: transparent;
    border: none;
    border-radius: 0;
    margin-top: var(--space-xs);
}
.wheel-arc {
    position: absolute;
    top: 50%;
    width: 860px;
    height: 860px;
    border-radius: 50%;
    transform: translateY(-50%);
    background: radial-gradient(circle,
        rgba(45, 90, 123, 0)     50%,
        rgba(45, 90, 123, 0.015) 55%,
        rgba(45, 90, 123, 0.03)  64%,
        rgba(45, 90, 123, 0.022) 72%,
        rgba(45, 90, 123, 0)     80%);
    pointer-events: none;
    z-index: 0;
}
.markov-wheel--left  .wheel-arc { right: 55%; }
.markov-wheel--right .wheel-arc { left:  55%; }

/* Each item: one plot tile. Absolutely positioned, sized as a fraction
   of the wheel; transition runs on transform + opacity, so the slot
   class change animates as a slide along the wheel's arc. */
.wheel-item {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 90%;
    height: 60%;
    /* Glassy main plot — translucent white with a backdrop blur so
       the section background shows through softly. Peeks override
       this with a more transparent fill below (and skip the blur to
       avoid the GPU cost on items that the user isn't focused on). */
    background: rgba(255, 255, 255, 0.42);
    -webkit-backdrop-filter: blur(10px) saturate(140%);
            backdrop-filter: blur(10px) saturate(140%);
    border: 1px solid rgba(255, 255, 255, 0.5);
    border-radius: 12px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06),
                0 1px 3px rgba(0, 0, 0, 0.04);
    display: flex;
    align-items: center;
    justify-content: center;
    transform: translate(-50%, -50%);
    opacity: 1;
    transition: transform 360ms cubic-bezier(0.34, 1.2, 0.64, 1),
                opacity 360ms ease;
    will-change: transform, opacity;
    /* Lock each wheel item into its own stacking context. Without this
       Safari's compositor sometimes reuses stale composite layers from
       a previous slot class across rotations, which leaves the new
       main slot rendered with the previous peek's reduced opacity. */
    isolation: isolate;
    z-index: 1;
}
.wheel-item-label {
    font-family: var(--font-mono, monospace);
    font-size: 1.05rem;
    color: var(--color-text-muted);
    letter-spacing: 0.03em;
}

/* Corner badge inside a wheel item — small dark pill that names the
   plot at a glance ("Groundtruth" / "Input" on the left wheel; "No
   noise" / "Optimal noise" / "Excessive noise" on the right wheel).
   `.wheel-item-badge--tr` anchors to the top-right corner (left wheel,
   per `_wheel_item` in app.py); `.wheel-item-badge--tl` anchors to the
   top-left (right wheel). The graph mount underneath is full-card, so
   the badge floats over the vis-network canvas; pointer-events are
   disabled so clicks on a peek still bubble up to the wheel-item
   rotation handler. */
.wheel-item-badge {
    position: absolute;
    top: 14px;
    z-index: 3;
    pointer-events: none;
    background: #1a1a1a;
    color: #f7f4ee;
    font-family: 'Source Sans 3', system-ui, sans-serif;
    font-size: 1.35rem;
    font-weight: 600;
    letter-spacing: 0.01em;
    line-height: 1.1;
    padding: 8px 15px;
    border-radius: 999px;
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.20);
}
.wheel-item-badge--tr { right: 16px; }
.wheel-item-badge--tl { left:  16px; }

/* Graph mount inside a wheel-item — assets/wheel_vis.js attaches a
   vis.Network to this div on page load (and on Dash mutations). The
   wheel-item itself is `display: flex` so we just stretch the mount
   to fill the card. vis-network adds its own canvas inside. */
.wheel-graph-mount {
    width: 100%;
    height: 100%;
    flex: 1 1 auto;
    background: transparent;
}
/* Centre slot — full opacity, full size, on top of everything. */
.wheel-item--main {
    transform: translate(-50%, -50%) scale(1);
    opacity: 1;
    z-index: 3;
    cursor: default;
}
/* Peek slots — half-faded, shrunken, and offset toward the wheel's
   pivot side (outside edge) so they read as "off-axis" relative to
   the centred main. Vertical translate (~145 px) lifts them above /
   drops them below the main; horizontal translate biases toward the
   outside. */
.wheel-item--prev,
.wheel-item--next {
    opacity: 0.22;
    z-index: 2;
    cursor: pointer;
    /* Peeks use a flatter fill so they read as "background context".
       We keep `backdrop-filter` declared as a zero-blur no-op (not
       `none`) so the filter chain stays alive across slot-class
       changes — Safari has a compositor bug where flipping
       `backdrop-filter` between `none` and `blur(…)` leaves stale
       opacity stuck on the previous-peek layer, which manifests as
       the new main plot rendering dimmed after a few rotations. */
    background: rgba(255, 255, 255, 0.30);
    -webkit-backdrop-filter: blur(0);
            backdrop-filter: blur(0);
    transition: transform 360ms cubic-bezier(0.34, 1.2, 0.64, 1),
                opacity 360ms ease, box-shadow 240ms ease;
}
.wheel-item--prev:hover,
.wheel-item--next:hover {
    opacity: 0.5;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.10);
}

/* Peek slots host placeholders for now but will hold interactive
   Plotly figures soon. Disable internal pointer events while in a
   peek slot so all clicks/drags bubble up to the `.wheel-item` div
   (which fires the rotation callback). The wheel-item itself stays
   clickable. The main slot lets pointer events through to Plotly so
   the focused figure can drag/zoom/hover normally. */
.wheel-item--prev > *,
.wheel-item--next > * {
    pointer-events: none;
    user-select: none;
}
.wheel-item--prev .modebar-container,
.wheel-item--next .modebar-container {
    display: none !important;
}
.wheel-item--main > * {
    pointer-events: auto;
}
.markov-wheel--left .wheel-item--prev {
    transform: translate(-65%, calc(-50% - 230px)) scale(0.66);
}
.markov-wheel--left .wheel-item--next {
    transform: translate(-65%, calc(-50% + 230px)) scale(0.66);
}
.markov-wheel--right .wheel-item--prev {
    transform: translate(-35%, calc(-50% - 230px)) scale(0.66);
}
.markov-wheel--right .wheel-item--next {
    transform: translate(-35%, calc(-50% + 230px)) scale(0.66);
}

/* §08 Block 3 — left wheel only. The 4th item ("offstage") is the
   hidden slot in the 4-item cyclic rotation. It sits in the centre
   at scale ~0.4, fully transparent, no pointer events. The CSS
   transition on .wheel-item animates the fade + scale so items
   wrap in / out of view as the wheel cycles. */
.wheel-item--offstage {
    transform: translate(-50%, -50%) scale(0.4);
    opacity: 0;
    pointer-events: none;
    z-index: 0;
}
.markov-wheel--left .wheel-item--offstage {
    transform: translate(-65%, -50%) scale(0.4);
}
.results-info-graph {
    width: 100%;
    height: 100%;
    background: transparent;
}
.results-info-graph .js-plotly-plot,
.results-info-graph .plot-container,
.results-info-graph .main-svg {
    width: 100% !important;
    height: 100% !important;
}

/* LRUD navigation arrows for the §08 Results sub-figure grid. Absolutely
   positioned on the frame borders; clicking moves the (row, col) store
   one step and the callback disables the arrow at the corresponding
   edge. Disabled arrows fade out and stop responding to hover. */
.results-fig-arrow {
    position: absolute;
    background: rgba(255, 255, 255, 0.88);
    border: 1px solid var(--color-border);
    border-radius: 50%;
    width: 32px;
    height: 32px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    padding: 0;
    z-index: 5;
    /* Subtle lift — slightly stronger on hover. */
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05);
    /* Hidden by default; revealed on frame hover (rule below). */
    opacity: 0;
    pointer-events: none;
    transition: background 0.15s ease, box-shadow 0.15s ease, opacity 0.2s ease;
}
.results-fig-frame:hover .results-fig-arrow {
    opacity: 1;
    pointer-events: auto;
}
.results-fig-arrow:hover:not(:disabled) {
    background: #fff;
    box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.08);
}
/* Disabled arrows stay faintly visible on hover so the user sees the
   edge of the grid; non-hover state keeps them fully hidden. */
.results-fig-frame:hover .results-fig-arrow:disabled {
    opacity: 0.3;
    cursor: not-allowed;
    pointer-events: none;
}

/* Pure-CSS chevron — same pattern as .chevron-up on the back-to-top
   button (two rotated borders form an angle bracket). Direction-specific
   rotation pulls the same shape into up / down / left / right. */
.results-fig-arrow::before {
    content: "";
    display: block;
    width: 8px;
    height: 8px;
    border-left: 2px solid var(--color-text-muted);
    border-bottom: 2px solid var(--color-text-muted);
    transition: border-color 0.15s ease;
}
.results-fig-arrow:hover:not(:disabled)::before {
    border-color: var(--color-text);
}
.results-fig-arrow--up::before    { transform: rotate(135deg);  margin-top: 3px; }
.results-fig-arrow--down::before  { transform: rotate(-45deg);  margin-bottom: 3px; }
.results-fig-arrow--left::before  { transform: rotate(45deg);   margin-left: 3px; }
.results-fig-arrow--right::before { transform: rotate(-135deg); margin-right: 3px; }

.results-fig-arrow--up    { top: 8px;    left: 50%;  transform: translateX(-50%); }
.results-fig-arrow--down  { bottom: 8px; left: 50%;  transform: translateX(-50%); }
.results-fig-arrow--left  { left: 8px;   top: 50%;   transform: translateY(-50%); }
.results-fig-arrow--right { right: 8px;  top: 50%;   transform: translateY(-50%); }

/* Width-split variants — applied on desktop only. On mobile both columns
   stack to full width by the column-collapse rule below. The 2-1 / 1-2
   variants also switch align-items to stretch so the narrow text column
   matches the panel's height (lets bottom-aligned controls — toolbar,
   meta — sit flush with the panel's lower edge). */
.info-row--ratio-2-1,
.info-row--ratio-1-2,
.info-row--ratio-3-2,
.info-row--ratio-2-3 {
    align-items: stretch;
}
.info-row--ratio-2-1 .info-row__media { flex: 2 1 0; }
.info-row--ratio-2-1 .info-row__text  { flex: 1 1 0; }
.info-row--ratio-1-2 .info-row__media { flex: 1 1 0; }
.info-row--ratio-1-2 .info-row__text  { flex: 2 1 0; }
.info-row--ratio-3-2 .info-row__media { flex: 3 1 0; }
.info-row--ratio-3-2 .info-row__text  { flex: 2 1 0; }
.info-row--ratio-2-3 .info-row__media { flex: 2 1 0; }
.info-row--ratio-2-3 .info-row__text  { flex: 3 1 0; }

/* Live step-driven caption card (right column next to a PH-style 3D
   panel). Solid (not dashed) — this is real content that swaps per step,
   not a wireframe placeholder. */
.info-step-caption {
    width: 100%;
    min-height: 240px;
    background: transparent;
    border: none;
    padding: var(--space-sm) var(--space-md);
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
    align-items: flex-start;
    color: var(--color-text);
    line-height: 1.6;
    transition: opacity 0.2s ease;
}

/* Step title — same dark-pill treatment as the .rq-key keyword pills
   used in §02 hero card and §05 Block 1 captions. The whole title
   becomes a single rounded pill (cream-on-near-black) that grows
   character-by-character as the typewriter types. Box-decoration-
   break: clone keeps the pill rendering clean if the title ever wraps
   to a second line. */
.info-step-caption__header {
    width: 100%;
}
.info-step-caption__header .rq-stage--inline {
    margin-top: 0;
}
.info-step-caption__header .rq-card--inline .rq-card__line {
    line-height: 1.55;
}
.info-step-caption__header .rq-card--inline .rq-typed {
    display: inline;
    background: #1a1a1a;
    color: #f6f1e6;
    font-weight: 700;
    font-size: clamp(1.3rem, 1.7vw, 1.55rem);
    line-height: 1.25;
    letter-spacing: -0.005em;
    padding: 0.12em 0.5em;
    border-radius: 0.45em;
    box-shadow: 0 3px 9px rgba(0, 0, 0, 0.22), 0 1px 2px rgba(0, 0, 0, 0.18);
    box-decoration-break: clone;
    -webkit-box-decoration-break: clone;
}
/* Title cursor scales with the title font-size via em, but bumps the
   stroke a hair so it reads at the larger weight; sits just outside
   the pill so the pill silhouette stays clean. */
.info-step-caption__header .rq-card--inline .rq-cursor {
    width: 4px;
    margin-left: 0.35em;
}
/* Once the title finishes typing, drop the caret entirely — the bold
   headline is the visual anchor, no need for a blinking pen-stroke
   after. (Body keeps its blinking caret to signal "this is live
   commentary".) The `.is-finished` class is added by setupOneRqCard
   when an ONCE-mode card reaches the end of its TEXT. */
.info-step-caption__header .rq-card.is-finished .rq-cursor {
    display: none;
}

/* Step body — same Source Sans 3 medium scale as the §05 Block 1
   static typewriter captions. Pills are intentionally not used here
   (13 steps × hand-tuned pills is too brittle); the bold header
   above does the keyword job. */
.info-step-caption__body {
    width: 100%;
}
.info-step-caption__body .rq-stage--inline {
    margin-top: 0;
}
/* Static-body fallback (no typewriter, used by step 13 so it can carry
   a clickable [N] anchor). Mirror the typewriter body's font scale so
   the static prose reads identically to the typed prose in steps 1-9. */
.info-step-caption__body-static {
    margin: 0;
    color: var(--color-text);
    font-size: clamp(1.05rem, 1.55vw, 1.35rem);
    font-weight: 500;
    line-height: 1.5;
    letter-spacing: -0.005em;
}
.info-step-caption__body-static strong {
    color: var(--color-accent);
    font-weight: 600;
}

/* ===== §05 Block 3 — Mathematical details (collapsible) =====
   Slim BCPNN equation cheat-sheet rendered via dcc.Markdown
   (mathjax=True). Card sits on cream with a thin terracotta left rail
   so it visually belongs to the §05 commentary family (same rail as
   the step caption). Body sans for prose, MathJax owns the equation
   typography. */
.bcpnn-equations {
    width: 100%;
    background: var(--color-bg);
    border-left: 4px solid var(--color-accent);
    padding: var(--space-md) calc(var(--space-md) + 0.25rem);
    border-radius: 0 14px 14px 0;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.bcpnn-equations__md p {
    font-family: var(--font-body);
    font-size: clamp(1.0rem, 1.4vw, 1.18rem);
    font-weight: 500;
    line-height: 1.6;
    color: var(--color-text);
    margin: 0.4rem 0 1.1rem;
    letter-spacing: -0.005em;
}
.bcpnn-equations__md p strong {
    font-weight: 700;
    color: var(--color-text);
}
.bcpnn-equations__md p:last-child {
    margin-bottom: 0;
    color: var(--color-text-muted);
    font-size: clamp(0.95rem, 1.3vw, 1.08rem);
    font-style: italic;
}
/* MathJax wraps each display block in a .MathJax / mjx-container
   element; center it and give it breathing room so equations don't
   crowd the gloss text. */
.bcpnn-equations__md mjx-container[display="true"],
.bcpnn-equations__md .MathJax_Display,
.bcpnn-equations__md .katex-display {
    display: block;
    margin: 0.4rem 0 1.4rem;
    text-align: center;
    font-size: 1.05em;
}

/* ===== BCPNN §05 — Block 2 panel + step toolbar ===== */
/* Host element for the Three.js canvas mounted by the inline module in
   app.index_string. Aspect-ratio gives the canvas a defined height before
   the renderer attaches. The renderer manages the canvas's own size; we
   only style the host container here. */
.bcpnn-panel-host {
    width: 100%;
    aspect-ratio: 16 / 9;
    background: transparent;
    border: none;
    border-radius: 20px;
    overflow: hidden;
    /* Containing block for absolutely-positioned overlay controls
       (auto-rotate button, future legend/play/pause chips). */
    position: relative;
}

.bcpnn-panel-host canvas {
    display: block;
    border-radius: 20px;
}

/* ===== Overlay buttons on a Three.js panel host =====
   Floating, semi-transparent corner controls (e.g. auto-rotate). The
   panel's canvas sits underneath; pointer-events on the button itself
   stay active so clicks register. .is-on flips to the accent fill,
   matching scratch/bcpnn_3d.html's Plot A1 button family. */
.bcpnn-overlay-btn {
    position: absolute;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 5px 7px;
    background: rgba(255, 255, 255, 0.85);
    border: 1px solid var(--color-border);
    border-radius: 8px;
    color: var(--color-text-muted);
    cursor: pointer;
    z-index: 2;
    backdrop-filter: blur(4px);
    transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
    line-height: 1;
}

.bcpnn-overlay-btn:hover {
    background: var(--color-accent-light);
    color: var(--color-accent);
    border-color: var(--color-accent);
}

.bcpnn-overlay-btn:active {
    transform: translateY(1px);
}

.bcpnn-overlay-btn.is-on {
    background: var(--color-accent);
    color: #ffffff;
    border-color: var(--color-accent);
}

/* Click on the SVG icon inside a button should land on the button
   itself, not the path. Without this, pointerdown on the path can
   leave the click target ambiguous when the canvas underneath also
   listens for pointerdown (OrbitControls). */
.bcpnn-overlay-btn svg {
    pointer-events: none;
}

/* Pin the Three.js canvas at the bottom of the host's stacking order
   so overlay buttons (z-index: 2) are unambiguously on top. Without
   the explicit z-index: 0, the canvas's static positioning can cause
   inconsistent hit-testing on the very first click after mount. */
.bcpnn-panel-host canvas,
.training-plot-host canvas {
    position: relative;
    z-index: 0;
}

/* Pulsing glow on the ▶ button at gated phases (AUTO_PAUSE, HELD_1):
   nudges the user to click again so the timeline can advance. Same
   animation curve as scratch's .plot-toggle-icon.is-resume. */
.bcpnn-overlay-btn.is-resume,
.bcpnn-overlay-btn.is-held1 {
    color: var(--color-accent);
    border-color: var(--color-accent);
    animation: bcpnn-pulse-glow 1.6s infinite ease-in-out;
}

@keyframes bcpnn-pulse-glow {
    0%, 100% {
        box-shadow: 0 0 0 0 rgba(45, 90, 123, 0.5);
        background: rgba(255, 255, 255, 0.92);
    }
    50% {
        box-shadow: 0 0 0 8px rgba(45, 90, 123, 0);
        background: rgba(232, 240, 248, 1);
    }
}

.bcpnn-overlay-btn--top-left     { top: 12px;    left: 12px; }
.bcpnn-overlay-btn--top-right    { top: 12px;    right: 12px; }
.bcpnn-overlay-btn--bottom-left  { bottom: 12px; left: 12px; }
.bcpnn-overlay-btn--bottom-right { bottom: 12px; right: 12px; }

.bcpnn-step-toolbar {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-md);
    margin-top: var(--space-sm);
}

/* When the toolbar lives inside the right caption column (info-row__text
   is flex-column), push it to the bottom so its baseline lines up with
   the panel's lower edge in the wider sibling column. */
.info-row__text > .bcpnn-step-toolbar {
    margin-top: auto;
    padding-top: var(--space-md);
}

.bcpnn-step-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    padding: 0;
    background: var(--color-bg);
    border: 1px solid var(--color-border);
    border-radius: 50%;
    font-size: 0.95rem;
    color: var(--color-text);
    cursor: pointer;
    transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}

.bcpnn-step-btn:hover {
    background: var(--color-accent-light);
    border-color: var(--color-accent);
    color: var(--color-accent);
}

.bcpnn-step-btn:active {
    transform: translateY(1px);
}

.bcpnn-step-indicator {
    font-family: var(--font-body);
    font-size: 0.9rem;
    color: var(--color-text-muted);
    min-width: 96px;
    text-align: center;
}

@media (max-width: 768px) {
    .info-row,
    .info-row--reverse {
        flex-direction: column;
        align-items: stretch;
    }
}

/* Ratio variants (2:1 / 1:2) collapse to a single column at a wider
   breakpoint than the 1:1 image rows: their narrow side is a caption /
   toolbar column that gets too cramped on tablet-portrait widths long
   before phone widths. Tuned to the 1100 px section max-width — below
   ~980 px the 1/3 column shrinks under ~300 px and the panel above
   gets disproportionately wide. */
@media (max-width: 980px) {
    .info-row--ratio-2-1,
    .info-row--ratio-1-2,
    .info-row--ratio-3-2,
    .info-row--ratio-2-3 {
        flex-direction: column;
    }
}

/* ===== Section content prose ===== */
section .container > p {
    color: var(--color-text-muted);
    max-width: 720px;
    margin-bottom: var(--space-sm);
}

section .container > p strong {
    color: var(--color-text);
}

/* ===== Footer ===== */
footer {
    padding: var(--space-md) 0;
    border-top: 1px solid var(--color-border);
    text-align: center;
}

footer p {
    font-size: 0.85rem;
    color: var(--color-text-muted);
}

/* ===== Responsive: Tablet+ ===== */
@media (min-width: 768px) {
    .nav-links {
        display: flex;
    }

    .nav-toggle,
    .nav-mobile {
        display: none;
    }
}

@media (max-width: 768px) {
    .hero {
        padding-top: 100px;
    }
}

/* ===== Animations ===== */
@keyframes fadeUp {
    from {
        opacity: 0;
        transform: translateY(20px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.animate-in {
    animation: fadeUp 0.6s ease forwards;
}

/* ----- §09 Biocounterparts ----------------------------------------------
   Each block (Neural noise / Tonic DA / Parkinson's) is a `.viz-block`
   that contains 2–3 `.bio-subrow` cards. A sub-row is a labeled
   sub-point: a small "1.1" number pill + sub-heading on top, followed
   by a 2-column grid (typed prose + placeholder-figure stub). Rows
   alternate fig-on-right / fig-on-left via `fig_first=True` in the
   builder — set in `_bio_subrow` (app.py). Images can be swapped in
   later by replacing the placeholder Div with an `html.Img`.
*/
.bio-subrow {
    margin-top: var(--space-md);
}

.bio-subrow:first-of-type {
    /* The first sub-row sits right under the block title — let the
       block-title's bottom border carry the visual separator. */
    margin-top: var(--space-sm);
}

.bio-subrow-head {
    display: flex;
    align-items: baseline;
    gap: 0.75rem;
    margin-bottom: var(--space-sm);
}

.bio-subrow-num {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 2.6rem;
    padding: 0.18rem 0.6rem;
    border-radius: 999px;
    background: var(--color-accent);
    color: #ffffff;
    font-family: var(--font-mono);
    font-size: 0.95rem;
    font-weight: 700;
    letter-spacing: 0.02em;
}

.bio-subrow-title {
    font-family: var(--font-display);
    font-size: 1.35rem;
    color: var(--color-text);
}

/* Float-based wrap layout: the figure floats to one side at a fixed
   fraction of the row width, and the typed prose flows around it. If
   the text outruns the figure's height, the overflow lines snap back
   to full row width — exactly what magazine "wrap-around-image"
   layouts do. `display: flow-root` contains the float so the next
   sub-row starts cleanly below this one (no clearfix hacks). */
.bio-subrow-body {
    display: flow-root;
}

.bio-subrow-text {
    font-size: 1.15rem;
    line-height: 1.6;
    color: var(--color-text);
}

/* Multi-paragraph sub-rows (e.g. §3.2) pass a list of typewriter
   blocks as children. Each block produces its own `.rq-stage`, so we
   nudge sibling stages apart for readable paragraph spacing without
   touching single-block rows. */
.bio-subrow-text > .rq-stage + .rq-stage {
    margin-top: var(--space-md);
}

/* Figure floats right by default and reads as a white card — the
   chrome (background + border + soft shadow + inner padding) makes
   the white space around the image visible. Margin gives the prose
   breathing room on the side facing the text; the opposite side
   hugs the row edge so the card aligns with the section gutter.
   Width sits at 75 % of the row, max 900 px. */
.bio-subrow-fig {
    float: right;
    width: 75%;
    max-width: 900px;
    margin: 0 0 var(--space-sm) var(--space-md);
    background: #ffffff;
    border: 1px solid var(--color-border);
    border-radius: 14px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05),
                0 1px 3px rgba(0, 0, 0, 0.03);
    padding: var(--space-md);
}

/* `fig_first=True` flips the float side. Mirror the margins so the
   text-facing side is still the one with the gap. */
.bio-subrow-body--fig-left .bio-subrow-fig {
    float: left;
    margin: 0 var(--space-md) var(--space-sm) 0;
}

/* `stacked=True` overrides the float layout for the sub-rows that
   ship a horizontal/banner image (e.g. §1.1 noise-in-bio-systems).
   Base `.bio-subrow-fig` already provides the white-card chrome —
   here we only swap float/width/margin so the card spans the full
   row width instead of floating. The image inside sits centered at
   80 % of the card's content area, leaving visible padding around
   the banner. Text follows below in the natural reading order. */
.bio-subrow-body--stacked .bio-subrow-fig {
    float: none;
    width: 100%;
    max-width: none;
    margin: 0 0 var(--space-md) 0;
}

.bio-subrow-body--stacked .bio-subrow-img {
    width: 100%;
    margin: 0;
}

/* Itemized panel inside `.bio-subrow-text`. Matches the screenshot
   reference: bold sans heading on top, then a clean list with no
   visible bullet markers, generous line-height. Used by §1.1 for
   the "Neuronal noise sources absorbed into σ" enumeration. */
.bio-list {
    margin: var(--space-sm) 0;
}

.bio-list-title {
    font-family: var(--font-body);
    font-weight: 700;
    font-size: 1.25rem;
    color: var(--color-text);
    margin-bottom: var(--space-sm);
}

.bio-list-items {
    list-style: none;
    padding-left: 0;
    margin: 0;
}

.bio-list-items li {
    font-size: 1.15rem;
    line-height: 1.7;
    color: var(--color-text);
}

/* §2.2 — two dark-teal cards side by side ("Tonic dopamine modulation"
   vs "BCPNN as Internal model"), used in `no_fig=True` mode where
   the standard prose-plus-image layout would not fit. Card colour
   matches `--color-accent` (the section's hero blue). Collapses to
   a single column under 900 px. */
.bio-cards-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--space-md);
    margin-top: var(--space-sm);
}

.bio-card {
    background: #ffffff;
    color: var(--color-text);
    border: 1px solid var(--color-border);
    border-radius: 18px;
    padding: var(--space-md);
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.05),
                0 1px 3px rgba(0, 0, 0, 0.03);
}

.bio-card-title {
    font-family: var(--font-body);
    font-weight: 700;
    font-size: 1.5rem;
    color: var(--color-text);
    margin-bottom: var(--space-md);
}

.bio-card-list {
    list-style: disc;
    padding-left: 1.25rem;
    margin: 0;
}

.bio-card-list li {
    font-size: 1.25rem;
    line-height: 1.55;
    color: var(--color-text);
    margin-bottom: var(--space-md);
}

.bio-card-list li:last-child {
    margin-bottom: 0;
}

@media (max-width: 900px) {
    .bio-cards-grid {
        grid-template-columns: 1fr;
    }
}

/* ----- Remark section --------------------------------------------------
   Standalone summary inserted between §09 Biocounterparts and §10
   References. Three stacked white cards (Main Finding / Key Results /
   Takeaway) reuse `.bio-card` chrome but at a slightly larger reading
   size — this section is the closing recap, so it should read like a
   summary slide. */
.remark-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-md);
    margin-top: var(--space-md);
}

.remark-card .bio-card-title {
    font-size: 1.6rem;
}

.remark-card .bio-card-list li {
    font-size: 1.2rem;
    line-height: 1.6;
}

/* Plain prose inside Main Finding / Takeaway. No typewriter chrome —
   just regular body type bumped to a presentation-friendly size so
   the recap reads from across the room. */
.remark-card-text p {
    margin: 0;
    font-size: 1.25rem;
    line-height: 1.65;
    color: var(--color-text);
}

/* ----- Clickable highlight → image popup --------------------------------
   Trigger: a `.rq-key` highlight pill marked with `.rq-popup-trigger`
   and `data-popup-src` / `data-popup-alt` attrs. JS in script.js
   creates a single global `.rq-popup-overlay` once on init and
   populates it with the trigger's image whenever the user clicks.
   Keeping the popup out of the trigger DOM avoids event-bubble
   re-toggle races. Used by §1.2 (clinical example).
*/
.rq-popup-trigger {
    cursor: pointer;
    user-select: none;
}

.rq-popup-trigger:hover {
    filter: brightness(1.08);
}

.rq-popup-trigger:focus-visible {
    outline: 2px solid var(--color-accent);
    outline-offset: 3px;
    border-radius: 6px;
}

/* Single global overlay built by JS on init. `display: none` keeps
   it inert until JS adds `.is-open`. */
.rq-popup-overlay {
    position: fixed;
    inset: 0;
    display: none;
    align-items: center;
    justify-content: center;
    background: rgba(0, 0, 0, 0.45);
    padding: var(--space-md);
    z-index: 9000;
}

.rq-popup-overlay.is-open {
    display: flex;
}

.rq-popup-card {
    position: relative;
    background: #ffffff;
    border-radius: 18px;
    box-shadow: 0 24px 60px rgba(0, 0, 0, 0.30),
                0 4px 14px rgba(0, 0, 0, 0.12);
    padding: 1rem;
    max-width: 96vw;
    max-height: 96vh;
    overflow: auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.9rem;
}

.rq-popup-img {
    display: block;
    width: auto;
    height: auto;
    max-width: 100%;
    max-height: calc(96vh - 2rem);
    object-fit: contain;
    border-radius: 10px;
}
/* When the popup carries a caption, shrink the image's max-height so
   there's room for the prose underneath. ~7rem covers a 3-line caption
   at the popup's 1.05rem font-size + the card gap. */
.rq-popup-card.has-caption .rq-popup-img {
    max-height: calc(96vh - 2rem - 7rem);
}
.rq-popup-caption {
    display: none;
    max-width: min(90ch, 96vw);
    color: var(--color-text);
    font-size: 1.05rem;
    line-height: 1.55;
    text-align: left;
    padding: 0 0.5rem;
}
.rq-popup-card.has-caption .rq-popup-caption {
    display: block;
}

.rq-popup-close {
    position: absolute;
    top: 10px;
    right: 12px;
    width: 32px;
    height: 32px;
    background: rgba(0, 0, 0, 0.08);
    border: none;
    font-size: 1.3rem;
    line-height: 1;
    color: var(--color-text);
    cursor: pointer;
    padding: 0;
    border-radius: 999px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.rq-popup-close:hover {
    background: rgba(0, 0, 0, 0.16);
}

.bio-subrow-fig > .placeholder-figure {
    min-height: 200px;
}

/* Real-image chrome inside the white card. The card carries the
   outer shadow + rounded corners + padding; the image itself just
   needs a small inner corner radius so it doesn't sit pixel-perfect
   against the card's inner background. Width fills the card's
   content area — the padding from `.bio-subrow-fig` is what creates
   the visible white frame around the image. The `--stacked`
   modifier keeps its own 80 %-centered override. */
.bio-subrow-img {
    display: block;
    width: 100%;
    height: auto;
    border-radius: 8px;
}
/* Image flagged as a side-ad trigger (§09 §3.1 PD figure). Acts as a
   button — clickable, hover-ring affordance — but keeps its inline
   sizing intact. */
.bio-subrow-img--pd-ad-trigger {
    cursor: pointer;
    transition: box-shadow 180ms ease;
}
.bio-subrow-img--pd-ad-trigger:hover {
    box-shadow: 0 0 0 2px rgba(45, 90, 123, 0.35);
}
.bio-subrow-img--pd-ad-trigger:focus-visible {
    outline: 2px solid var(--color-accent, #2d5a7b);
    outline-offset: 3px;
}

/* PD §3.1 side "ads" — fixed-position vertical callouts in the
   left/right viewport margins. Hidden by default; the trigger image
   toggles `.is-visible`. Slide in from off-screen on reveal. Mimic
   newspaper-website margin ads (kicker label, headline, short body,
   close button) without screaming for attention. */
.pd-side-ad-host {
    /* No layout role — just a wrapper so the two siblings can sit at
       a known DOM location. */
    display: contents;
}
.pd-side-ad {
    position: fixed;
    top: 12vh;
    width: 380px;
    max-height: 78vh;
    background: #fbfaf8;
    border: 1px solid rgba(0, 0, 0, 0.08);
    border-radius: 10px;
    padding: 18px 18px 16px;
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06),
                0 2px 4px rgba(0, 0, 0, 0.04);
    opacity: 0;
    pointer-events: none;
    transition: opacity 220ms ease,
                transform 280ms cubic-bezier(.16, .84, .44, 1);
    z-index: 60;
    overflow-y: auto;
    color: var(--color-text);
}
/* Anchor to the central content column (`.container` is capped at
   `var(--max-width)` = 1100 px, centred via `margin: 0 auto`). Each
   panel sits just outside that column on its side, regardless of how
   wide the viewport gets. Falls through to the media-query hide
   below once the side margins drop under one panel-width + gap. */
.pd-side-ad--left {
    left: auto;
    right: calc(50vw + (var(--max-width) / 2) + 12px);
    transform: translateX(-32px);
}
.pd-side-ad--right {
    right: auto;
    left: calc(50vw + (var(--max-width) / 2) + 12px);
    transform: translateX(32px);
}
.pd-side-ad.is-visible {
    opacity: 1;
    pointer-events: auto;
    transform: translateX(0);
}
.pd-side-ad__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding-bottom: 8px;
    border-bottom: 1px solid rgba(0, 0, 0, 0.06);
    margin-bottom: 8px;
}
.pd-side-ad__kicker {
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}
.pd-side-ad__close {
    background: none;
    border: none;
    color: var(--color-text-muted);
    font-size: 1.15rem;
    cursor: pointer;
    padding: 0 4px;
    line-height: 1;
    border-radius: 4px;
}
.pd-side-ad__close:hover {
    color: var(--color-text);
    background: rgba(0, 0, 0, 0.04);
}
.pd-side-ad__title {
    font-family: var(--font-display, inherit);
    font-size: 0.98rem;
    font-weight: 700;
    margin-bottom: 6px;
}
/* Body font: 1rem ≈ 40% smaller than the previous 1.66rem (which was
   2× the original 0.83rem). Question heading shares the size with
   the answer, distinguished by font-weight. Each Q&A block gets a
   generous bottom-margin so the two questions on a panel breathe. */
.pd-side-ad__body {
    font-size: 1.25rem;
    line-height: 1.45;
}
.pd-side-ad__body p {
    margin: 0 0 0.55em;
}
.pd-side-ad__body p:last-child {
    margin-bottom: 0;
}
.pd-side-ad__qa {
    margin-bottom: 1.15em;
}
.pd-side-ad__qa:last-child {
    margin-bottom: 0;
}
.pd-side-ad__qa-q {
    font-weight: 700;
    margin-bottom: 0.35em;
    color: var(--color-text);
    line-height: 1.3;
}
.pd-side-ad__qa-a {
    color: var(--color-text);
    line-height: 1.45;
}
/* Narrow viewports: there's no margin room beside the centred content
   column to hold the panels. 1900 px ≈ 1100 (content) + 2 × (380
   panel + ~30 breathing). Below that, hide them — the audience only
   needs the in-flow story there. */
@media (max-width: 1900px) {
    .pd-side-ad {
        display: none;
    }
}

/* Narrow viewports: floating a half-width figure leaves the text
   columns too narrow to read, so collapse to a stacked layout. */
@media (max-width: 900px) {
    .bio-subrow-fig,
    .bio-subrow-body--fig-left .bio-subrow-fig {
        float: none;
        width: 100%;
        max-width: none;
        margin: 0 0 var(--space-sm) 0;
    }
}

/* ----- Laser-pointer cursor (presentation mode) -------------------------
   Toggled by the L key — see `__laserInit` in script.js. `body.laser-on`
   hides the system cursor; a glowing red dot (`.laser-cursor`) tracks the
   mouse, dropping fading comet-trail dots (`.laser-trail`) and emitting an
   expanding ripple (`.laser-ripple`) on click. All three are
   `pointer-events: none` so they never intercept clicks. Each is
   positioned by a JS-set `transform: translate(x, y)`; the centering
   offset and any size/scale animation live on a `::before` child so they
   don't fight the JS transform. */
body.laser-on {
    cursor: none;
}

/* Hand the real cursor back over interactive controls. */
body.laser-on button,
body.laser-on a,
body.laser-on input,
body.laser-on select,
body.laser-on textarea,
body.laser-on summary,
body.laser-on [role="button"],
body.laser-on [role="tab"],
body.laser-on .wheel-item,
body.laser-on .modebar-btn {
    cursor: pointer;
}

/* Plotly stamps a crosshair on its drag layer; null it so the laser dot
   is the only thing visible over a plot (the audience never pans/zooms
   these). Modebar buttons keep the pointer via the rule above. */
body.laser-on .nsewdrag,
body.laser-on .js-plotly-plot .drag {
    cursor: none !important;
}

.laser-cursor,
.laser-trail,
.laser-ripple {
    position: fixed;
    left: 0;
    top: 0;
    width: 0;
    height: 0;
    pointer-events: none;
}

/* Glowing red dot — persistent while laser mode is on. */
.laser-cursor {
    z-index: 100000;
    opacity: 0;
    transition: opacity 120ms ease;
}
.laser-cursor.is-visible {
    opacity: 1;
}
.laser-cursor::before {
    content: "";
    position: absolute;
    width: 34px;
    height: 34px;
    margin-left: -17px;
    margin-top: -17px;
    border-radius: 50%;
    background: radial-gradient(
        circle at center,
        rgba(255, 255, 255, 0.95) 0%,
        rgba(255, 64, 42, 0.95) 20%,
        rgba(228, 32, 20, 0.55) 40%,
        rgba(228, 32, 20, 0) 72%
    );
}

/* Comet-trail dot — spawned per frame, fades + shrinks then removes. */
.laser-trail {
    z-index: 99999;
}
.laser-trail::before {
    content: "";
    position: absolute;
    width: 13px;
    height: 13px;
    margin-left: -6.5px;
    margin-top: -6.5px;
    border-radius: 50%;
    background: radial-gradient(
        circle at center,
        rgba(255, 92, 60, 0.7) 0%,
        rgba(228, 32, 20, 0) 70%
    );
    animation: laser-trail-fade 460ms ease-out forwards;
}
@keyframes laser-trail-fade {
    from { transform: scale(1);   opacity: 0.65; }
    to   { transform: scale(0.3); opacity: 0; }
}

/* Click ripple — expanding ring, fades then removes. */
.laser-ripple {
    z-index: 99998;
}
.laser-ripple::before {
    content: "";
    position: absolute;
    width: 18px;
    height: 18px;
    margin-left: -9px;
    margin-top: -9px;
    border-radius: 50%;
    border: 2px solid rgba(255, 64, 42, 0.85);
    animation: laser-ripple-expand 520ms ease-out forwards;
}
@keyframes laser-ripple-expand {
    from { transform: scale(0.4); opacity: 0.85; }
    to   { transform: scale(3.6); opacity: 0; }
}

/* Respect reduced-motion: keep the dot, drop trail + ripple motion. */
@media (prefers-reduced-motion: reduce) {
    .laser-trail::before,
    .laser-ripple::before {
        animation-duration: 1ms;
    }
}
