/* ============================================================================
   mobile-polish.css  ·  Additive mobile performance & flow polish
   ----------------------------------------------------------------------------
   Loaded last (after app.css / app-2.css) so it wins specificity ties without
   editing any existing rule. Every block here is PURELY ADDITIVE: it only sets
   properties that the target selectors do not already declare (touch-action,
   content-visibility, overscroll-behavior, will-change, tap-highlight), plus a
   few clearly-beneficial mobile-scoped touch-target and reduced-motion tweaks.
   Nothing here changes desktop layout or existing look.

   Scope covers the five requested surfaces:
     · My Stances        (/my-stances · .mst-* studio, .ms-* collection)
     · Alignment Tool    (.align-*)
     · Stance Library    (#stance-library / .sl-*)
     · Comparisons       (.cmp-*)
     · Homepage / global (nav, .nav-link, .btn-tap, .li-card, .vh-*)

   Goals: faster paint, 60fps scrolling, less jank, bigger touch targets,
   seamless transitions.
   ========================================================================== */


/* ── 1 · Tap responsiveness ──────────────────────────────────────────────
   `touch-action: manipulation` drops the ~300ms double-tap-zoom wait so taps
   register immediately, and removing the grey tap-flash makes buttons feel
   native. Harmless on desktop; every interactive control across the five
   surfaces opts in. */
.ms-chip, .ms-fchip, .ms-pos, .ms-group-head, .ms-notebtn, .ms-remove,
.ms-sum-exp, .ms-pow-btn, .ms-toggle, .ms-sh-btn, .ms-sh-ic, .ms-ov-cta-btn,
.ms-prio select,
.align-cat-head, .align-chip, .align-int-btn, .align-quick-chip,
.align-tool-btn, .align-area-chip, .align-fab, .align-compact-btn,
.align-done-btn, .align-collapse-link,
.sl-chip, .sl-card, .sl-jchip, .sl-back, .sl-btn, .sl-adopt-btn,
.sl-pol-act, .sl-search-clear, .sl-thread, .sl-rec-btn,
.cmp-empty-cta, .cmp-issue-allev, .cmp-vs-link,
.nav-link, .nav-mandate-btn, .btn-tap, .vh-cta-primary, .vh-step-cta,
.li-card,
/* Politician profile: the drawer toggles, the jump rail's pills and the pledge
   count chips are the controls a reader taps most on a phone, and none of them
   had opted in — every tap paid the ~300ms double-tap-zoom wait, which reads as
   the profile being slow rather than the browser being cautious.

   The profile's other two disclosure mechanisms are here for the same reason:
   .pdxg-toggle folds the coverage gaps, and the <summary> elements open an
   Official Record issue row, an awaiting-position list and a Flashpoint card
   body. Same job as .dd-toggle-btn, same tap, so the same treatment — a reader
   opening three sections in a row should not feel one of them lag. */
.dd-toggle-btn, .pdx-pnav-pill, .vbadge-click, .pdx-ft-rate-how, .pdx-ft-rate-sum,
.pdxg-toggle, .pdxor-row-sum, .pdxor-awaiting-d > summary, .pdx-ctv-more > summary {
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}


/* ── 2 · Faster first paint — skip rendering off-screen list items ────────
   `content-visibility: auto` lets the engine skip layout & paint for cards
   that are scrolled out of view, so long stance/library/comparison lists
   paint their first screen far sooner and scroll cheaper. `contain-intrinsic-
   size` reserves a remembered placeholder height so the scrollbar never
   jumps, and items still render on demand as they approach the viewport
   (in-page scrollIntoView / find-in-page keep working). Scoped to phones,
   where these lists are single-column and paint cost hurts most. */
