/* static/css/reading-v2.css — the v2 card-navigator reading UI.
 *
 * Sixth of the seven stylesheets split out of the former single
 * ``gallae.css`` (tasks/FILE_SPLIT_TASKS.md Phase 1). It is the whole
 * Reading-UI-v2 navigator, scoped under ``.container--post-detail``: the
 * narrow (<880px) single-column base layer and the ``@media (min-width:880px)``
 * desktop spine + sibling carousel. This file is ORDER-CRITICAL internally —
 * the base and desktop layers override each other by equal-specificity source
 * order (documented in-file); never reorder within it. MUST load after the
 * page files and before ``reading-tools.css`` (whose ultra-wide re-centre
 * overrides the ≥880px block).
 */

/* =====================================================================
 * Reading UI v2 — card navigator: spine + sibling carousels (RV.2.1)
 *
 * Phase 2 of the reading redesign (tasks/READING_V2_TASKS.md), extended
 * through Phase 6 (the narrow layout). Everything here is scoped under
 * ``.container--post-detail`` — the post-reading page's <main> container
 * class (set by the container_modifier block in works/post_detail.html, on
 * top of base.html's ``.container``), unique to that page. (Through Phases
 * 0–8 this was scoped under a ``[data-reading-ui="next"]`` marker that a
 * resolver stamped to pick v1 vs v2; RV.9.2 cut over to v2 as the only
 * reading UI and re-scoped onto the page's own container class.) The CSS is
 * in two layers, mobile-first:
 *
 *   - The BASE layer (no media query) is the NARROW (<880px) layout (RV.6.1):
 *     a single-column stack of the active path — one full-width card per
 *     level, off-path siblings hidden and switched via the per-card
 *     "‹ N of M ›" prev/next bar (no carousel), comments opening as a bottom
 *     sheet. It also holds the card chrome (the highlight borders) shared by
 *     both widths. The hard rule here is NO horizontal scroll at any width —
 *     so this layer carries no ``overflow-x``
 *     (works/tests/test_reading_ui_responsive.py guards that).
 *   - The ``@media (min-width: 880px)`` layer is the DESKTOP "full navigator":
 *     the vertical spine + horizontal sibling carousels (peeks + the "N of M"
 *     indicator) + the bounded-sticky comment side rail. The carousel's
 *     ``overflow-x`` lives here, the one place horizontal scroll is allowed.
 *     The base narrow rules that must NOT apply at desktop (hide off-path
 *     siblings, the bottom sheet, the prev/next bar) are each overridden here.
 *
 * The v1 subgrid (pages.css) is deliberately left OUT of v2 scope — the
 * desktop rules below override it with a flex carousel.
 *
 * The model (see "Card anatomy" / "Vertical spine" / "Sibling carousel" /
 * "Active highlight" in the task file):
 *
 *   - The active root→node path is a VERTICAL SPINE: the post card, then
 *     the active top-level riff, then its active child, and so on. Each
 *     active card shows its children below it; non-active siblings hide
 *     theirs, which is what collapses the tree into a single spine. The
 *     default active path is the first-child chain, stamped server-side
 *     (works.views._stamp_active_path) as ``data-on-active-path`` so the
 *     spine lays out with JS off; pathnav.js (Phase 3) stamps the single
 *     focused card's ``data-active`` and re-roots on navigation.
 *   - Each sibling group is a HORIZONTAL CAROUSEL: one active card, the
 *     rest peeking at the edges. flex + scroll-snap; the active card is
 *     centered by pathnav.js (Phase 3, scrollIntoView). Before that it
 *     sits at the START of its track — a fine pre-JS state: the spine
 *     reads as a left-aligned column with siblings peeking to the right.
 *
 * Spatial values are deliberately CSS custom properties: the task marks
 * the gap / peek / card width as "tune against render", and the active
 * border is built from colour/width/style props so the planned "choose
 * your highlight" user preference becomes a value swap, not a rewrite.
 * The "feel" (exact peek, the parent-pin amount, transition timings) is
 * validated by eye, not asserted here; the one
 * machine-checked contract is the SSR ``data-on-active-path`` / ``data-active``
 * stamping (works/tests/test_reading_ui_spine.py) and the pathnav reducer's
 * path math (js-tests/pathnav-reducer.test.js, run by `pnpm test:js`).
 *
 * Since-Phase-2 updates (what landed where): comment areas are positioned
 * (Phase 5.5 — per-card bounded-sticky panels at desktop, bottom sheet at
 * narrow); active-card centering + scroll-driven re-centering landed in
 * Phase 3/4. Still deferred by design:
 *   - No parent pin yet. The design wants each parent's BOTTOM edge (its
 *     last 2–3 lines) to stick to the viewport top as you scroll into the
 *     active card. CSS `sticky` only pins the TOP, which freezes a tall
 *     post over the whole viewport (hiding the riffs behind it), so the
 *     pin waits on a JS sliver measurement (not yet built); the spine just
 *     scrolls cleanly until then.
 * ------------------------------------------------------------------ */

/* Tunable knobs. Defined on the navigator root itself (the <main>
 * ``.container--post-detail`` element, outside the media query — they are
 * inert until the rules below consume them) so BOTH the page-shell rule
 * (which caps the container at ``--rv-navigator-width``) and the whole v2
 * subtree inherit one set of values to adjust against the real render. */
.container--post-detail {
    /* Card (story) width — FLUID, not fixed. It grows with the viewport from
     * ``--rv-card-min`` at the desktop floor to ``--rv-card-max`` once there's
     * room, so the story column flexes rather than sitting at one width. The
     * comment rail width is derived from this (``--rv-card-width − D`` below),
     * which is what makes the story : comments ratio glide 2:1 → 3:2 without a
     * breakpoint: card and comments grow by the SAME amount (equal growth from
     * a constant difference D), so their ratio compresses from 2:1 toward 1:1
     * as both get bigger. It starts at 2:1 (30rem card / 15rem comment, D =
     * --rv-card-comment-diff = 15rem) and glides down as both grow. The chat
     * box is independently capped at 500px (see --rv-comment-rail-width), so
     * past the point where the derived comment width hits that cap the card
     * keeps widening alone toward its --rv-card-max. The fluid middle term
     * ramps card from min at ~880px (55rem) toward max; slope 0.4 = 15rem of
     * growth over 37.5rem of viewport. Tunable starting points — verify by
     * eye. */
    --rv-card-min: 30rem;
    --rv-card-max: 50rem;
    --rv-card-comment-diff: 15rem;
    --rv-card-width: clamp(
        var(--rv-card-min),
        calc(var(--rv-card-min) + (100vw - 55rem) * 0.4),
        var(--rv-card-max)
    );
    /* Centring the [rail · card · comments] composite (not just the card).
     *
     * The card sits between two overlays: the metadata rail (left) and the
     * comment box (right). They're different widths — the comment box is the
     * card minus D (wide); the rail is just a narrow text column — so centring
     * the CARD would leave the composite lopsided (comments stick out further
     * right). To get equal empty space beyond the rail and beyond the comments,
     * we centre the whole composite, which means nudging the card LEFT of
     * page-centre by half that width asymmetry (the page-shell rule below).
     *
     * ``--rv-rail-reserve`` is the rail's assumed visible width — it's
     * content-sized, so this is an estimate, and the symmetry is therefore
     * "roughly equal", off by however far the real rail text differs from it.
     * ``--rv-card-overhang`` is how far the card's left edge sits inside the
     * container's left edge (container padding + half the navigator's
     * gap+peek), letting the page-shell rule turn a target card position into a
     * container margin. ``--rv-card-right-offset`` is where the card's RIGHT
     * edge lands relative to page-centre once centred — it falls out as a
     * constant, (D + rail-reserve)/2, independent of the (fluid) card width, so
     * the no-JS overlay fallbacks anchor off it. */
    --rv-rail-reserve: 6rem;
    --rv-card-overhang: calc(1rem + (var(--rv-sibling-gap) + var(--rv-peek)) / 2);
    --rv-card-right-offset: calc(
        (var(--rv-card-comment-diff) + var(--rv-rail-reserve)) / 2
    );
    /* Parent→child vertical gap down the spine. */
    --rv-spine-gap: calc(5px + 0.5rem);
    /* Gap between sibling cards in a carousel. */
    --rv-sibling-gap: 1.8rem;
    /* How much of a neighbour card peeks past the active one. With the active
     * card centred (Phase 3+), this slack is SPLIT across both sides, so each
     * neighbour shows ~(peek − sibling-gap)/2; the card body's 2rem horizontal
     * padding eats the first 2rem of that, so a peek must clear ~2rem before any
     * text shows. At 12rem each side reveals ~5.4rem — ~3.4rem of text past the
     * padding, a word's width — versus the old 5rem, which showed only a warm
     * padding sliver. Upper bound ~12.75rem: beyond it the page-shell
     * margin-left (overhang grows with peek) goes negative at the 880px
     * breakpoint and the navigator overflows (horizontal scroll). */
    --rv-peek: 12rem;
    /* The visible width of one neighbour's peek (the same ~(peek − gap)/2 the
     * note above derives). The peek-fade mask spans this so a sibling's text
     * fades from opaque at the edge nearest the centred card to transparent
     * right at the clip — keep it in step with --rv-peek / --rv-sibling-gap. */
    --rv-sibling-peek-visible: calc(
        (var(--rv-peek) - var(--rv-sibling-gap)) / 2
    );
    /* The peek-fade mask holds full opacity for this lead-in before it starts
     * fading. It equals the card body's 2rem horizontal padding: the nearest
     * 2rem of a peek is blank padding, so without the lead the first *text*
     * would already be mid-fade. Keep it equal to .contribution-body's
     * horizontal padding so the nearest word reads at full strength. */
    --rv-sibling-peek-solid: 2rem;
    /* How far in from each carousel edge the WHILE-SLIDING mask fades (the
     * stationary, viewport-anchored variant used during a swipe — see the
     * .rv-carousel-sliding rules). It must clear the neighbour's blank padding
     * AND reach full opacity by the first text, i.e. visible − solid, so the
     * centred card lands fully opaque and a sliding-in card emerges as it
     * crosses this band. */
    --rv-sibling-peek-fade: calc(
        var(--rv-sibling-peek-visible) - var(--rv-sibling-peek-solid)
    );
    /* A peeking sibling taller than its row's centred card is clipped to that
     * card's height (pathnav.js sets the inline max-height) so it can't dangle
     * down past the centred card's bottom and overlap the row beneath it. This
     * is the height of the fast bottom fade that lands fully transparent right
     * at the clip — level with the centred card's bottom edge. */
    --rv-peek-cap-fade: 2rem;
    /* Navigator width = the active card plus ONE gap + peek. Phase 2 leaves
     * the active card at the START of its carousel (centering is Phase 3's
     * scrollIntoView), and the default active path is the first-child chain,
     * so the active card is always the first sibling: nothing peeks on the
     * left and the nearest sibling peeks --rv-peek on the right. Sizing the
     * column for two-sided peeks (card + 2*(gap+peek)) while the card is
     * pinned to the left dumped ALL that slack onto the right edge, so ~5rem
     * of the next sibling's body showed instead of a 2rem blank edge. When
     * Phase 3 centers the active card this widens back to two-sided (the
     * design allows up to two peeks per side). */
    --rv-navigator-width: calc(
        var(--rv-card-width) + var(--rv-sibling-gap) + var(--rv-peek)
    );
    /* Active-card highlight border, split into colour/width/style so the
     * planned per-user highlight preference is a value swap. */
    --rv-active-border-color: var(--accent);
    --rv-active-border-width: 5px;
    --rv-active-border-style: solid;
    /* RV.5.5 — per-card comment panel. An OPEN panel becomes a fixed-width
     * panel beside its card, bottom-anchored to the card's bottom edge (pathnav.js
     * sets `bottom` + `max-height` per-frame via commentBoxVertical). Width /
     * viewport-top offset / stack
     * order are tunable starting points; the pin feel + the right-peek
     * collision are validated by eye.
     * ``--rv-comment-rail-top`` doubles as the pin's offset, read by
     * pathnav.js (so keep it in px-resolvable units — rem is fine).
     *
     * The width is DERIVED from the card width: a constant ``D`` narrower than
     * the story (see ``--rv-card-width`` above). That constant difference is
     * what makes the story : comments ratio glide as both flex. It floors at
     * the narrow-end value (card-min − D) so it never inverts, and is CAPPED at
     * --rv-comment-rail-max so the chat box stops widening once it's comfortably
     * readable while the card keeps growing toward its max. The fixed-overlay
     * rules below (and commentRailBox in pathnav-reducer.js) still shrink-to-fit
     * when the viewport can't grant the full preferred width. The max is a plain
     * px value (not a rem/calc) so pathnav-overlays.js's railVarPx can parse it — the JS
     * pins the width inline from the MEASURED card and must apply the same cap,
     * since it can't read the calc()-valued --rv-comment-rail-width. */
    --rv-comment-rail-max: 500px;
    --rv-comment-rail-width: min(
        calc(var(--rv-card-width) - var(--rv-card-comment-diff)),
        var(--rv-comment-rail-max)
    );
    /* The box grows UPWARD from the card bottom and stops this far below the
     * viewport top (commentBoxVertical's topOffset). It must clear the sticky
     * ``.site-nav`` bar (--nav-height: 3.25rem) so a very tall thread's top edge
     * never tucks under the header — 3.25rem nav + 0.5rem breathing gap. Keep it
     * a plain rem (NOT calc(var(--nav-height)…)): railVarPx parses rem but
     * getComputedStyle returns a calc() unresolved, so a calc() would NaN in JS
     * — if --nav-height changes, update this literal to match. */
    --rv-comment-rail-top: 3.75rem;
    --rv-comment-rail-z: 5;
    /* Gap between the active card's RIGHT EDGE and the panel's left edge — the
     * panel sits directly adjacent to its card (covering the right peek)
     * rather than out past the navigator column, where the sibling peek
     * showing between card and panel read as clutter. */
    --rv-comment-rail-gap: 0.5rem;
    /* Gap between the composite's RIGHT EDGE (the chat box) and the viewport's
     * right edge, once the [rail · card · chat] composite is right-anchored (see
     * the page-shell rule). Single source of truth: the container margin below,
     * the no-JS overlay fallbacks, and pathnav-reducer.js's commentRailBox margin arg
     * all read it, so the chat box lands exactly this far off the window edge. */
    --rv-right-edge-gap: 0.5rem;
    /* The slight border framing an open panel, so it reads as a bounded
     * element rather than a borderless blob when it overlaps a sibling peek.
     * Lighter than the card highlight (--rv-active-border-color) so the panel
     * stays visually secondary to the cards. */
    --rv-comment-panel-border: var(--border-card-rv);
}

/* =====================================================================
 * BASE LAYER (no media query) — the NARROW (<880px) layout + shared card
 * chrome. Mobile-first: these apply at every width; the desktop block below
 * overrides the narrow-only pieces (off-path hide, prev/next bar, bottom
 * sheet) and adds the carousel. See the section header for the two-layer
 * split. The hard rule: NO horizontal scroll here (no ``overflow-x``).
 * ===================================================================== */

/* ----- Card chrome (all widths) -----
 * Every VISIBLE card gets the same full-width highlight border in its author
 * colour — the active card is distinguished by centering/position, not a
 * thicker edge (we ditched the thick-active / thin-sibling split). Built from
 * the --rv-* props (the
 * future "choose your highlight" preference is then a value swap). This is the
 * card's LOOK, not spatial layout, so it lives in the base layer and frames the
 * cards at narrow (the single-column stack) and at desktop (spine + carousel)
 * alike. Covers the post card (the <article> IS the focused element) and a
 * focused riff card (the <article> lives inside the focused <li>).
 * All cards share a light beige fill so they stand out from the slightly
 * darker page background — this also makes the carousel peek visible as a
 * warm sliver rather than just a thin border. */
.container--post-detail .contribution > .contribution-body,
.container--post-detail .post-detail {
    padding: 0.5rem 2rem 0;
    background: var(--surface);
}
/* While a riff card is edited in place, its box takes the paler inset "writing
 * sheet" fill (--writing-sheet, defined near the compose rules) — the SAME
 * treatment the compose card wears, so editing and drafting read identically and
 * both stand apart from reading. Keyed on the ``.is-editing`` class Alpine adds to
 * the article while ``editing`` is true (works/_contribution.html). The extra
 * class out-specifies the base box fill above; the active-card rule below only
 * sets borders, so it doesn't fight this. Only riff cards edit in place, so
 * ``.post-detail`` never gets it. --sheet-inset (default 1rem = half the 2rem side
 * padding) drops to 0.5rem on narrow phones, where the card padding halves — see
 * the narrow media query below. */
.container--post-detail .contribution > .contribution-body.is-editing {
    background: var(--writing-sheet);
}
/* Narrow phones: halve the card's side padding (2rem -> 1rem) so the reading
 * column keeps more width once #main-content has gone edge-to-edge. Applies to
 * every card (post + riff share this rule) so the inset stays uniform. */
@media (max-width: 559px) {
    .container--post-detail .contribution > .contribution-body,
    .container--post-detail .post-detail {
        padding-left: 1rem;
        padding-right: 1rem;
    }
    /* The edit card's side padding just halved to 1rem, so its writing-sheet
     * inset halves too (0.5rem = the new midpoint). The compose card keeps 2rem
     * padding at all widths, so its --sheet-inset stays at the 1rem default. */
    .container--post-detail .contribution > .contribution-body.is-editing {
        --sheet-inset: 0.5rem;
    }
}
.container--post-detail .contribution[data-active] > .contribution-body,
.container--post-detail .post-detail[data-active] {
    border-top: none;
    border-bottom: none;
    border-left: var(--rv-active-border-width) var(--rv-active-border-style)
        var(--card-author-color, var(--rv-active-border-color));
    border-right: var(--rv-active-border-width) var(--rv-active-border-style)
        var(--card-author-color, var(--rv-active-border-color));
    border-radius: 0;
}
.container--post-detail .contribution:not([data-active]) > .contribution-body,
.container--post-detail .post-detail:not([data-active]) {
    border-top: none;
    border-bottom: none;
    border-left: var(--rv-active-border-width) solid var(--card-author-color, var(--rv-active-border-color));
    border-right: var(--rv-active-border-width) solid var(--card-author-color, var(--rv-active-border-color));
    border-radius: 0;
}
/* Tombstone cards: a soft-deleted post / riff that still has visible
 * descendants (see works/_contribution.html + works/_post_card.html). Same
 * frame as any other card — the two thick left/right edges, no top/bottom,
 * no radius — but a neutral gray instead of the author colour. The riff
 * selector keys off the article's own ``contribution-tombstone-body`` class
 * (not the ``<li>``'s ``contribution-tombstone-row``) so it also lands when an
 * author deletes their card live: that HTMX swap replaces only the body and
 * leaves the ``<li>`` (and its absent tombstone-row class) untouched. It
 * carries both ``.contribution-body`` and ``.contribution-tombstone-body``, so
 * it matches the per-card active border rule above on specificity and, sitting
 * after it in source order, wins in both the active and inactive states (a
 * tombstone never lights up an author colour). Italic muted text, no rail /
 * comments. */
.container--post-detail .contribution > .contribution-body.contribution-tombstone-body,
.container--post-detail .post-detail.post-tombstone[data-active],
.container--post-detail .post-detail.post-tombstone:not([data-active]) {
    border-top: none;
    border-bottom: none;
    border-left: var(--rv-active-border-width) solid var(--border);
    border-right: var(--rv-active-border-width) solid var(--border);
    border-radius: 0;
}
.contribution-tombstone-text {
    margin: 0;
    padding: 0.5rem 0 1rem;
    color: var(--text-muted);
    font-style: italic;
}
/* v1 marks a focused / roving-tabindex riff with a tinted background + a 3px
 * inset left-edge accent (reading-tree.css). Under v2 the data-active border is
 * the sole focus indicator, so strip all v1 styling (background tint,
 * box-shadow accent, outline) from both treeitems and the post card. */
.container--post-detail [role="treeitem"]:focus,
.container--post-detail [role="treeitem"][tabindex="0"] {
    background: transparent;
    box-shadow: none;
    outline: none;
}
.container--post-detail .post-detail:focus,
.container--post-detail .post-detail:focus-visible {
    outline: none;
}

/* The shared "writing sheet" fill for BOTH the compose card and the in-place
 * riff editor: the box sits on the reading surface (--surface) with the paler
 * --surface-raised sheet inset by --sheet-inset on BOTH sides — half the card's
 * side padding, i.e. the midpoint between the card's inner edge and the text —
 * running the full height. A hard-edged horizontal gradient (matching colour
 * stops = crisp step, no blend). The textarea (also --surface-raised) sits
 * seamlessly on the sheet; the strip on each side shows the reading surface,
 * setting the writing area in from the card edges. A single gradient, not an
 * overlay element, so it never joins the fixed action rail's stacking context.
 * --sheet-inset defaults to 1rem (half the 2rem side padding both cards use on
 * normal widths); the edit card overrides it to 0.5rem on narrow phones, where
 * its padding halves to 1rem (the compose card keeps 2rem at all widths). Placed
 * on the container so both consumers inherit the recipe. */
.container--post-detail {
    --writing-sheet: linear-gradient(
        to right,
        var(--surface) var(--sheet-inset, 1rem),
        var(--surface-raised) var(--sheet-inset, 1rem),
        var(--surface-raised) calc(100% - var(--sheet-inset, 1rem)),
        var(--surface) calc(100% - var(--sheet-inset, 1rem))
    );
}

/* ----- Compose modal as a card (riff-modal-out-of-body, step 2) -----
 * Step 1 lifted the compose-area out of the body <article> to be a
 * sibling card. Step 2 dresses it as a post: the same beige fill and
 * author-colour side stripes the cards above wear, the same 1rem 2rem
 * padding, no radius — so the composer reads as a post-in-progress
 * rather than a form bolted under the card. The stripe colour comes from
 * ``--card-author-color`` set (in the templates) to the *viewer's* colour:
 * the riff-to-be is theirs, so this previews how it will look once posted.
 * Chrome only paints when a form is loaded (``:not(:empty)`` — an empty
 * compose-area is display:none from step 1). */
.container--post-detail .post-detail-layout > .compose-area:not(:empty),
.container--post-detail .contribution > .compose-area:not(:empty) {
    margin-block-start: var(--rv-spine-gap);
    padding: 1rem 2rem;
    /* The paler inset "writing sheet" fill (see --writing-sheet above): --surface
     * with the --surface-raised sheet inset 1rem (--sheet-inset default = half the
     * 2rem padding) on both sides. Same treatment as the in-place editor. */
    background: var(--writing-sheet);
    border-left: var(--rv-active-border-width) solid
        var(--card-author-color, var(--rv-active-border-color));
    border-right: var(--rv-active-border-width) solid
        var(--card-author-color, var(--rv-active-border-color));
}
/* While a card's composer is open, tuck its existing children carousel away so
 * the modal reads as "appending a new sibling" with nothing underneath, rather
 * than inserting above the previously-visible child. Keyed purely on the open
 * composer (``:has(> .compose-area:not(:empty))``), so it reverses itself the
 * instant the area empties on cancel/submit. (This revisits the subtree-hide
 * dropped in 856d3e4; hiding shrinks the document, so pathnav.js's compose
 * spacer pads <body> by the hidden carousel's height to stop the scroll
 * snapping — see setComposeSpacer there.) */