@media (max-width: 640px) {
  .ms-issue {
    content-visibility: auto;
    contain-intrinsic-size: auto 128px;
  }
  .sl-card {
    content-visibility: auto;
    contain-intrinsic-size: auto 190px;
  }
  .sl-pol {
    content-visibility: auto;
    contain-intrinsic-size: auto 240px;
  }
  .cmp-issue {
    content-visibility: auto;
    contain-intrinsic-size: auto 150px;
  }
  /* ── Profile modal sections ──────────────────────────────────────────────
     The politician profile is the longest single view in the app — a dozen-plus
     `.modal-section` blocks in one scroller — and it was the one long-list
     surface this pass skipped, so the whole thing laid out and painted before
     the first screen appeared. Lazy-painting the sections is the single biggest
     win available here.

     `content-visibility: auto` implies `contain: layout style paint`, which does
     three things that can bite, so the exclusion list below is the "where safe"
     part and each entry is there for a measured reason:

       canvas             Chart.js sizes to its parent at draw time. A canvas in a
                          skipped subtree measures 0×0 and stays broken after the
                          section reveals.
       .dd-body           _pdxNavJump reads getBoundingClientRect() on a jump
                          target that can sit inside a drawer, and a skipped
                          subtree reports zeros. Cheap to exclude: a closed
                          .dd-body is not painted anyway, so little was saved.
       #pdx-promise-filter,
       .mandate-jump      position: sticky descendants — paint containment clips
                          them to the section box, so a bar meant to stick to the
                          scroller would scroll away with its section instead.
       .pdx-sib-grid      its own horizontal scroller, with sticky header cells;
                          same clipping problem, plus its width is not derivable
                          from an intrinsic-size guess.
       .pdxsp-dw          the spine's drawer-wrapper sections, per .dd-body above.

     Scoped to #modal-body so nothing outside the profile is affected, and to
     phones, where the paint cost actually hurts. `contain-intrinsic-size: auto`
     keeps each section's real measured height once it has rendered, so the
     scrollbar settles and the scroll-spy reads true positions after the first
     pass; the 320px figure is only the first-paint placeholder. Sections keep
     their own boxes while skipped, which is what the jump rail and the spy
     measure, and find-in-page still reveals skipped content.

     An engine without :has() support drops the whole rule, which is exactly the
     pre-existing behaviour — so this degrades to a no-op rather than to a bug. */
  #modal-body .modal-section:not(.pdxsp-dw):not(:has(canvas, .dd-body, #pdx-promise-filter, .mandate-jump, .pdx-sib-grid)) {
    content-visibility: auto;
    contain-intrinsic-size: auto 320px;
  }
}
@media (max-width: 560px) {
  /* Local Issues collapses to one column at 560px — safe to lazy-paint here. */
  #local-issues .li-card {
    content-visibility: auto;
    contain-intrinsic-size: auto 220px;
  }
}


/* ── 3 · Smoother scrolling — momentum + contained overscroll ─────────────
   Momentum (inertial) scrolling on iOS for the scrollable panels, and
   `overscroll-behavior: contain` so a swipe that reaches the end of a chip
   rail or a modal doesn't chain into the page behind it or trigger the
   browser's back-swipe. */
.sl-chips {
  -webkit-overflow-scrolling: touch;
  overscroll-behavior-x: contain;
}
.ms-ov-panel {
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
}
/* The profile's sticky jump rail is a horizontal scroller like .sl-chips above.
   Without containment, swiping it past either end chains into the modal body — or
   into the browser's back-swipe gesture, which closes the profile mid-read. */
.pdx-pnav-track {
  overscroll-behavior-x: contain;
}

/* THE PHONE OVERRIDE THAT USED TO LIVE HERE IS GONE ON PURPOSE.
   It read `html { scroll-padding-top: 72px }` and was described as a fallback
   for an unresolvable custom property — but it was a THIRD value for the one
   offset the page has, on top of app.css's and section 7c's, and 72px is not
   the chrome on any phone that reported the clipped wordmark: a notched device
   is 113–160px once the safe-area inset and two nav rows are counted.

   There is now exactly one declaration of html{scroll-padding-top}, in app.css,
   derived from --pdx-chrome — the variable the nav measures itself into. A
   phone whose JS never runs gets the stylesheet literal that --pdx-chrome is
   declared with, which is the same fallback this rule was reaching for and is
   maintained in one place instead of three. */


/* ── 4 · Seamless transitions — promote only what actually animates ───────
   A light, targeted `will-change` on the handful of elements that transform
   or fade (never a blanket promotion) hands them to the compositor so their
   transitions stay on the 60fps path. All are single, small elements. */