.container--post-detail .post-detail-layout:has(> .compose-area:not(:empty)) > .contribution-tree,
.container--post-detail .contribution:has(> .compose-area:not(:empty)) > .contribution-children {
    display: none;
}
/* The inner form sheds its standalone white-box chrome (the #fff fill /
 * 1px border / radius from the base ``.compose-form`` rule) so the
 * surrounding compose-area card is the only frame — otherwise a white
 * panel would nest inside the beige card. Scoped to ``.container--post-detail``
 * so the JS-off standalone compose page (works/compose.html) keeps its box.
 *
 * The inline riff *editor* (``.contribution-edit-inline``, rendered in the
 * card body in place of the prose) reuses the exact same treatment so editing
 * looks identical to drafting — it just lives in the body rather than a
 * compose-area, so it's listed here directly. */
.container--post-detail .compose-area .compose-form,
.container--post-detail .contribution-edit-inline {
    background: transparent;
    border: none;
    border-radius: 0;
    padding: 0;
}
/* The writing surface sheds the white fill + border of the base
 * ``.compose-form textarea`` rule and takes ``--surface-raised`` — the slightly
 * lighter beige/gray the card box and compose-area also switch to while editing
 * or composing (see the ``.is-editing`` card rule and the compose-area fill) —
 * so the writing area reads as a natural, distinctly-lit extension of the story
 * rather than a form field dropped into the card. This textarea only shows in
 * edit/compose mode (x-show-hidden otherwise), so the lighter fill needs no
 * gate. Scoped to ``.container--post-detail`` so the JS-off standalone compose
 * page keeps its bordered white box. */
.container--post-detail .compose-area .compose-form textarea,
.container--post-detail .contribution-edit-inline textarea {
    background: var(--surface-raised);
    border: none;
    /* No padding: the textarea's text lines up flush with the card's own
     * content edge, completing the "writing directly into the story" look.
     * For the editor, that edge is the body's, so the text being edited lines
     * up with the published prose it replaces. */
    padding: 0;
    /* Auto-grow, no ceiling. The Alpine handler (template) sets inline height to
     * the content's scrollHeight, so the editor is exactly as tall as the text —
     * i.e. as tall as the rendered post it replaces, and you scroll the PAGE to
     * reach a long riff's end rather than a shorter inner window. ``min-height``
     * is the floor — ~8 text lines (1rem × 1.5 line-height), matching the no-JS
     * ``rows="8"`` box — so a near-empty box doesn't collapse to one line. (A
     * ``max-height: 80vh`` ceiling + ``overflow-y: auto`` used to cap the box and
     * scroll a long post inside a shorter window; removed so the edit view is the
     * same length as the post and doesn't jump on toggle.) ``resize: none``
     * retires the drag handle: auto-grow makes it redundant and a manual drag
     * would be overridden on the next keystroke. */
    min-height: 12rem;
    resize: none;
}
/* With the border gone, the browser's default focus outline (the blue ring)
 * is the only chrome left, and it re-frames the surface we just dissolved
 * into the card. Drop it: the textarea is autofocused the moment the editor
 * opens and the author-striped card already signals "you're writing here",
 * so the ring adds nothing. */
.container--post-detail .compose-area .compose-form textarea:focus,
.container--post-detail .compose-area .compose-form textarea:focus-visible,
.container--post-detail .contribution-edit-inline textarea:focus,
.container--post-detail .contribution-edit-inline textarea:focus-visible {
    outline: none;
}
/* Match the published prose's line-height (the page's 1.5) so toggling
 * between the rendered body and the textarea doesn't change the text's
 * height — no jump in the card. The base ``.compose-form textarea`` leaves
 * line-height at the UA default (~1.2), which is shorter. */
.container--post-detail .contribution-edit-inline textarea {
    line-height: 1.5;
    /* Match the bottom spacing to reading mode so the card's bottom edge sits the
     * same distance below the text whether reading or editing. The riff card body
     * is ``padding: 0.5rem 2rem 0`` — ZERO bottom padding — because in reading mode
     * the prose's last paragraph supplies the bottom spacing itself (the 1.5em
     * ``margin-bottom`` on ``.contribution-content p`` above). The textarea has no
     * such margin, so with 0 bottom padding the edit box would butt flush against
     * the card's bottom edge — the container jumps tighter around the text the
     * moment you edit. Give the textarea the SAME 1.5em bottom margin the last
     * paragraph has (the wrappers on both paths are padding-less, so it collapses
     * the same way), and the container-to-text relationship holds across the
     * toggle. Edit-only: the compose card has symmetric 1rem vertical padding, so
     * its textarea needs no such margin. */
    margin-bottom: 1.5em;
    /* Drop the shared 12rem min-height floor (compose keeps it — an EMPTY compose
     * box needs a comfortable size). An edit box already has content, and the
     * CARD carries its own 12rem floor (reading-v2.css ~960) that vertically
     * centres short cards. With the textarea's own floor gone it sizes to its
     * content (via GallaeAutoGrow), so a short riff's edit content stays under
     * 12rem and the card floors to 12rem — the SAME height as reading, with the
     * content centred the same way, instead of the textarea's 12rem + chrome
     * busting past the card floor and growing it. (A card taller than 12rem grows
     * naturally in both modes, as before.) */
    min-height: 0;
}
/* Markdown separates paragraphs with a blank line, which in the editor's
 * textarea is exactly one line-height (1.5em) of space. Render the published
 * prose's between-paragraph gap to that same one-line-height so a
 * multi-paragraph riff is the same height rendered or being edited — no jump
 * on toggle. (The base ``p`` rule uses 1em, which is shorter than the 1.5
 * line-height.) Reading-surface prose only — ``.post-body`` /
 * ``.contribution-content`` don't appear in the feed/profile previews — and
 * the root post + riffs stay consistent (one card type). ``em`` keeps it equal
 * to one line-height at the comment font-size too. */
.container--post-detail .post-body p,
.container--post-detail .contribution-content p {
    margin-bottom: 1.5em;
}

/* ----- Narrow single-column stack -----
 * Below 880px there is no carousel (no horizontal scroll at any width). The
 * active root→node path is one vertical column of full-width cards, one per
 * level: off-path siblings are hidden entirely (the desktop block re-shows
 * them as carousel peeks) and reached via the "‹ N of M ›" prev/next bar. The
 * post card is always on the spine, so only off-path RIFFS match here. */
.container--post-detail .contribution:not([data-on-active-path]) {
    display: none;
}
/* Flatten the v1 thread's per-level indent (the children <ul>'s padding-left;
 * the body's left accent is already overridden by the card border above). The
 * narrow stack is full-width cards, not an indented thread — and an
 * accumulating indent would push a deep card off a 360px screen. */
.container--post-detail .contribution-children {
    padding-left: 0;
}
/* Spine gap at ALL widths — the thin strip of page background between
 * a parent card and its child carousel. Applied here (base layer) so
 * it works at narrow too; the desktop block keeps its own copy for the
 * navigator-width + re-centering overrides that sit alongside it. */
.container--post-detail .contribution-tree,
.container--post-detail .contribution-children {
    margin-block-start: var(--rv-spine-gap);
}

/* ----- Spine spacing (narrow) -----
 * The parent→child distance on the single-column stack should be ONE
 * deliberate gap, not the sum of several elements' default spacing. The cards
 * have no bottom padding/border, so a card body's last paragraph margin
 * (1.5em) ESCAPES below the card and collapses with the comment mount's margin
 * and the child group's spine gap — stacking up to a wide, accidental gulf.
 * Zero those contributors and re-pad with a single knob so the gap is tunable
 * in one place (and matches the sketch's tighter rhythm). */
@media (max-width: 879px) {
    .container--post-detail {
        --rv-spine-gap-narrow: 0.5rem;
    }
    /* Contain the body's bottom content margin so it doesn't push the next card
     * away (post .post-body + riff .contribution-content both run 1.5em paras). */
    .container--post-detail .contribution-content > :last-child,
    .container--post-detail .post-body > :last-child {
        margin-bottom: 0;
    }
    /* The collapsed comment mount earns no visual gap here (a closed panel is
     * 0-height); fold its 0.25rem margins into the single spine gap below. */
    .container--post-detail .contribution-meta-mount {
        margin: 0;
    }
    /* The one deliberate parent→child gap: post→first riff and riff→child alike. */
    .container--post-detail .contribution-tree,
    .container--post-detail .contribution-children {
        margin-block-start: var(--rv-spine-gap-narrow);
    }
}

/* ----- Sibling swipe (narrow, touch) -----
 * On touch widths pathnav.js lets a left/right swipe on any card with siblings
 * switch that card's level. The gesture translates card bodies (transform only —
 * no scroll container), so:
 *   - the card body carries a transform transition for the slide-out / slide-in
 *     and snap-back (pathnav.js clears it to ``none`` to track the finger 1:1);
 *   - the incoming neighbour is normally display:none here; during a swipe
 *     pathnav.js marks it ``data-rv-swipe-peek`` to REVEAL it as an absolute
 *     overlay on the active card's slot (top/width set inline) so it slides in
 *     live instead of a blank gap. The carousel <ul> is position:relative so the
 *     overlay anchors to it;
 *   - the container CLIPS the off-screen travel so a card translated past the
 *     viewport edge can't grow a horizontal scrollbar. ``overflow: clip`` is a
 *     clip box, NOT a scroll box, so the "no horizontal scroll at any width"
 *     rule still holds (and it's the shorthand, not ``overflow-x``, which is
 *     reserved for the desktop carousel). overflow-y stays effectively visible —
 *     the <main> sizes to its content, so nothing is clipped vertically, and the
 *     mobile comment sheet is position:fixed (escapes the clip).
 * Scoped to narrow so the desktop carousel's peek/overflow is untouched. */
@media (max-width: 879px) {
    .container--post-detail {
        overflow: clip;
    }
    .container--post-detail .contribution-tree,
    .container--post-detail .contribution-children {
        position: relative; /* anchor the swipe-peek overlay */
    }
    .container--post-detail .contribution > .contribution-body {
        transition: transform 0.2s ease;
    }
    /* The revealed incoming neighbour during a swipe: un-hide it (it is otherwise
     * an off-path display:none sibling) and overlay it on the active card's slot.
     * Later in the cascade than the off-path hide rule above, and equal in
     * specificity, so it wins. top/width are set inline by pathnav.js. */
    .container--post-detail .contribution[data-rv-swipe-peek] {
        display: block;
        position: absolute;
        left: 0;
    }
    @media (prefers-reduced-motion: reduce) {
        .container--post-detail .contribution > .contribution-body {
            transition: none;
        }
    }
}

/* ----- Sibling-position indicator (narrow) -----
 * A decorative segmented bar at the top and bottom of a card that has siblings,
 * showing where it sits in the sibling carousel (the narrow replacement for the
 * desktop peeks). ``--sib-size`` segments, the ``--sib-pos``-th lit in the
 * author colour, the rest grey with gaps. The two bars (.sibling-position--top
 * / --bottom, rendered in works/_contribution.html when setsize > 1) are
 * absolutely positioned at the card's true top/bottom edges — on mobile the
 * byline sits at the card top and the actions at the bottom, so DOM order can't
 * place them by flow. Pure CSS: no overflow, so the no-horizontal-scroll rule
 * holds, and they ride the card's swipe transform as body children. */