.align-fab { will-change: transform; }
.ms-toast { will-change: transform, opacity; }
.ms-ov-panel { will-change: transform; }
.ms-group-caret, .align-cat-chev { will-change: transform; }

/* The fixed, backdrop-blurred top nav repaints as the page scrolls under it;
   pinning it to its own compositor layer keeps that repaint off the main
   scroll frame. */
nav.nav-blur {
  will-change: transform;
  transform: translateZ(0);
}

/* Card hover-lift transitions are pointer-only flourishes; on touch devices
   they can't fire but the compositor still watches for them. Neutralize the
   transition cost on coarse pointers so first paint and scroll are leaner. */
@media (hover: none) and (pointer: coarse) {
  .sl-card, .li-card, .ms-chip { transition: none; }
}


/* ── 5 · Comfortable touch targets (≥44px) ───────────────────────────────
   Several controls render below the 44px tap minimum on phones. Grow them on
   coarse-pointer / small screens only, leaving the compact desktop look
   untouched. Uses min-height + centering so nothing reflows awkwardly. */
@media (hover: none) and (pointer: coarse) {
  .cmp-vs-link {
    min-width: 40px;
    min-height: 40px;
  }
  .cmp-chip,
  .cmp-empty-cta {
    min-height: 34px;
    display: inline-flex;
    align-items: center;
  }
  .sl-jchip,
  .sl-chip,
  .sl-rec-btn,
  .sl-pol-act {
    min-height: 40px;
    display: inline-flex;
    align-items: center;
  }
  .sl-back,
  .sl-search-clear {
    min-width: 40px;
    min-height: 40px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  .ms-chip,
  .ms-fchip,
  .ms-sh-ic {
    min-height: 40px;
    display: inline-flex;
    align-items: center;
  }
}


/* ── 6 · Battery & jank — hush decorative loops for reduced-motion ────────
   Additive top-up to the existing prefers-reduced-motion handling: silence the
   remaining infinite/decorative loops on the polished surfaces so low-power
   phones aren't animating off-screen. Layout and interactivity are unchanged. */
@media (prefers-reduced-motion: reduce) {
  .align-fab.is-empty,
  .align-panel::before,
  .ms-flash,
  .align-flash {
    animation: none !important;
  }
  .align-chip.just-toggled,
  .align-cat-badge.pop,
  .align-sel-count.pop {
    animation: none !important;
  }
}


/* ══════════════════════════════════════════════════════════════════════════
   7 · LAYOUT STABILITY — app-shell hardening
   --------------------------------------------------------------------------
   Everything below targets site-wide "jumpiness": content that moves after you
   have started reading it. It pairs with pdx-stability.js, which owns the
   scroll-lock coordinator and the Safari scroll-anchoring fallback. The top
   offset these rules consume is --pdx-chrome, measured once under the nav in
   index.html and nowhere else. Still purely additive: no
   existing declaration is replaced, and every layout-affecting change is either
   mobile-scoped or a no-op where the browser already behaves.
   ══════════════════════════════════════════════════════════════════════════ */

/* ── 7a · Reserve the scrollbar's space, permanently ──────────────────────
   ~40 call sites across 15 feature files lock the page by setting
   `overflow: hidden` on <body> when a modal opens. On any desktop/laptop with
   classic (space-taking) scrollbars, that removes the scrollbar and the whole
   document instantly re-lays-out ~15px wider — then snaps back on close. Every
   modal open/close was a full-page horizontal reflow. Reserving the gutter for
   good makes the lock free. Phones use overlay scrollbars, so this is inert
   there; declared unscoped so the desktop case is fixed too. */
html {
  scrollbar-gutter: stable;
}

/* ── 7b · The scroll lock, done once and done properly ────────────────────
   pdx-stability.js adds this class to <html> for the duration of any modal.
   Two things the per-file `body { overflow: hidden }` could not do:
     · iOS Safari ignores overflow on <body> for the document scroller but
       honours it on <html>, so the page behind an open profile actually stays
       put instead of drifting while you read the modal.
     · `scroll-behavior: auto` keeps the position restore on close instant —
       the global `html { scroll-behavior: smooth }` would otherwise animate a
       correction that is meant to be invisible.
   Modal internals are `position: fixed` with their own overflow, so their
   scrolling is unaffected. */
html.pdx-scroll-locked {
  overflow: hidden;
  scroll-behavior: auto;
}
/* Applied for the single frame around any internal scroll correction, so
   engines that ignore `behavior: 'instant'` still land instantly. */
html.pdx-instant-scroll {
  scroll-behavior: auto !important;
}

/* ── 7c · One source of truth for the fixed nav's height ───────────────────
   Sticky jump-bars and scroll padding hard-coded the nav height (62px, 80px,
   108px) at a single breakpoint, so on phones the sticky bars tucked under the
   nav and hash jumps landed with the target heading hidden behind it.

   THAT FIX SHIPPED A SECOND SOURCE OF TRUTH RATHER THAN ONE. --pdx-nav-h was
   measured by pdx-stability.js while --pdx-chrome was measured by the script
   under the nav in index.html, and the two do not agree:

     · --pdx-nav-h took `nav.getBoundingClientRect().height`, i.e. the WHOLE
       nav element — and the mobile drawer is a normal-flow child of that nav,
       so an open menu is part of the number.
     · To survive that, it discarded anything over 200px. A notched phone at a
       stepped-up font size renders the two permanent rows plus the safe-area
       inset at more than 200px, so on exactly the devices that reported the
       clip the measurement was thrown away and the 57px literal below stood.
     · This file loads after app.css, so the `html { scroll-padding-top }`
       that used to sit in this section overrode app.css's correct
       calc(var(--pdx-chrome) …) site-wide with calc(57px + 16px) = 73px —
       against real chrome of 113–160px. Every hash jump and every
       scrollIntoView on the page landed 40–90px under the bar.

   --pdx-chrome is the survivor: it measures the bottom edge of the last
   PERMANENT row (.pdx-eye-row), which is immune to the drawer, and it counts
   the safe-area inset because the nav is padded by it. --pdx-nav-h is kept as
   a plain alias so any rule that still names it gets the same number, and the
   duplicate html{scroll-padding-top} that lived here is gone — app.css owns
   that declaration alone now.

   The literal is the pre-measurement / no-JS value, and it is the same literal
   --pdx-chrome is declared with in index.html. */
:root {
  --pdx-nav-h: var(--pdx-chrome, 7.125rem);
}

/* THE BLANKET `section[id] { scroll-margin-top: … }` THAT USED TO SIT HERE IS
   GONE ON PURPOSE. It was written to give sections that had no inline offset
   the same clearance as the ones that did — but scroll-margin-top ADDS to the
   scrollport's scroll-padding-top rather than replacing it, so once app.css
   states the chrome once for the whole document, restating it here charged
   every `<section id>` on the site the fixed nav twice and landed hash jumps
   and scrollIntoView calls a full chrome-height past their heading. Sections
   need nothing here: html{scroll-padding-top} in app.css already clears them. */

/* The two sticky quick-jump rails both hard-coded `top: 62px`. Pin them to the
   measured chrome instead so they sit flush under it rather than overlapping it
   or floating clear of it. */
.el-jump,
.mandate-jump {
  top: calc(var(--pdx-chrome, 7.125rem) + 5px);
}

/* ── 7d · Stop the sticky chrome repainting the page behind it, per frame ──
   `backdrop-filter: blur(…)` on a full-width fixed/sticky bar forces the
   compositor to re-sample and re-blur everything underneath it on EVERY scroll
   frame. On mid-range phones that is the single most expensive thing on the
   page and it reads as stuttering, unstable scrolling. The bars are already
   ~80–92% opaque, so on phones we drop the blur and top the background up to
   fully opaque: near-identical appearance, none of the per-frame cost. Desktop
   keeps the blur untouched. */
@media (max-width: 640px) {
  nav.nav-blur {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
    background-color: #0a0f1e;
  }
  .el-jump,
  .mandate-jump {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }

  /* The profile overlay blurs a page that is already hidden. #modal-overlay is
     bg-black/85 AND backdrop-blur-md: at 85% opacity the blur is all but
     invisible, and it costs a full-viewport rasterisation on every profile open
     and every close — the two gestures a reader makes most. Dropped on phones
     only; the tablet and desktop rule is untouched. */
  #modal-overlay {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

/* ── 7e · Hush the huge decorative blur loops on phones ───────────────────
   The hero and several section backgrounds stack 900px-wide radial glows behind
   a `blur(120–160px)` filter and animate them on infinite loops. Each frame
   re-rasterises a very large blurred layer, which competes with scrolling on
   exactly the devices that can least afford it. On phones the glows stay — they
   are part of the look — but they stop animating, so they rasterise once.
   Desktop is untouched. */
@media (max-width: 640px) {
  .animate-pulse2,
  .animate-float {
    animation: none !important;
  }
}

/* ── 7f · Async-loaded sections must not collapse to zero ─────────────────
   #say-vs-do, #issue-front-door and #hr1-showcase ship as empty, `hidden`
   stubs and are filled in later by their feature modules; #local-issues and
   the admin sections toggle `display` from JS. Each of those transitions moves
   everything below it. pdx-stability.js absorbs the shift on engines without
   native scroll anchoring; this reserves a floor for the fade-in so the growth
   that remains is a small settle rather than a jump from nothing. Scoped to
   phones and to the populated state — an empty stub still takes no space, so a
   self-gating section that has no data to show never leaves a blank band. */
@media (max-width: 640px) {
  #say-vs-do:not(:empty):not([hidden]),
  #issue-front-door:not(:empty):not([hidden]),
  #hr1-showcase:not(:empty):not([hidden]) {
    min-height: 320px;
  }
}


/* ── 7g · The donate card owns its own scroll on a phone ──────────────────
   THE REPORT: on a 390×844 screen the Support card was "jumpy, laggy, and
   stops scrolling." Three separate causes, all of them in this one card:

   1. IT WAS THE TALLEST CARD ON THE HOMEPAGE AND HAD NO SCROLLER OF ITS OWN.
      Badge, heading, paragraph, a 210px QR block and four more lines of copy
      run past 844px of glass once the browser toolbar is showing, so the
      reader's only way through it was the document scroller — and the document
      scroller is the one the toolbar's own show/hide gesture competes with.
      The fix is the scroll contract this pass applies everywhere: the panel
      hosting Support owns `overflow-y: auto` and a height bounded in `dvh`
      minus the measured fixed chrome, so the CARD is what scrolls under the
      finger and the page behind it is not asked to move at the same time.

      `--pdx-chrome` (§7c) is the measured nav depth, so this is the visible
      viewport minus the bar that covers it, minus a margin so the card visibly
      ends inside the screen rather than exactly at its edge. `dvh`, not `vh`:
      §7c and the bottom-chrome doctrine both say it, and a `vh`-bounded box is
      precisely the bug being fixed on the Your File panel in the same pass.

      THE FALLBACK IS 7.125rem BECAUSE THAT IS THE ONE LITERAL. index.html
      declares `--pdx-chrome: 7.125rem` on :root for the frames before the
      measurer runs, and scripts/test-top-chrome.mjs pins every `var(--pdx-chrome,
      …)` fallback in every sheet to that same value — "one offset". A second
      number here, however reasonable on its own, is first paint disagreeing with
      the second frame about where the page starts.

   2. `overscroll-behavior-y: contain` STOPS THE HANDOFF. Without it, a flick
      that reaches the end of the card's range chains straight into the document
      and the toolbar — which is the "jumpy" half: the reader lifts a finger and
      the whole page lurches. Contained, the card's range simply ends.

   3. `animation: venmoGlow 6s infinite` ON THE QR FRAME WAS THE LAG. It
      repaints a 300%×300% five-stop gradient on a shadowed, rounded box, every
      frame, forever, whether or not the card is on screen — sitting behind two
      256px circles under a `blur(64px)` filter. That is three large composited
      layers of permanent work for a decorative shimmer nobody is looking at
      while they scroll. On phones the frame keeps its gradient, pinned at one
      position, and stops animating; the blur circles fade to a flat tint. The
      appearance barely changes. Desktop keeps all of it.

   Scoped to ≤640px and to touch, because none of the three is a problem on a
   desktop viewport that fits the card whole and has the headroom to paint it.
   Nothing here adds a number, a percentage, a party read or a count — the card
   publishes no total and this rule does not give it one. */
@media (max-width: 640px) {
  .pdx-donate .pdx-donate-scroll {
    max-height: calc(100dvh - var(--pdx-chrome, 7.125rem) - 40px);
    overflow-y: auto;
    overscroll-behavior-y: contain;
    touch-action: pan-y;
    -webkit-overflow-scrolling: touch;
    /* The card's own padding already inset the content; this only keeps the
       scrollbar (where one is drawn) off the last character of a line. */
    padding-right: 2px;
  }
  /* The sweep stands down. The gradient stays — it is the card's frame — it
     just holds still at the position the animation started from. */
  .pdx-donate .venmo-qr-frame {
    animation: none !important;
    background-position: 0% 50%;
  }
  /* Two 256px circles under a 64px blur, for a tint. Same tint, no blur. */
  .pdx-donate .pdx-donate-glow {
    -webkit-filter: none;
    filter: none;
    opacity: 0.5;
  }
  /* The button is full-bleed inside the card at phone width: it is the action
     the card exists for, and a centred 22rem control on a 390px screen leaves
     dead margin on both sides of the only thing worth tapping. */
  .pdx-donate .pdx-donate-btn {
    max-width: 100%;
    min-height: 52px;
  }
}

/* A reader who has asked for less motion gets the still card at every width,
   not just on a phone. §6 already hushes the decorative loops; the QR frame
   was never in that list because its animation is a `background-position`
   sweep rather than one of the named keyframes. */
@media (prefers-reduced-motion: reduce) {
  .venmo-qr-frame {
    animation: none !important;
    background-position: 0% 50%;
  }
}


/* ── 7h · The donate card stands still at EVERY width ─────────────────────
   THE REPORT: "the donate hash is still hitchy on phone and a bit on desktop."
   §7g above stood the card's two permanent compositor jobs down at ≤640px and
   §6's reduced-motion block stood them down for a reader who had asked for
   less motion. That left one case uncovered — a desktop reader who has not
   asked — and it is the case the report calls "a bit on desktop."

   THE TWO JOBS, and why neither survives being asked to prove itself:

   1. `animation: venmoGlow 6s ease-in-out infinite` repaints a 300%×300%
      five-stop gradient on a shadowed, rounded box every frame, forever,
      on screen or not. A background-position sweep is not a compositor-only
      animation: `background-position` is not a transform or an opacity, so it
      cannot be handed to the compositor — every frame is a paint of the
      element on the main thread, which is the same thread the arrival scroll
      and the card's layout are on. That is the fight.

   2. The two `.pdx-donate-glow` circles are `w-64 h-64` (256px) under
      Tailwind's `blur-3xl` — a 64px Gaussian. A blur filter promotes its
      element to its own layer and expands that layer well past its own box, so
      the card carries two ~380px composited surfaces whose only job is a
      tint. The claim in the brief was "stand them down unless you can prove 0
      extra layer": a `filter` other than `none` is, by the spec's own
      definition, a stacking context and a new layer. It cannot be proven free
      because it is not free. The same tint is reachable with no layer at all —
      the circles are already a flat radial colour at 50% opacity behind a
      backdrop-blurred card, so `filter: none` with the opacity held at the
      value §7g uses is visually the same card.

   WHAT IS NOT CHANGED. app.css keeps `@keyframes venmoGlow` and keeps
   declaring it on `.venmo-qr-frame`: the keyframes are the card's identity and
   scripts/test-mobile-body-lock.mjs pins that declaration deliberately, so
   this stand-down is ADDITIVE and scoped to `.pdx-donate` — the one place the
   frame ships. A QR frame used anywhere else, now or later, is untouched.

   And nothing here is a layout change: no size, no position, no flow. The card
   publishes no total, goal or progress bar before this rule and none after it,
   and the Venmo address is the one address it already had. */
.pdx-donate .venmo-qr-frame {
  animation: none !important;
  /* Pinned where the sweep began, so the frame keeps its gradient. */
  background-position: 0% 50%;
}
.pdx-donate .pdx-donate-glow {
  -webkit-filter: none;
  filter: none;
  opacity: 0.5;
}