@media (max-width: 879px) {
    /* Only cards that actually carry the bars get the anchor + the top/bottom
     * room to host them — single-sibling cards stay as tight as before. */
    .container--post-detail .contribution > .contribution-body:has(.sibling-position) {
        position: relative;
        padding-top: 1.9rem;
        padding-bottom: 1.4rem;
    }
    /* Inset both ends from the card's side borders so the top + bottom bars and
     * the side borders read as a framed box. The repeating gradient leaves a
     * --sib-gap trailing gap after the LAST segment but none before the first,
     * so a flush bar touches the left border yet sits --sib-gap shy of the right.
     * Land both visible tips the same 1.2rem from the border by reducing the
     * right inset by that trailing gap. */
    .container--post-detail .sibling-position {
        position: absolute;
        left: 1.2rem;
        right: calc(1.2rem - var(--sib-gap));
        height: 3px;
        pointer-events: none;
        --sib-gap: 0.4rem; /* gap between segments */
        background:
            /* The lit segment: author colour across the ``--sib-pos``-th cell only.
             * A ``0`` length after a colour clamps to the previous stop → hard edge. */
            linear-gradient(
                to right,
                transparent calc((var(--sib-pos) - 1) * 100% / var(--sib-size)),
                var(--card-author-color, var(--rv-active-border-color)) 0,
                var(--card-author-color, var(--rv-active-border-color))
                    calc(var(--sib-pos) * 100% / var(--sib-size) - var(--sib-gap)),
                transparent 0
            ),
            /* The grey track: one dash per sibling (period = 100% / --sib-size),
             * each ``--sib-gap`` short of its cell so a transparent gap shows the
             * card background through. The lit layer above covers its own cell. */
            repeating-linear-gradient(
                to right,
                var(--border) 0,
                var(--border) calc(100% / var(--sib-size) - var(--sib-gap)),
                transparent 0,
                transparent calc(100% / var(--sib-size))
            );
    }
    .container--post-detail .sibling-position--top {
        top: 0.3rem;
    }
    .container--post-detail .sibling-position--bottom {
        bottom: 0.3rem;
    }
}

/* ----- "‹ › " sibling prev/next bar -----
 * The no-JS sibling switcher at every width: per-card arrows that are ?focus=
 * anchors (works/_contribution.html renders the bar on every multi-sibling
 * card). With JS it is hidden (the hide rule below) — the desktop carousel and
 * the narrow swipe gesture take over — so it shows ONLY when JS is off, which is
 * the one case those JS affordances can't serve. */
.container--post-detail .contribution-sibling-nav {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    margin: 0 0 0.5rem;
}
.container--post-detail .contribution-sibling-step {
    /* A generous touch target — the sibling control at narrow (touch) widths,
     * and the no-JS sibling switcher at desktop (RV.7.1). The live arrow is a
     * ?focus= <a> and the boundary is an inert <span>; inline-flex centres the
     * glyph in the box for both, and text-decoration:none drops the <a>'s
     * underline so it reads as a button, not a text link. */
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 2.5rem;
    min-height: 2.25rem;
    padding: 0.25rem 0.6rem;
    border: 1px solid var(--rv-comment-panel-border);
    border-radius: 6px;
    background: var(--surface-raised);
    color: var(--rv-active-border-color);
    font-size: 1.15rem;
    line-height: 1;
    text-decoration: none;
    cursor: pointer;
}
/* The boundary arrow (the inert <span> at the first / last sibling — the hard
 * stop) reads as disabled so "no further sibling that way" is visible,
 * mirroring the carousel's missing peek. Keyed on aria-disabled (the <span>
 * carries no href / data-sibling-step, so it is genuinely inert). */
.container--post-detail .contribution-sibling-step[aria-disabled="true"] {
    color: var(--text-faintest);
    border-color: var(--border-disabled);
    cursor: default;
}
/* Hide the bar whenever pathnav is driving (data-pathnav-ready), at ALL widths:
 * the desktop carousel and the narrow left/right swipe gesture replace it. With
 * JS off the marker is absent and the bar shows as the sole sibling switcher. */
.container--post-detail[data-pathnav-ready] .contribution-sibling-nav {
    display: none;
}

/* ----- Comments: bottom sheet (narrow) -----
 * Desktop pins an open panel beside its card (the :has rule in the ≥880 block);
 * at narrow there's no room for a side rail, so an open panel is a bottom sheet
 * sliding up over the page. The panel keeps its native "Comments ▶/▼" toggle
 * (shown <880px; the desktop 💬 disclosure is hidden) as the open/close control
 * — when open, the whole panel (header + body) becomes the sheet, so its ▼
 * collapses it. Keyed off the same toggle ``aria-expanded`` as desktop.
 *
 * Put on the inner .meta-panel, NOT the .contribution-meta-mount: the mount
 * stays position:static at narrow, so pathnav-overlays.js's pinCommentPanels applier — which
 * sets the MOUNT's inline ``bottom`` / ``max-height`` for the desktop rail — is
 * inert here (clears them on a static box). Background + padding come from the v1
 * .meta-panel rule (the desktop neutralise that strips them is ≥880-only). */
.container--post-detail .meta-panel:has(.meta-panel-toggle[aria-expanded="true"]) {
    position: fixed;
    inset: auto 0 0 0;
    margin: 0;
    /* Cap the height so the sheet's TOP edge always stays ~2rem clear of the
     * sticky nav rather than growing up under/over it. ``dvh`` (not ``vh``)
     * so the mobile browser's dynamic chrome is accounted for. */
    max-height: calc(100dvh - var(--nav-height) - 2rem);
    /* Flex column with a pinned, non-scrolling header (the drag handle) over a
     * scrolling body — the body is the one scroll region (rule below), so a
     * header drag never fights comment-list scrolling. */
    display: flex;
    flex-direction: column;
    overflow: hidden;
    z-index: var(--rv-comment-rail-z);
    border-top: 1px solid var(--rv-comment-panel-border);
    border-radius: 12px 12px 0 0;
    box-shadow: 0 -4px 16px var(--shadow-lg);
}

/* Narrow bottom-sheet dismiss UX (drag handle + swipe-down + tap-outside),
 * driven by static/js/comments_sheet.js. The visible ▶/▼ toggle is replaced by
 * a grab handle: the toggle stays in the DOM (sr-only) as the Alpine
 * open-state owner, the ``:has()`` CSS hook, and a keyboard/AT close control,
 * but is hidden for sighted users. The JS dismisses by translating the sheet
 * down and clicking the (hidden) toggle, so Alpine stays the single owner of
 * the open state — the same convention pathnav.js uses. */
@media (max-width: 879px) {
    /* Collapsed panel: the in-flow "Comments ▶" toggle box is retired. The
     * rail's comment-count bubble opens the panel and the drag handle /
     * swipe-down / tap-outside close it (above), so the header box is redundant.
     * Hide the header and dissolve the panel's own surface so a COLLAPSED panel
     * (aria-expanded != true) takes no visual space — no beige pill, no arrow.
     * The toggle BUTTON stays in the DOM (display:none is still .click()-able),
     * so the bubble/drag/swipe that flip Alpine's open state through it keep
     * working; the open state (:has aria-expanded=true) is the mutually-
     * exclusive case and its bottom-sheet rules are unaffected. JS-off keeps
     * .meta-panel-body visible (x-show is inert), so comments still read here —
     * just without the box. */
    .container--post-detail .meta-panel:not(:has(.meta-panel-toggle[aria-expanded="true"])) {
        background: transparent;
        padding: 0;
        margin: 0;
    }
    .container--post-detail .meta-panel:not(:has(.meta-panel-toggle[aria-expanded="true"])) .meta-panel-header {
        display: none;
    }
    /* A collapsed panel is visually empty but its mount is still an in-flow
     * block between the card body and the child carousel — and an in-flow box
     * there blocks margin-collapsing, so the body's bottom spacing AND the spine
     * gap both render, reading as a gap above and below the (zero-height) mount.
     * When pathnav is driving, take the closed mount OUT of flow entirely (like
     * the empty .compose-area) so the spacing collapses to a single spine gap.
     * Opening still works: pathnav .click()s the toggle (fine on display:none),
     * Alpine flips aria-expanded, the :not(:has) stops matching, and the mount
     * reappears as the position:fixed bottom sheet. Gated on data-pathnav-ready
     * so the JS-off path keeps its in-flow .meta-panel-body (comments readable
     * without the box, per the rule above). Same for the post's .post-meta-mount
     * (the gap above the first riff). */
    .container--post-detail[data-pathnav-ready]
        .contribution-meta-mount:not(:has(.meta-panel-toggle[aria-expanded="true"])),
    .container--post-detail[data-pathnav-ready]
        .post-meta-mount:not(:has(.meta-panel-toggle[aria-expanded="true"])) {
        display: none;
    }

    /* The header is the pinned drag zone; the grab pill is its ::before.
     * ``touch-action: none`` so a vertical drag here is the dismiss gesture,
     * not a page scroll (the body keeps native scrolling below). */
    .container--post-detail .meta-panel:has(.meta-panel-toggle[aria-expanded="true"]) .meta-panel-header {
        flex: 0 0 auto;
        /* Keep the base space-between: the "Comments" title stays left and the
         * chat-subscribe bell sits right, with the centred grab pill (::before)
         * floating between them. (The collapse toggle is sr-only here.) */
        position: relative; /* anchor the centred grab pill */
        margin: 0 0 0.4rem;
        padding: 0.45rem 0; /* a comfortable drag target around the pill */
        touch-action: none;
        cursor: grab;
    }
    /* The grab pill is centred across the header while the "Comments" title
     * keeps its place on the left of the same row. */
    .container--post-detail .meta-panel:has(.meta-panel-toggle[aria-expanded="true"]) .meta-panel-header::before {
        content: "";
        position: absolute;
        left: 50%;
        top: 50%;
        transform: translate(-50%, -50%);
        width: 5.625rem;
        height: 4px;
        border-radius: 999px;
        background: var(--border);
    }
    /* Hide the visible toggle (kept in the DOM, sr-only). Mirrors
     * ``.visually-hidden`` — can't add the class to the sibling-owned template,
     * so the properties live here. */
    .container--post-detail .meta-panel:has(.meta-panel-toggle[aria-expanded="true"]) .meta-panel-toggle {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
    }
    /* The body is the single scroll region of the open sheet. */
    .container--post-detail .meta-panel:has(.meta-panel-toggle[aria-expanded="true"]) .meta-panel-body {
        flex: 1 1 auto;
        min-height: 0;
        overflow-y: auto;
    }
    /* Follow-the-finger drag + snap-back. The JS sets inline
     * ``transform: translateY()`` during a drag and clears it on release; the
     * transition animates the snap-back / slide-out. Reduced-motion users get
     * the state change with no glide. */
    @media (prefers-reduced-motion: no-preference) {
        .container--post-detail .meta-panel:has(.meta-panel-toggle[aria-expanded="true"]) {
            transition: transform 200ms ease;
        }
    }
}

@media (min-width: 880px) {
    /* ----- Page shell: navigator column, COMPOSITE-centred -----
     * Override v1's two-column body+meta page grid. The v2 navigator is one
     * column wide enough for a card plus its peeks. Raise the container's
     * reading-width cap to fit the navigator column (the rule below adds back
     * ``.container``'s 1rem padding, so the *content* box lands at exactly the
     * navigator width — see its comment) — still a cap, so any *other*
     * <main>-level content (notably the Django messages list) stays bounded
     * rather than stretching across a wide monitor. (``max-width: none`` would
     * un-cap those too.) The navigator's ``max-width: 100%`` keeps it inside
     * the container on narrow desktops.
     *
     * Positioning: instead of centring the CARD (``margin: 0 auto``), centre
     * the whole [rail · card · comments] COMPOSITE, so the empty space beyond
     * the rail equals the empty space beyond the comment box. The comment box
     * (card − D) is wider than the rail, so the composite is right-heavy;
     * centring it means nudging the card left of page-centre by half that
     * asymmetry. We compute the card's target LEFT edge — page-centre, minus
     * the card, minus the comment box's overhang past the card, plus the rail's
     * reserve, all halved back to a left offset — then subtract --rv-card-overhang
     * to get the container's left margin (the card is centred inside the
     * container, so the container sits --rv-card-overhang left of the card).
     *
     *   target cardLeft = 50vw − card − (card − D)/2 + rail-reserve/2 + gap terms
     *
     * which simplifies (the two equal gaps cancel) to the margin below. The JS
     * pinners (pinMetaRail / pinCommentPanels) position both overlays from the
     * card's MEASURED box, so they track the shifted card with no JS change. */
    .container--post-detail.container {
        /* ``+ 2rem`` compensates for ``.container``'s 1rem padding: the box
         * is border-box, so a bare --rv-navigator-width cap would shrink the
         * *content* box by 2rem — exactly enough to swallow the sibling peek
         * (card + gap then fills the column and the next sibling clips to
         * zero). Adding the padding back makes the content box the full
         * navigator width, peek included. Still a hard cap, so flash messages
         * and other <main>-level content stay bounded. */
        max-width: calc(var(--rv-navigator-width) + 2rem);
        /* Right-anchor the [rail · card · chat] composite: push it right so the
         * chat box's right edge sits --rv-right-edge-gap off the viewport's
         * right edge; margin-left:auto eats the left slack. The right margin is
         * the distance from the CONTAINER's right edge to the viewport's — the
         * chat box (which overlays past the container's right edge) plus its
         * card gap plus the viewport-edge gap, less the card's overhang inside
         * the container. Because the JS pinners position rail + chat off the
         * card's MEASURED box, shifting the container drags both overlays right
         * with it (same mechanism the composite-centred layout relied on). */
        margin-left: auto;
        margin-right: calc(
            var(--rv-comment-rail-width) + var(--rv-comment-rail-gap)
                + var(--rv-right-edge-gap) - var(--rv-card-overhang)
        );
        /* Breathing room below the story so the last card / deepest spine
         * doesn't butt right up against the viewport bottom. Desktop only —
         * scoped to this media query. On <body> this would clash with the
         * compose spacer's inline padding-bottom (pathnav-compose.js); the container is
         * a separate element, so the two don't fight. */
        padding-block-end: 10rem;
    }
    .container--post-detail .post-detail-layout {
        display: block;
        width: var(--rv-navigator-width);
        max-width: 100%;
        margin-inline: auto;
    }
    /* The post card is the spine root: pin it to the card width and CENTRE it
     * in the navigator, so it lines up with the now-centred active riff card
     * below it (the carousel spacers centre the first/last siblings too). It
     * was previously left-aligned to match the pre-centring first card; now
     * that every active card centres, the post centres with them — which also
     * gives its comment panel the same constant gap the riff panels get.
     * Without the width pin the block-level <article> would fill the whole
     * navigator and read wider than the riffs below it. */
    .container--post-detail .post-detail-layout > .post-detail {
        width: var(--rv-card-width);
        max-width: 100%;
        margin-inline: auto;
    }
    /* Give a very short card some presence: floor every reading-UI card at
     * 12rem and vertically centre its content within that floor. Applied to
     * the post card AND riff cards alike (``posts and riffs are all posts`` —
     * the root is not special-cased); riffs are where it shows most, since a
     * titleless one-line riff is otherwise ~2-3rem of cramped sliver.
     *
     * This is NOT a special case — a flex column with ``justify-content:
     * center`` only has slack to distribute while the content is shorter than
     * 12rem; once a card outgrows the floor the centring degenerates to a
     * no-op and the card grows as before. (At a 12rem floor even a titled
     * post — ~8rem of natural content — gets floored up, so the extra breathing
     * room shows on most short cards, not just the one-line riffs.)
     * The fixed metadata rail is out of flow, so it's unaffected. Desktop
     * only — the narrow single-column stack keeps its natural heights. */
    .container--post-detail .post-detail-layout > .post-detail,
    .container--post-detail .contribution > .contribution-body {
        min-height: 12rem;
        display: flex;
        flex-direction: column;
        justify-content: center;
    }
    /* The post's compose card mirrors the post card: pinned to the card
     * width and centred in the navigator, so the composer lines up under
     * the post (and reads at the same width as the riffs it joins) rather
     * than stretching the full navigator width as a block child would.
     * The riff compose-area needs no width rule — its parent ``.contribution``
     * is already a card-width flex item, so the block child inherits that. */
    .container--post-detail .post-detail-layout > .compose-area {
        width: var(--rv-card-width);
        max-width: 100%;
        margin-inline: auto;
    }

    /* ----- Card metadata rail (post-metadata-ui) -----
     * The text rail to the LEFT of a card. EVERY card is positioned the same
     * way here — the post is NOT special-cased; it's just another card with a
     * rail (works/_post_metacol.html / works/_contribution_metacol.html, each
     * a child of its card's <article>).
     *
     * ``position: fixed`` + pathnav-overlays.js (pinMetaRail) glue the ACTIVE card's
     * rail to its box on every scroll: top/height track the card — so the
     * identity half sits at the card's top and, via ``justify-content:
     * space-between``, the actions half rides the card's bottom; a short card
     * packs both halves up and overflows below. left/right place the rail in
     * the left margin, half a rem off the card's left edge (the card is centred
     * in the viewport), and text right-aligns (below) to hug it.
     *
     * Why ``fixed`` and not ``absolute``: riff cards live inside the carousels'
     * clipping ``overflow-x``, which would clip an absolutely-positioned child
     * reaching into the margin; ``fixed`` escapes it. The post card isn't in a
     * carousel and *could* be absolute, but we run one path for all cards
     * rather than privilege the root.
     *
     * The values here are the no-JS fallback: a sane fixed placement in the
     * left margin, viewport-anchored top/bottom (pinMetaRail overrides
     * top/height/left/right when JS is on). */
    .container--post-detail .post-metacol {
        position: fixed;
        /* Restore the vertical stack the mobile default flipped to a row: the
         * fixed left-margin rail stacks the identity box above the action band. */
        flex-direction: column;
        top: 5rem;
        bottom: 2rem;
        left: 1rem;
        /* No-JS fallback only — pinMetaRail overrides left/right inline from the
         * card's measured left edge when JS is on. The composite is now
         * right-anchored: the chat box's right edge sits --rv-right-edge-gap off
         * the viewport, so the rail's right edge (half a rem left of the card's
         * left edge) is that gap + the chat box + its gap + the whole card away
         * from the viewport's right edge. */
        right: calc(
            var(--rv-right-edge-gap) + var(--rv-comment-rail-gap)
                + var(--rv-comment-rail-width) + var(--rv-card-width) + 0.5rem
        );
        justify-content: space-between;
        /* flex-end shrinks the top/bottom text blocks to their content and
         * pins them against the card edge, so their beige backing (below) hugs
         * the text rather than spanning the whole left margin. */
        align-items: flex-end;
        /* No minimum gap between the identity (top) and action (bottom) halves:
         * ``space-between`` still pushes them to the card's top and bottom on a
         * normal card, but on a short card they're free to pack all the way
         * together instead of being held 1rem apart. */
        gap: 0;
        z-index: 5;
        /* The rail spans the whole left gutter where the LEFT peeking sibling
         * shows, so a click meant for that peek would land on the rail (a fixed
         * descendant of the active card) and re-root onto the already-active card
         * — a silent no-op. Make the rail's empty area click-through so peek
         * clicks reach the sibling underneath; the interactive blocks below
         * re-enable pointer events on themselves. */
        pointer-events: none;
    }
    /* Re-enable pointer events on the rail's real surfaces: the opaque identity
     * box (top), every control (.post-metacol-item — permadate, edit, delete,
     * subscribe, like, cowrite + the history toggle/versions), the author link,
     * and the open version popup. The bare action-band gaps and the empty middle
     * of the rail stay click-through (the bulk of the left-peek strip). */
    .container--post-detail .post-metacol .post-metacol-top,
    .container--post-detail .post-metacol .post-metacol-item,
    .container--post-detail .post-metacol .post-metacol-author,
    .container--post-detail .post-metacol .post-metacol-history-versions {
        pointer-events: auto;
    }

    /* The identity (top) block gets a solid backing in the card-surface colour
     * (--surface), so it reads as the same material as the card it hugs. The
     * action band (bottom) is deliberately bare: just icons, no box, no padding
     * (see below), so it doesn't get this backing. */
    .container--post-detail .post-metacol .post-metacol-top {
        background: var(--surface);
        border-radius: 6px;
        padding: 0.2rem;
        /* Nudge the box up (−0.3rem) and right (−0.2rem); the right shift
         * mirrors the action band's flush margin below, so both rail blocks
         * butt against the card's left edge. */
        margin: -0.3rem -0.2rem 0 0;
    }
    /* The action band is bare icons with a small margin. The right margin is
     * negative (−0.2rem) so the icons slide off the card's left edge and centre
     * in the ``--rv-sibling-gap`` (1.8rem) channel between the active card and
     * the neighbour peeking on its left — their centre lands at ~cardLeft−0.86rem,
     * against the gap midpoint cardLeft−0.9rem. Applied to EVERY card, not just
     * ones with a visible sibling: keeping a single fixed offset means the band
     * never jumps sideways as you navigate between cards that do and don't have
     * siblings. On a lone riff or the root post the icons simply float in the
     * (empty) left margin at that same offset — uniform by design, no
     * is-root/sibling special-casing. */
    .container--post-detail .post-metacol .post-metacol-bottom {
        margin: 0.2rem -0.2rem 0.2rem 0.2rem;
        /* Undo the mobile horizontal icon row: the desktop rail stacks the
         * action icons in a vertical column down the left gutter. */
        flex-direction: column;
        flex-wrap: nowrap;
        gap: 0.3rem;
    }
    /* The composer's rail (post-metacol--compose, in _compose_form.html) has no
     * identity row — just the action band — so bottom-anchor that lone band with
     * an AUTO top margin rather than justify-content, so the compose actions ride
     * the compose card's bottom edge like the metacol-bottom action band on every
     * reading / edit card. Why margin-top:auto and not justify-content:flex-end:
     * the pinned box's top is clamped to the card top (metaRailBox: max(cardTop,
     * navClearance)), but as the card scrolls out the box shrinks below the band's
     * height. flex-end would then overflow the band UPWARD, above the box top —
     * i.e. above the card's top. An auto margin instead collapses to 0 when
     * there's no free space, leaving the band at the box top (never above the
     * card), and only pushes it down to ride the bottom when the box has room. */
    .container--post-detail .post-metacol--compose .post-metacol-bottom {
        margin-top: auto;
    }

    /* Right-align every rail's text (post + riff) so it butts against the
     * card's left edge, regardless of how wide the left margin is. */
    .container--post-detail .post-metacol .post-metacol-top,
    .container--post-detail .post-metacol .post-metacol-bottom,
    .container--post-detail .post-metacol .post-metacol-history,
    .container--post-detail .post-metacol .post-metacol-history-versions {
        align-items: flex-end;
    }
    .container--post-detail .post-metacol .post-metacol-item {
        text-align: right;
    }
    /* The version list indents from the rail's right edge (toward the card),
     * flipping the mobile left-indent. */
    .container--post-detail .post-metacol .post-metacol-history-versions {
        padding-left: 0;
        padding-right: 0.75rem;
    }

    /* ----- Carousels: every sibling group is a horizontal track -----
     * Override the v1 subgrid (display: grid + grid-template-columns:
     * subgrid) with a flex + scroll-snap carousel. The top-level
     * ``.contribution-tree`` fills the navigator; nested
     * ``.contribution-children`` reclaim the navigator width below their
     * (active) parent so deeper levels get the same peek room — see the
     * width override under "Vertical spine" below. */
    .container--post-detail .contribution-tree,
    .container--post-detail .contribution-children {
        display: flex;
        flex-direction: row;
        /* Top-align so peeking siblings show their top edge level with the
         * active card; the active card grows taller (it carries the spine
         * below it) without dragging the siblings down with it. */
        align-items: flex-start;
        gap: var(--rv-sibling-gap);
        padding: 0;
        /* Horizontal scroll-snap so a drag / Phase-3 scrollIntoView settles
         * a sibling into place. overflow-x is allowed at desktop widths —
         * the "no horizontal scroll at any width" rule is the *narrow*
         * layout's hard rule (Phase 6). */
        overflow-x: auto;
        scroll-snap-type: x mandatory;
        /* Hard stop, no wrap: the track never loops, and the missing peek
         * at the first / last card is what signals the boundary. */
        flex-wrap: nowrap;
        /* Hide the scrollbar entirely — sibling switching is a keyboard /
         * click affordance (Phase 3), not a visible bar. The track stays
         * scrollable (overflow-x: auto) for trackpad swipes and Phase 3's
         * programmatic scrollIntoView; only the visible bar is suppressed. */
        scrollbar-width: none;
    }
    /* WebKit/Blink counterpart to ``scrollbar-width: none``. */
    .container--post-detail .contribution-tree::-webkit-scrollbar,
    .container--post-detail .contribution-children::-webkit-scrollbar {
        display: none;
    }
    /* While a sibling-switch GLIDE is animating scrollLeft (A/D or a click on a
     * peek — pathnav-motion.js glideCarouselTo), turn scroll-snap OFF: ``mandatory``
     * snap fights per-frame scrollLeft writes (it re-snaps between frames,
     * stuttering the tween). The glide's final frame lands exactly on the
     * ``scroll-snap-align: center`` position, so re-enabling snap when the class
     * is removed causes no jump. Reader swipes (.rv-carousel-sliding WITHOUT this
     * class) keep snap, since the gesture relies on it to settle. */
    .container--post-detail .contribution-tree.rv-carousel-gliding,
    .container--post-detail .contribution-children.rv-carousel-gliding {
        scroll-snap-type: none;
    }
    /* A focused draft textarea (the in-place riff / comment composer) lives
     * inside this carousel. ``scroll-snap-type: mandatory`` fights a mouse
     * drag-selection: when the drag nudges the overflow-x:auto track even
     * slightly, snap yanks it back to the centred snap point between pointer
     * moves, sliding the textarea under the cursor and collapsing the
     * selection. While any descendant textarea holds focus, drop snap so the
     * selection drags freely. The active card is already centred, so removing
     * (and later restoring, on blur) snap causes no jump — the same reasoning
     * the ``.rv-carousel-gliding`` rule above relies on. */
    .container--post-detail .contribution-tree:has(textarea:focus),
    .container--post-detail .contribution-children:has(textarea:focus) {
        scroll-snap-type: none;
    }
    /* Leading + trailing spacers so the FIRST and LAST cards can scroll to
     * centre like the middle ones. Without them the track starts at the first
     * card, so scroll position clamps at 0 and the first card sits flush-left
     * (and the last flush-right) — only middle siblings could centre. That
     * left the active card's horizontal position inconsistent (it depended on
     * which sibling), so a comment panel pinned at a fixed offset showed a
     * varying gap. The spacer (rather than a real card) is what shows at the
     * boundary, so the end still reads as "no further sibling". Pseudo-element
     * spacers — not container padding — to dodge the flex scroll-container
     * end-padding quirk.
     *
     * Sizing — and why the ``- gap``: the room a centred end card needs on its
     * missing-peek side is half the leftover track, ``(navigator - card) / 2``.
     * But this flex track also carries ``gap: --rv-sibling-gap``, and the gap
     * sits BETWEEN the spacer and its adjacent card just as it does between two
     * cards (the ::before/::after ARE flex items). So a bare half-leftover
     * spacer overshoots by exactly one gap: a lone-child track then measures
     * ``navigator + 2*gap`` and OVERFLOWS, resting centred only if scroll-snap
     * happens to pull it there. Snap does that on a fresh top-level load, but
     * the carousels BELOW the active card aren't scrolled by pathnav's
     * scrollIntoView, and when snap doesn't correct them the lone child — and
     * every deeper one, cumulatively — drifted right by ~gap per level (the
     * "deepest card in the chain is indented" bug). Subtracting one gap makes
     * spacer + its adjacent gap sum back to ``(navigator - card) / 2``, so a
     * single-child track is EXACTLY the navigator width: it centres
     * structurally with zero overflow, no scroll-snap required. A multi-sibling
     * track still overflows (it must, to reach the off-centre siblings), and
     * its first/last cards still land centred at the scroll extremes because
     * spacer + gap is again the half-leftover there. */
    .container--post-detail .contribution-tree::before,
    .container--post-detail .contribution-tree::after,
    .container--post-detail .contribution-children::before,
    .container--post-detail .contribution-children::after {
        content: "";
        flex: 0 0 calc(
            (var(--rv-navigator-width) - var(--rv-card-width)) / 2 - var(--rv-sibling-gap)
        );
    }

    /* Each card is a fixed-width snap target. ``display: block`` undoes
     * v1's per-row subgrid so the card's own contents (identity band,
     * body, footer, comment mount, child carousel) stack normally. */
    .container--post-detail .contribution {
        display: block;
        flex: 0 0 var(--rv-card-width);
        max-width: var(--rv-card-width);
        scroll-snap-align: center;
        padding: 0;
    }
    /* The base layer hides off-path siblings for the narrow single-column
     * stack; desktop re-shows them as the carousel's peeking neighbours — but
     * ONLY for the two rows that fan out: the SELECTED card's own sibling row,
     * and the children row directly beneath it. Every other level on the spine
     * (ancestors above, and the first-child chain that continues below the
     * child row) collapses to its single on-path card — a non-selected card
     * never shows its siblings. CSS has no parent/preceding-sibling combinator,
     * so each case needs its own selector:
     *   1. the selected card's LATER siblings (after [data-active] in its row);
     *   2. its EARLIER siblings (:has a later [data-active] sibling);
     *   3. its CHILDREN row (off-path cards directly under [data-active]);
     *   4. the POST root's children row — the top-level .contribution-tree is a
     *      sibling of the post <article>, not nested in it, so when the post is
     *      the selected card its riffs fan out via this separate selector.
     * Each is more specific than the base :not() hide, so unmatched off-path
     * siblings stay hidden. */
    .container--post-detail .contribution[data-active] ~ .contribution:not([data-on-active-path]),
    .container--post-detail .contribution:not([data-on-active-path]):has(~ .contribution[data-active]),
    .container--post-detail .contribution[data-active] > .contribution-children > .contribution:not([data-on-active-path]),
    .container--post-detail .post-detail[data-active] ~ .contribution-tree > .contribution:not([data-on-active-path]) {
        display: block;
    }

    /* ----- Peek fade -----
     * A peeking neighbour's content fades toward its clipped edge instead of
     * ending in a hard cutoff: opaque at the edge nearest the centred card,
     * trailing to transparent right at the clip. The visible sliver is one
     * peek wide (--rv-sibling-peek-visible), so the mask gradient spans that;
     * past it the card is off-screen anyway. The card background is --surface
     * — the same colour the page body paints — so masking the body to
     * transparent reveals an identical colour: only the text + the side border
     * visibly fade, with no seam. Only the two NON-centred neighbours are
     * masked; the centred on-active-path card (which alone owns the fixed
     * metadata rail + comment box) is never masked, so those overlays render
     * whole.
     *
     * Direction depends on which side the neighbour peeks from, and CSS has no
     * preceding-sibling combinator, so the two sides need different selectors:
     *   - a PREVIOUS sibling peeks from the LEFT (its right edge faces the
     *     centre) → fade leftward. :has(~ …) picks "cards before the centred
     *     one".
     *   - a NEXT sibling peeks from the RIGHT → fade rightward. The general
     *     sibling combinator picks "cards after the centred one".
     * Both -webkit- and unprefixed mask-image for Safari.
     *
     * These per-card masks are the RESTING state. They travel with their card,
     * so during a swipe a card sliding toward the centre stays masked until the
     * selection commits (settle) — it would surface its text annoyingly late.
     * So while a carousel is being swiped (.rv-carousel-sliding, set by
     * pathnav.js) the per-card masks switch OFF here and a stationary edge mask
     * (below) takes over, which the cards slide out from. */
    .container--post-detail
        :is(.contribution-tree, .contribution-children):not(.rv-carousel-sliding)
        > .contribution:has(~ .contribution[data-on-active-path])
        > .contribution-body {
        -webkit-mask-image: linear-gradient(
            to left,
            #000 var(--rv-sibling-peek-solid),
            transparent var(--rv-sibling-peek-visible)
        );
        mask-image: linear-gradient(
            to left,
            #000 var(--rv-sibling-peek-solid),
            transparent var(--rv-sibling-peek-visible)
        );
    }
    .container--post-detail
        :is(.contribution-tree, .contribution-children):not(.rv-carousel-sliding)
        > .contribution[data-on-active-path]
        ~ .contribution
        > .contribution-body {
        -webkit-mask-image: linear-gradient(
            to right,
            #000 var(--rv-sibling-peek-solid),
            transparent var(--rv-sibling-peek-visible)
        );
        mask-image: linear-gradient(
            to right,
            #000 var(--rv-sibling-peek-solid),
            transparent var(--rv-sibling-peek-visible)
        );
    }

    /* ----- Peeking-sibling height cap -----
     * A sibling much taller than its row's centred card would dangle below the
     * centred card and overlap the row directly beneath it (the children
     * carousel is widened + page-centred, so it sits under the side peeks). So
     * pathnav.js clips an over-tall peeking sibling's body to the centred card's
     * height (an inline ``max-height``) and stamps ``.rv-peek-capped``; this
     * hides the overflow and fades the last --rv-peek-cap-fade of it out, fully
     * transparent right at the clip — level with the centred card's bottom. The
     * fade paints the card background (--surface, the page colour) so the text
     * dissolves into the page with no hard seam; it's an ::after overlay rather
     * than a mask layer so it composes cleanly with the left/right peek mask
     * already on the body.
     *
     * Scoped to off-path cards only: the clip + fade is a PEEK affordance, never
     * applied to a spine card. pathnav.js already only stamps .rv-peek-capped on
     * off-path siblings, but pinning the visual to :not([data-on-active-path])
     * makes "a spine card never fades" hold at the render layer too — see the
     * invariant rule below. */
    .container--post-detail .contribution:not([data-on-active-path]) > .contribution-body.rv-peek-capped {
        overflow: hidden;
        position: relative;
        /* The card box is ``flex-direction: column; justify-content: center`` (it
         * vertically centres short cards within the 12rem min-height floor). On a
         * capped peek the content is TALLER than the clip, so centring would push
         * its top above the clip and reveal the card's MIDDLE. Pin it to the top
         * so the clip shows the card's top (then fades out at the bottom). */
        justify-content: flex-start;
    }
    .container--post-detail .contribution:not([data-on-active-path]) > .contribution-body.rv-peek-capped::after {
        content: "";
        position: absolute;
        inset-inline: 0;
        bottom: 0;
        height: var(--rv-peek-cap-fade);
        background: linear-gradient(to bottom, transparent, var(--surface));
        pointer-events: none;
    }

    /* ----- INVARIANT: a spine card never fades -----
     * The card the reader is reading — EVERY card on the active path, the
     * selected card and the child directly below it in the spine especially —
     * always renders WHOLE. Only the off-path siblings peeking around it may
     * fade (the per-card peek mask) or clip (the height cap). Holding this at
     * the render layer, with !important, means no peek-mask selector edge case
     * and no stray inline max-height can ever fade or clip a spine card: those
     * are the only two ways a card can fade, and both are neutralised here for
     * anything on the path. The off-path peeks are unaffected (they don't match
     * [data-on-active-path]). */
    .container--post-detail .contribution[data-on-active-path] > .contribution-body {
        -webkit-mask-image: none !important;
        mask-image: none !important;
        max-height: none !important;
    }
    /* ----- Peek fade WHILE SLIDING (stationary, viewport-anchored) -----
     * During a reader swipe the fade is anchored to the carousel's own box (its
     * left/right edges) instead of to each card, so the gradient stays put and
     * the cards slide UNDER it: a sibling centring itself surfaces its text the
     * moment it crosses out of the edge band, rather than carrying its own mask
     * to the centre and only clearing on settle. The carousel box is exactly
     * --rv-navigator-width, so the opaque middle (between the two
     * --rv-sibling-peek-fade edges) is the centred card, full-strength; the
     * neighbours sit in the fading edge bands.
     *
     * This masks the WHOLE carousel subtree, including the active card's fixed
     * rail + comment box when the active card is in this carousel — but that is
     * exactly the case where pathnav.js has faded those overlays out for the
     * swipe (.rv-carousel-scrolling), so masking the already-invisible overlays
     * is moot. A descendant-carousel swipe (active card elsewhere) never
     * contains those overlays. So no visible overlay is ever wrongly masked. */
    .container--post-detail
        :is(.contribution-tree, .contribution-children).rv-carousel-sliding {
        -webkit-mask-image: linear-gradient(
            to right,
            transparent 0,
            #000 var(--rv-sibling-peek-fade),
            #000 calc(100% - var(--rv-sibling-peek-fade)),
            transparent 100%
        );
        mask-image: linear-gradient(
            to right,
            transparent 0,
            #000 var(--rv-sibling-peek-fade),
            #000 calc(100% - var(--rv-sibling-peek-fade)),
            transparent 100%
        );
    }

    /* ----- Vertical spine -----
     * Every card ON THE ACTIVE PATH shows its children (the next carousel
     * down); cards off the path hide their subtree. That is what collapses
     * the branching tree into a single root→node spine. Phase 3 split spine
     * *membership* (``data-on-active-path``, the whole root→leaf chain,
     * stamped here server-side and by pathnav.js) from the single *focused*
     * card (``data-active``, the highlight — see "Active highlight" below):
     * the structure must follow the whole path, not just the focused card,
     * or descending into a child would hide its ancestors' carousels. */
    .container--post-detail .contribution:not([data-on-active-path]) > .contribution-children {
        display: none;
    }
    /* Parent→child gap, and reclaim the navigator width for the nested
     * carousel so grandchildren peek like the top level does (the nested
     * <ul> is otherwise trapped at its card-width parent's size). It
     * left-aligns under its parent. pathnav.js (Phase 3) centers the focused
     * card; Phase 4 adds scroll-driven re-centering. */
    .container--post-detail .contribution[data-on-active-path] > .contribution-children {
        width: var(--rv-navigator-width);
        margin-block-start: var(--rv-spine-gap);
        /* Re-centre the nested carousel on the PAGE. It's wider than its
         * card-width parent and would otherwise left-align under it, drifting
         * ~1.6rem right of page-centre and compounding per depth level — which
         * left a nested active card off-centre, so the page-centre-relative
         * comment panel overlapped it. The negative start margin (−half the
         * leftover) pulls it back so it fills the navigator column like the
         * top-level tree, and every active card — at any depth — centres on
         * the page, giving the comment panel a constant gap. */
        margin-inline-start: calc((var(--rv-card-width) - var(--rv-navigator-width)) / 2);
    }
    /* The compose modal is a *prospective sibling* of the responded-to card's
     * existing children (the new riff joins them), so we deliberately do NOT
     * hide that subtree while composing — the modal shows as its own card above
     * the still-visible children. RV.3.1 used to hide them
     * (``.contribution[data-composing] > .contribution-children`` /
     * ``.post-detail[data-composing] ~ .contribution-tree`` → display:none) so
     * the composer wasn't "competing with a carousel", but that *shrank the
     * document* every time, and on a card scrolled into its subtree the browser
     * clamped the scroll and snapped the parent's bottom to the page bottom —
     * the opposite of "the parent stays put". Keeping the subtree visible costs
     * nothing structurally (the doc only grows, below the card) and is the
     * honest picture: you're adding a sibling, here are the existing ones. */

    /* ----- Active highlight (moved) -----
     * The full-width card border (same on active and non-focused cards) and
     * the suppression of v1's focus accent are CARD CHROME — they moved up to
     * the base layer ("Card chrome (all widths)") so they frame the cards at
     * narrow too. Only the desktop-specific spine note stays here. */
    /* Parent pin — STILL DEFERRED (needs JS); NO card is pinned. The intent
     * (design doc, "Vertical spine") is that the parent's BOTTOM edge sticks to
     * the viewport top, its last 2–3 lines readable, as you scroll into the
     * active card. Plain `position: sticky; top: 0` can't express that — it
     * pins an element's TOP, so a post taller than the viewport (bodies grow to
     * fit; a long one runs ~18k chars) sticks the instant you scroll and
     * freezes, covering the whole viewport while the riff tree scrolls
     * invisibly behind it. A variable-height bottom-sliver pin needs a measured
     * offset (top: -(height - sliver)) — JS not yet built; until then the spine
     * just scrolls cleanly, which keeps every card reachable. */

    /* (The "‹ › " bar's data-pathnav-ready hide now lives in the base layer —
     * it applies at every width, so the narrow swipe gesture and the desktop
     * carousel both replace it; only the no-JS render still shows it.) */

    /* ----- Comments per card -----
     * Each card on the spine owns its comment panel (works/_comments_panel
     * .html, mounted in a .contribution-meta-mount on each riff card and a
     * .post-meta-mount beside the post card). At desktop the ACTIVE card's
     * panel is an always-on side rail beside the card (the mount rule below) —
     * the right-hand twin of the left metadata rail. See "Per-card
     * bounded-sticky comments" in tasks/READING_V2_TASKS.md.
     *
     * The always-on box keeps its header: the "Comments on <treepos>" heading
     * labels which card's thread this is (left), and the chat-subscribe bell
     * sits at the right — the base .meta-panel-header's space-between flow does
     * both, so no desktop display override is needed. Only the collapse toggle
     * is dropped: the desktop box is always open, so there's nothing to expand.
     * (At narrow the header stays in full: it labels the bottom sheet and
     * carries that toggle to open it.) */
    .container--post-detail .meta-panel-toggle {
        display: none;
    }

    /* The rail's comment bubble (works/_metacol_comment.html) exists to pull the
     * panel UP — redundant at desktop, where that panel is the always-on side
     * rail above. So hide it here: for now it's a narrow-layout-only affordance
     * (it may grow desktop functionality later). */
    .container--post-detail .post-metacol-icon--comment {
        display: none;
    }

    /* Hide every card's mount by default; reveal the ACTIVE card's panel as an
     * always-on side rail — the right-hand twin of the left metadata rail,
     * which shows for the same data-active card. The box follows the focused
     * card (post or riff), so exactly one panel shows at a time. There's no
     * open/close gate at desktop: the panel is permanently expanded (the
     * .meta-panel-body force-show below overrides Alpine's collapse) and the
     * panel's own toggle is hidden (it's the narrow-layout control). The post
     * mount is a *sibling* of the .post-detail article (general-sibling
     * combinator); riff mounts are children of their .contribution <li>.
     * pathnav-overlays.js's pinCommentPanels pins the active card's panel each frame,
     * the twin of pinMetaRail. */
    .container--post-detail .contribution-meta-mount,
    .container--post-detail .post-meta-mount {
        display: none;
    }
    /* The active card's panel: a fixed ~22rem panel beside its card. `position: fixed`
     * (not `sticky`) deliberately — the sibling carousels set
     * `overflow-x: auto`, which makes `overflow-y` compute to `auto` too,
     * turning each into a scroll box that would trap a sticky descendant
     * (sticking it to the carousel, not the viewport) AND clip a panel wider
     * than the peek. `fixed` escapes both. pathnav.js sets `bottom` + `max-height`
     * per-frame (the bottom-anchored pin, via commentBoxVertical) so the box
     * hangs off the card's bottom, pins ~1rem above the viewport bottom while
     * the card runs past the fold, then rides the card's bottom up as it scrolls
     * in — visibly owned by and bounded to its card (RV.5.1's rail floated free
     * of any card, which read as a magic widget). The `bottom`/`max-height` here
     * are the pre-pin defaults (used the instant a panel opens, before the first
     * scroll / the deferred pin). No `transition` on them: the pin is per-frame
     * positional, so prefers-reduced-motion needs nothing.
     *
     * The LEFT edge sits just past the active card's RIGHT EDGE (the card is
     * centred, so 50% + card-width/2, + a small --rv-comment-rail-gap) so the
     * panel is directly ADJACENT to its card and covers the right peek —
     * rather than out at the navigator column's edge, where the sibling peek
     * showing between card and panel read as clutter. The min() clamp pulls
     * the left back to ``100% - width - gap`` on a narrower desktop so the
     * right edge stays on-screen (the panel then overlaps more of the card)
     * instead of spilling off and adding a horizontal scrollbar. The slight
     * border + bg make it a bounded element so the overlap reads cleanly. A
     * long thread scrolls *within* the panel (max-height + overflow-y), the
     * border + bg framing the fixed viewport while content scrolls behind.
     * Only the active card's panel shows (the box follows the focused card),
     * so panels never collide — switching cards swaps which one is visible.
     * All offsets are first-cut tunables, verified by eye. Every active
     * card centres on the page — the post via
     * margin-inline:auto, top-level siblings via the carousel spacers, and
     * nested siblings via the spacers + the nested carousel's negative
     * start-margin re-centre — so this fixed offset yields the same gap at
     * every depth. */
    .container--post-detail .contribution[data-active] > .contribution-meta-mount,
    .container--post-detail .post-detail[data-active] ~ .post-meta-mount {
        /* A bottom-anchored "chat window": a flex column whose BOTTOM edge hangs
         * off the card's bottom (pathnav.js sets bottom + max-height inline via
         * commentBoxVertical). It shrinks to its content (a short thread hugs the
         * card bottom) and grows upward to max-height, after which the comment
         * list scrolls INSIDE while the composer stays pinned at the bottom (see
         * the flex rules below). overflow:hidden so the rounded corners clip the
         * scrolling list — the list owns the scrollbar, not the mount. */
        display: flex;
        flex-direction: column;
        position: fixed;
        /* No-JS fallback (pathnav.js overrides bottom + max-height): anchor to
         * the viewport bottom rather than the card, since without JS we can't
         * track the card's measured bottom. */
        top: auto;
        bottom: 1rem;
        /* No-JS fallback only — pathnav-overlays.js (pinCommentPanels → commentRailBox)
         * overrides left + width inline from the card's measured right edge.
         * The composite is right-anchored, so the box's right edge sits
         * --rv-right-edge-gap off the viewport: its left edge is that gap plus
         * its own (preferred) width in from the viewport's right edge. */
        left: calc(
            100vw - var(--rv-right-edge-gap) - var(--rv-comment-rail-width)
        );
        width: var(--rv-comment-rail-width);
        max-height: calc(100vh - var(--rv-comment-rail-top) - 1rem);
        overflow: hidden;
        z-index: var(--rv-comment-rail-z);
        margin: 0;
        /* Resting "faded" look: the box melts into the page — its background is
         * the body bg (``--surface``, NOT ``--bg``: see the body rule's note —
         * the page is the lighter card beige, and ``--surface-comment-box`` is a
         * touch darker than it) and its text mutes to a mid-tone (the excerpt +
         * the "username:" colon inherit this colour; author handles blend halfway
         * to the bg below). It lifts to its full surface + contrast only when the
         * reader engages it ([data-engaged], set by pathnav.js). Only the colour
         * props transition — the geometry (top/bottom/left/width) is pinned
         * per-frame inline and must not lag. The opacity term is carried over
         * from the carousel-swipe fade rule below: this higher-specificity rule
         * would otherwise replace its ``transition`` and drop that fade. The
         * faded text is mixed ~60% of the way to the page bg (more recessed than
         * --text-muted, which only reads as a ~20% fade). */
        background: var(--surface);
        color: color-mix(in srgb, var(--text) 40%, var(--surface));
        transition: opacity 84ms ease, background-color 140ms ease, color 140ms ease;
        border-radius: 6px;
    }
    /* Engaged: the reader is interacting with the box (composing, liking, …), so
     * restore its own surface (a beige a touch darker than the page, no border)
     * and full-contrast body text. The author handles + quick-comment input get
     * their own engaged overrides below. */
    .container--post-detail .contribution[data-active] > .contribution-meta-mount[data-engaged],
    .container--post-detail .post-detail[data-active] ~ .post-meta-mount[data-engaged] {
        background: var(--surface-comment-box);
        color: var(--text);
    }
    /* Author handles: full identity colour only when engaged; faded they blend
     * ~60% of the way to the page bg (--surface, matching the body text's fade
     * above — keep the two mix ratios AND the bg colour in step). (Only the
     * visible box matters — every non-active mount is display:none — so no
     * [data-active] qualifier needed.) */
    .container--post-detail .contribution-meta-mount .author-badge-handle,
    .container--post-detail .post-meta-mount .author-badge-handle {
        color: color-mix(in srgb, var(--card-author-color, currentColor) 40%, var(--surface));
        transition: color 140ms ease;
    }
    .container--post-detail .contribution-meta-mount[data-engaged] .author-badge-handle,
    .container--post-detail .post-meta-mount[data-engaged] .author-badge-handle {
        color: var(--card-author-color, currentColor);
    }
    /* The "username:" colon follows the box's body-text colour (mid-tone faded,
     * full engaged) instead of its own hard --text, so it mutes with the row. */
    .container--post-detail .contribution-meta-mount .meta-panel-comment-text > .author-badge::after,
    .container--post-detail .post-meta-mount .meta-panel-comment-text > .author-badge::after {
        color: currentColor;
    }
    /* The quick-comment field reads as a writing surface (--surface) only when
     * engaged; faded it merges into the box so the whole panel recedes. */
    .container--post-detail .contribution-meta-mount .meta-panel-quick-comment-input,
    .container--post-detail .post-meta-mount .meta-panel-quick-comment-input {
        background: transparent;
        transition: background-color 140ms ease;
    }
    .container--post-detail .contribution-meta-mount[data-engaged] .meta-panel-quick-comment-input,
    .container--post-detail .post-meta-mount[data-engaged] .meta-panel-quick-comment-input {
        background: var(--surface);
    }
    /* Hide the side overlays during a horizontal carousel swipe.
     *
     * The metadata rail (left) and comment panel (right) are fixed overlays
     * glued to the ACTIVE card's measured box. While a carousel is mid-swipe
     * the active card slides toward its new centre, so without this the
     * overlays would track it straight across the screen. Instead pathnav.js
     * stamps ``.rv-carousel-scrolling`` on the navigator root for the duration
     * of a reader swipe (not our own programmatic recenter, which jumps) and
     * clears it once the carousel settles; we fade the overlays out meanwhile
     * and back in re-glued to the now-centred active card. Opacity is the only
     * thing animated — the pin geometry stays per-frame/inline. */
    .container--post-detail .post-metacol,
    .container--post-detail .contribution-meta-mount,
    .container--post-detail .post-meta-mount {
        transition: opacity 84ms ease;
    }
    .container--post-detail.rv-carousel-scrolling .post-metacol,
    .container--post-detail.rv-carousel-scrolling .contribution-meta-mount,
    .container--post-detail.rv-carousel-scrolling .post-meta-mount,
    /* The rail's interactive blocks are pointer-events:auto by default (so left-peek
     * clicks fall through the empty area but still hit the controls); while the
     * rail is faded out mid-swipe they must NOT catch clicks, so re-disable them. */
    .container--post-detail.rv-carousel-scrolling .post-metacol .post-metacol-top,
    .container--post-detail.rv-carousel-scrolling .post-metacol .post-metacol-item,
    .container--post-detail.rv-carousel-scrolling .post-metacol .post-metacol-author,
    .container--post-detail.rv-carousel-scrolling .post-metacol .post-metacol-history-versions {
        opacity: 0;
        pointer-events: none;
    }
    /* The panel is permanently expanded at desktop: override Alpine's
     * ``x-show="open"`` (which sets the body to inline ``display:none`` while
     * the toggle reads collapsed) so the comment list + quick-comment input
     * always show. An ``!important`` stylesheet rule beats x-show's inline
     * non-important declaration. Desktop-only — the base (narrow) layer keeps
     * the collapse-by-default bottom-sheet behaviour untouched.
     *
     * It's also the flex column that lets the comment list scroll while the
     * composer stays pinned: it fills the .meta-panel (flex:1) and stacks the
     * list (scrollable) above the quick-comment form. min-height:0 lets it
     * actually shrink so its overflowing child scrolls instead of growing the box. */
    .container--post-detail .meta-panel-body {
        display: flex !important;
        flex-direction: column;
        flex: 1 1 auto;
        min-height: 0;
    }
    /* The mount now owns the panel's frame (bg + border + radius + scroll), so
     * neutralise the inner .meta-panel's own bg / radius / margin under v2
     * desktop — otherwise its background would sit as a second box inside the
     * border, with the .meta-panel margin showing a strip of the mount edge.
     * Its padding stays (content inset from the border). Scoped to ≥880px so
     * v1 and the narrow v2 fallback keep the panel's normal in-flow styling.
     *
     * Also a flex column so the mount's height flows down to the body → list →
     * composer chain (each must be a flex parent with min-height:0 for the
     * list's internal scroll to work). */
    .container--post-detail .meta-panel {
        display: flex;
        flex-direction: column;
        flex: 1 1 auto;
        min-height: 0;
        background: transparent;
        border-radius: 0;
        margin: 0;
    }
    /* The header (title + collapse toggle) and the quick-comment composer are
     * fixed bookends of the chat column — they never scroll. The composer
     * staying put at the bottom is requirement #2: it's always visible even as
     * the comment history scrolls behind it. */
    .container--post-detail .meta-panel-header,
    .container--post-detail .meta-panel-quick-comment,
    .container--post-detail .comment-subscribe {
        flex: 0 0 auto;
    }
    /* A thin divider separates the header (title + bell) from the comment
     * history below it. Scoped to the desktop always-on box: at narrow the
     * header can sit above a collapsed (hidden) body, where an underline would
     * read as a stray line, so keep the divider to the always-open box. */
    .container--post-detail .meta-panel-header {
        padding-bottom: 0.4rem;
        border-bottom: 1px solid var(--border-subtle);
    }
    /* The send button is a narrow-layout (touch) affordance: at desktop the
     * always-on side-rail composer keeps bare-Enter-to-send, so drop it. The
     * row collapses cleanly to the textarea alone (it's the only flex child). */
    .container--post-detail .meta-panel-quick-comment-send {
        display: none;
    }
    /* The comment history is the one scrolling region: it takes the leftover
     * column height (flex:1) and scrolls internally (min-height:0 lets it shrink
     * below its content). pathnav.js scrolls it to the bottom on selection /
     * after a new comment so the newest are in view (requirement #3).
     *
     * The scrollbar is pulled out to the box's far right edge and kept always
     * visible. A negative inline margin cancels the .meta-panel's 0.75rem side
     * padding so the wrapper spans the full frame width; an equal inline padding
     * re-insets the text. The vertical scrollbar then rides the frame edge (in
     * the space the side padding used to waste), with that 0.75rem of padding
     * sitting between it and the text so the bar never overlaps a comment.
     * overflow-y:scroll reserves the track, and the ::-webkit-scrollbar rule
     * below opts WebKit/Blink into a classic (non-overlay, never-fading) bar so
     * it shows even when there's little to scroll.
     *
     * Deliberately NOT setting scrollbar-width / scrollbar-color here: Chrome
     * 121+ treats a non-auto scrollbar-width as "use the standard scrollbar" and
     * then IGNORES ::-webkit-scrollbar styling — falling back to the overlay bar
     * that fades on macOS. So the standard props would silently un-pin our
     * always-visible bar. WebKit/Blink is macOS's overlay-scrollbar case anyway;
     * Firefox (which uses the standard props) still honours the OS overlay
     * setting regardless, so there's nothing to win there. */
    .container--post-detail .meta-panel-comments-wrapper {
        flex: 1 1 auto;
        min-height: 0;
        margin-inline: -0.75rem;
        padding-inline: 0.75rem;
        overflow-y: scroll;
    }
    .container--post-detail .meta-panel-comments-wrapper::-webkit-scrollbar {
        width: 8px;
    }
    .container--post-detail .meta-panel-comments-wrapper::-webkit-scrollbar-thumb {
        background: var(--border);
        border-radius: 4px;
    }
    .container--post-detail .meta-panel-comments-wrapper::-webkit-scrollbar-track {
        background: transparent;
    }
    /* Nudge the header bell left by the comment-list scrollbar's width (the 8px
     * track reserved just above) so it lines up with the heart icons in the rows
     * below. Those rows live inside the scrolling wrapper, whose reserved track
     * insets their right edge; the header sits OUTSIDE that wrapper, so without
     * this the bell hangs 8px further right than the hearts it caps. */
    .container--post-detail .meta-panel-subscribe {
        margin-right: 8px;
    }
    /* Undo the base layer's narrow bottom-sheet: at desktop the open panel is
     * the fixed MOUNT (the side rail above), so the inner .meta-panel stays in
     * normal flow inside it. Same :has() selector as the base rule, so this
     * later in-media rule wins at ≥880px. (bg / radius / margin are already
     * reset by the neutralise just above; these are the positional props the
     * bottom sheet adds.) */
    .container--post-detail .meta-panel:has(.meta-panel-toggle[aria-expanded="true"]) {
        position: static;
        inset: auto;
        max-height: none;
        overflow: visible;
        z-index: auto;
        border-top: 0;
        border-radius: 0;
        box-shadow: none;
    }
}
