/* Just Mercy · core
   Split out of the single components.css so concurrent work on different
   areas cannot collide in one file, and so a page can eventually load only
   the CSS its elements need. Tokens come from tokens.css; no literal brand
   hex belongs in here (the page-quality gate's D2 criterion enforces it). */

/* =========================================================================
   THE CONTAINER — one measure for the whole site

   Figma puts every page board at 1440 wide with content starting at x=80 and
   running 1280 wide, and the header and footer sit on that same inset. Before
   this rule the site had three different measures — the header capped at 1280,
   the footer at 1440, and Bricks' own container at a flat 1100px — which put the
   header logo, the footer logo and page content on three different left edges.

   Everything that holds page content gets this rule: the header bar's inner, the
   footer's inner, and `.brxe-container`, which is both the Bricks container
   element the team places in the builder AND the wrapper Bricks uses for pages
   with no Bricks content (see the theme's template-parts/content.php).

   Bricks ships `.brxe-container { width: 1100px }` with no max-width at all, so
   its stock container also overflows below 1100. This replaces it.
   ========================================================================= */

.jm-container,
.jm-header__inner,
.jm-footer__inner,
.brxe-container {
  width: 100%;
  max-width: var(--container-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

/* A container nested inside another is a layout sub-group, not a second page
   container: it must not add a second gutter or re-apply the measure. Bricks does
   not mark the outermost container with a class on the front end, so the nesting
   has to be expressed in the selector. */
.brxe-container .brxe-container {
  max-width: none;
  padding-inline: 0;
}


/* Orphan control, site-wide: no paragraph-shaped run ends on a lone word.
   Phil's call; browsers without text-wrap:pretty ignore it, and the headings
   that want `balance` set it themselves at higher specificity. */
p,
li,
dd,
figcaption,
blockquote {
  text-wrap: pretty;
}

/* =========================================================================
   JM Button — Figma "Atoms / Button"

   State behaviour is taken verbatim from the spec panel on the Figma board:
     Transitions: background var(--motion-fast) var(--motion-ease-out), transform 100ms ease-out
     Hover:    Primary Gold 500 → 400 · Outline fills Purple 50 · Ghost fills Stone 100 at 12%
     Pressed:  scale 0.98 plus one step deeper (Gold 600 · Purple 100 · Stone 100 at 20%)
     Focus-visible: 3px ring, 2px offset — Purple 500 on light, Gold 500 on dark
     Disabled: Stone surfaces, hover inert, aria-disabled="true"
     Targets:  minimum 44x44px, label Host Grotesk Medium 16
     prefers-reduced-motion: drop the transform, keep the colour fades
   ========================================================================= */

.jm-button {
  --jm-button-bg: transparent;
  --jm-button-fg: var(--purple-500);
  --jm-button-border: transparent;
  --jm-button-ring: var(--purple-500);

  display: inline-flex;
  align-items: center;
  justify-content: center;
  /*
   * Straight off the Figma Button atom (304:414 / 304:418), which every board
   * instances rather than redrawing:
   *   padding 16 / 24, gap 8, label 15px Medium, line-height 1.2, tracking 2.5%.
   *
   * What was here before, and what it cost:
   *   gap 10   — the label sat 2px further from the arrow than drawn.
   *   16px     — every button label was a size too big.
   *   no tracking — the atom carries the same 0.375px as the eyebrows.
   *   border 1.5px on ALL variants — the board draws a border on `outline` only,
   *     so every gold and tinted button was 3px taller than drawn (45 not 42) and
   *     3px wider, on every page.
   *   min-height 44 — WCAG 2.5.8 asks for 24x24; 44 is the AAA figure. It was
   *     forcing the compact button from the drawn 42 up to 44. Removed: the
   *     default button computes to 50 and the compact to 42, both well clear.
   */
  gap: 0.5rem;                   /* 8 */
  padding: 1rem 1.5rem;          /* 16 / 24 */
  border: 0 solid var(--jm-button-border);
  border-radius: 0;              /* square, as drawn */
  background-color: var(--jm-button-bg);
  color: var(--jm-button-fg);
  font: 500 var(--text-caption-m)/1.2 var(--font-sans);   /* 15 Medium */
  letter-spacing: 0.025em;       /* 2.5% — the atom's tracking */
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  transition: background-color var(--motion-fast) var(--motion-ease-out),
              border-color var(--motion-fast) var(--motion-ease-out),
              color var(--motion-fast) var(--motion-ease-out),
              transform 100ms ease-out;
}

.jm-button--full { display: flex; width: 100%; }

.jm-button__label { display: inline-block; }

/* A wrapper, not a sizer. It used to force 1em square and stretch the child svg to
   100%, which squashed the brand arrow's 12.627 x 10.535 into a square. The icons
   carry their own dimensions now, so this only has to align them. */
.jm-button__icon {
  display: inline-flex;
  align-items: center;
  flex: 0 0 auto;
  line-height: 0;
}
.jm-button__icon svg { display: block; }
/* Bricks' icon picker can output an icon font instead of an svg. */
.jm-button__icon i { font-size: 1em; line-height: 1; }

/* ---- The two icon SVGs ----
   Both carry their real width/height attributes, which matters twice over: it gives
   the browser an aspect ratio before CSS arrives, and it stops Bricks' own
   `svg:not([width]) { min-width: 1em }` from flooring them to 1em of whatever
   font-size they happen to sit in — that rule was rendering the caret at 34px
   inside an h2 and squashing the arrow to a square. */
.jm-arrow {
  display: block;
  width: 0.789rem;                     /* 12.627 */
  height: 0.6585rem;                   /* 10.535 — the drawn aspect */
  transition: transform var(--motion-fast) var(--motion-ease-out);
}

/* One arrow, one size, one behaviour.

   Every arrow on the site comes from `jm_arrow_svg()` and is sized by the rule
   above, so a button, a link-arrow, a card link, a source, the header and footer
   CTAs and the donation widget all draw the identical 12.627 x 10.535 glyph.

   Phil: the arrow should nudge right on hover. Written against the interactive
   ancestor rather than each component, so it applies once and cannot drift as
   new components are added. Keyboard users get it too, via :focus-visible.

   Deliberately NOT applied to .jm-caret — a disclosure caret rotates, it does
   not travel. */
:is(a, button, summary):hover .jm-arrow,
:is(a, button, summary):focus-visible .jm-arrow {
  transform: translateX(0.25rem);      /* 4 */
}

@media (prefers-reduced-motion: reduce) {
  .jm-arrow { transition: none; }
  :is(a, button, summary):hover .jm-arrow,
  :is(a, button, summary):focus-visible .jm-arrow { transform: none; }
}

.jm-caret {
  display: block;
  width: 1rem;                         /* 16 */
  height: 1rem;
}

/* ---- Variants ---- */

.jm-button--primary {
  --jm-button-bg: var(--gold-500);
  --jm-button-fg: var(--purple-500);
  --jm-button-border: var(--gold-500);
}
.jm-button--primary:hover  { --jm-button-bg: var(--gold-400); --jm-button-border: var(--gold-400); }
.jm-button--primary:active { --jm-button-bg: var(--gold-600); --jm-button-border: var(--gold-600); }

/* The one variant the board strokes (304:418): 1px Purple/500, label Purple/600. */
.jm-button--outline {
  --jm-button-bg: transparent;
  --jm-button-fg: var(--purple-600);
  --jm-button-border: var(--purple-500);
  border-width: 1px;
}
.jm-button--outline:hover  { --jm-button-bg: var(--purple-50); }
.jm-button--outline:active { --jm-button-bg: var(--purple-100); }

.jm-button--tinted {
  --jm-button-bg: var(--purple-400);
  --jm-button-fg: var(--gold-500);
  --jm-button-border: var(--purple-400);
  --jm-button-ring: var(--gold-500);
}
/* Hover DARKENS: gold on a lighter purple loses contrast, so the ladder runs
   400 → 500 → 600 (Phil, 2026-08-31). */
.jm-button--tinted:hover  { --jm-button-bg: var(--purple-500); --jm-button-border: var(--purple-500); }
.jm-button--tinted:active { --jm-button-bg: var(--purple-600); --jm-button-border: var(--purple-600); }

/* Ghost and Outline dark are the dark-surface pair: light label, gold focus ring. */
.jm-button--ghost {
  --jm-button-bg: transparent;
  --jm-button-fg: var(--stone-100);
  --jm-button-border: transparent;
  --jm-button-ring: var(--gold-500);
}
.jm-button--ghost:hover  { --jm-button-bg: color-mix(in srgb, var(--stone-100) 12%, transparent); }
.jm-button--ghost:active { --jm-button-bg: color-mix(in srgb, var(--stone-100) 20%, transparent); }

.jm-button--outline-dark {
  --jm-button-bg: transparent;
  --jm-button-fg: var(--stone-100);
  --jm-button-border: var(--stone-100);
  --jm-button-ring: var(--gold-500);
}
.jm-button--outline-dark:hover  { --jm-button-bg: color-mix(in srgb, var(--stone-100) 12%, transparent); }
.jm-button--outline-dark:active { --jm-button-bg: color-mix(in srgb, var(--stone-100) 20%, transparent); }

/* ---- Pressed ---- */

.jm-button:active { transform: scale(0.98); }

/* ---- Focus ----
   "Never remove the outline." outline-offset keeps the ring clear of the square
   edge, and :focus-visible means a mouse click does not paint it. */
.jm-button:focus-visible {
  outline: 3px solid var(--jm-button-ring);
  outline-offset: 2px;
}

/* ---- Disabled ----
   Hover inert, Stone surfaces. Covers both the real button[disabled] and the
   aria-disabled anchor, since an anchor has no disabled state of its own. */
.jm-button:disabled,
.jm-button[aria-disabled='true'] {
  --jm-button-bg: var(--stone-300);
  --jm-button-fg: var(--stone-700);
  --jm-button-border: var(--stone-600);
  cursor: not-allowed;
  pointer-events: none;
}
.jm-button:disabled:hover,
.jm-button[aria-disabled='true']:hover,
.jm-button:disabled:active,
.jm-button[aria-disabled='true']:active {
  --jm-button-bg: var(--stone-300);
  --jm-button-border: var(--stone-600);
  transform: none;
}

/* ---- Compact size ----
   The header and footer "Start a conversation" CTA: tighter padding, a little
   tracking, and a Purple/700 label, which reads with more contrast on the purple
   bar than Purple/500 does. Taken from the Figma header and footer organisms,
   which both use exactly these values. */

.jm-button--compact {
  min-height: 2.75rem;                 /* still a 44px target */
  padding: 0.75rem 1rem;               /* 12 / 16 */
  gap: 0.25rem;                        /* 4 */
  letter-spacing: 0.025em;             /* Figma: 0.375px at 15px */
  font-size: var(--text-label);        /* 15 */
}
.jm-button--compact.jm-button--primary { --jm-button-fg: var(--purple-700); }

/* ---- Reduced motion ----
   Per the Figma spec: drop the transform, keep the colour fades. This is more
   specific than the global guard in tokens.css, deliberately. */
@media (prefers-reduced-motion: reduce) {
  .jm-button {
    transition: background-color var(--motion-fast) var(--motion-ease-out),
                border-color var(--motion-fast) var(--motion-ease-out),
                color var(--motion-fast) var(--motion-ease-out);
  }
  .jm-button:active { transform: none; }
}


/* =========================================================================
   JM Tag — Figma "Atoms / Tag / Pill"
   ========================================================================= */

.jm-tag {
  display: inline-flex;
  align-items: center;
  padding: 0.375rem 0.75rem;     /* 6 / 12 */
  border-radius: var(--radius-pill);
  font: 500 var(--text-overline)/1.2 var(--font-sans);   /* Overline 11 Medium */
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--purple-500);
}

.jm-tag--purple { background-color: var(--purple-50); }
.jm-tag--gold   { background-color: var(--gold-500); }

a.jm-tag { transition: background-color var(--motion-fast) var(--motion-ease-out); }
a.jm-tag--purple:hover { background-color: var(--purple-100); }
a.jm-tag--gold:hover   { background-color: var(--gold-400); }
a.jm-tag:focus-visible {
  outline: 3px solid var(--purple-500);
  outline-offset: 2px;
}


/* =========================================================================
   JM Link arrow — Figma "Atoms / Link / Arrow"
   ========================================================================= */

.jm-link-arrow {
  display: inline-flex;
  align-items: baseline;
  gap: 0.375rem;
  font: 500 var(--text-body)/1.2 var(--font-sans);
  text-decoration: none;
  color: var(--purple-500);
}
.jm-link-arrow--light { color: var(--stone-100); }
.jm-link-arrow--gold  { color: var(--gold-500); }

.jm-link-arrow__label { text-decoration: underline; text-underline-offset: 0.2em; text-decoration-thickness: 1px; }
.jm-link-arrow__arrow { transition: transform var(--motion-fast) var(--motion-ease-out); }
.jm-link-arrow:hover .jm-link-arrow__arrow { transform: translateX(0.25em); }
.jm-link-arrow:focus-visible { outline: 3px solid currentColor; outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .jm-link-arrow__arrow { transition: none; }
  .jm-link-arrow:hover .jm-link-arrow__arrow { transform: none; }
}


/* =========================================================================
   JM Logo — Figma "Atoms / Logo / Master lockup"

   The artwork's two brand colours are custom properties, so one SVG file serves
   both the dark and light surfaces. Figma exports the lockup reversed, because
   both places it appears sit on Purple/500.
   ========================================================================= */

.jm-logo {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  line-height: 0;
}

.jm-logo__svg {
  display: block;
  width: 12.875rem;                    /* 206 */
  height: auto;
}

.jm-logo--reversed { --jm-logo-mark: var(--stone-500); --jm-logo-tag: var(--gold-500); }
.jm-logo--primary  { --jm-logo-mark: var(--purple-500); --jm-logo-tag: var(--gold-500); }

.jm-logo:focus-visible {
  outline: 3px solid var(--gold-500);
  outline-offset: 4px;
}
.jm-logo--primary:focus-visible { outline-color: var(--purple-500); }

/* ---- Headings carry no margin; spacing is the container's job ----

   Bricks ships `:where(p:last-of-type) + h1, ... + h6 { margin-block-start: ... }`
   in frontend-layer.min.css: any heading directly after a paragraph gets a top
   margin sized in em, so it scales with the heading.

   Every eyebrow on this site is a <p> immediately followed by its heading, so
   every eyebrow/heading pair inherited that margin ON TOP of the 24px flex gap
   the Figma frames actually specify. Measured on the live site before this fix:
   88.8px above an h1, 57.6px above an h2, 30px above a small card heading. Figma
   draws 24 for all of them (About hero 567:6181, homepage hero 567:6179 — eyebrow
   ends at y=18, heading starts at y=42).

   The boards are auto-layout frames: all spacing is gaps, never margins. So
   headings here carry none and the container owns the rhythm. Unlayered, which
   beats Bricks' layered rule regardless of specificity — the same cascade-layer
   property that makes fluid-root.css authoritative. */
:where(p) + :is(h1, h2, h3, h4, h5, h6),
:is(h1, h2, h3, h4, h5, h6) {
  margin-block-start: 0;
}

/* Same problem on the other side of the box. Bricks gives a paragraph a bottom
   margin of about 1.2em, and a `text-basic` element IS a single <p>, so that
   margin is not paragraph spacing — it is a gap between two sibling layout items
   that already have a `gap` set. The two add up.

   It showed up worst in the homepage stat blocks: Figma draws a 4px gap between
   the 32px figure and its 15px caption (StatBlock, 81:222), and the page rendered
   42.4px — the 4px gap plus a 38.4px margin. Every stacked text element on every
   page carried the same silent extra.

   Scoped to `.brxe-text-basic`, which is Bricks' single-paragraph element. The
   rich-text element is a different class and keeps its paragraph rhythm. */
.brxe-text-basic {
  margin-block: 0;
}

/* The header is absolutely positioned against the page (see chrome.css), so the
   page needs to be its containing block. */
body { position: relative; }

/* ---- A <summary> authored as a Bricks text element ----

   The About team cards use a native <details>/<summary> disclosure, which gets
   keyboard operation and assistive-tech state for free with no JavaScript. The
   browser's default triangle is not on the board, so it goes; the board draws
   "Read more" as plain text.

   Scoped to `summary.brxe-text-basic` so it only touches a summary that was
   authored as a text element, not the footer's own `.jm-footer__summary`, which
   draws its own caret. */
summary.brxe-text-basic {
  list-style: none;
  cursor: pointer;
}
summary.brxe-text-basic::-webkit-details-marker { display: none; }

/* The marker was the only affordance, so the underline carries it instead. */
summary.brxe-text-basic { text-decoration: underline; text-underline-offset: 0.2em; }

summary.brxe-text-basic:focus-visible {
  outline: 3px solid var(--gold-500);
  outline-offset: 2px;
}

/* A link-arrow label with nowhere to go: no underline, no arrow, and muted, so it
   reads as a caption rather than an affordance. Used by jm-card when a card has a
   label but no destination yet. */
.jm-link-arrow--plain { text-decoration: none; cursor: default; }
.jm-link-arrow--plain .jm-link-arrow__label {
  text-decoration: none;
  color: var(--purple-400);
}

/* An eyebrow that leads somewhere. It keeps the eyebrow's own colour and
   tracking — only the underline says it is a link, so the section label still
   reads as a label rather than as body-copy link text. */
.jm-eyebrow-link {
  color: inherit;
  /* No underline at rest (Phil, 2026-08-31) — the eyebrow reads as a label;
     hover and keyboard focus surface the affordance. */
  text-decoration: none;
  text-underline-offset: 0.25em;
  text-decoration-thickness: 1px;
}

.jm-eyebrow-link:hover,
.jm-eyebrow-link:focus-visible { text-decoration: underline; }
.jm-eyebrow-link:focus-visible { outline: 3px solid currentColor; outline-offset: 3px; }

/* ---- Read more: progressive disclosure for long copy on narrow screens ----

   The clamp is applied by script (readmore.js), never in the static CSS, so
   without JavaScript the full paragraph shows rather than a clipped one with no
   control to open it. */

/* Scoped under #brx-content because Bricks writes an element's _display as an
   unlayered `#brxe-…` ID rule: a bare class pair loses to it, so a clamped ROW
   (About, Partnerships, The Facts) stayed flex/grid and never overflowed. The
   ID scope out-specifies it without !important. Hero copy only ever lives in
   the content wrapper. */
#brx-content .jm-readmore.is-clamped {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 5;               /* the spec board caps hero copy at 5 lines */
  overflow: hidden;
}

.jm-readmore__wrap {
  width: 100%;
  min-width: 0;                        /* behave as a flex/grid child */
}

.jm-readmore__toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;                         /* 8 */
  margin-top: 0.75rem;                 /* 12 */
  padding: 0;
  border: 0;
  background: none;
  color: var(--gold-500);
  font: 500 var(--text-body)/1.2 var(--font-sans);
  cursor: pointer;
}

.jm-readmore__toggle[hidden] { display: none; }
.jm-readmore__toggle:focus-visible { outline: 3px solid var(--gold-500); outline-offset: 3px; }

/* A caret drawn in CSS rather than the brand arrow: this points DOWN and rotates,
   and the arrow is the site's "go somewhere" mark, not its "open" mark. */
.jm-readmore__caret {
  width: 0.5rem;
  height: 0.5rem;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: translateY(-0.125rem) rotate(45deg);
  transition: transform var(--motion-base) var(--motion-ease);
}

.jm-readmore__toggle[aria-expanded="true"] .jm-readmore__caret {
  transform: translateY(0.0625rem) rotate(225deg);
}

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

/* The outline button on a dark ground (514:3290): a 1.5px Stone/100 rule with a
   Stone/100 label, where the light version is 1px Purple/500 with a Purple/600
   label (304:418). One component, two grounds. */
.jm-button--outline.jm-button--on-dark {
  --jm-button-fg: var(--stone-100);
  --jm-button-border: var(--stone-100);
  --jm-button-ring: var(--stone-100);
  border-width: 1.5px;
}

.jm-button--outline.jm-button--on-dark:hover  { --jm-button-bg: rgb(253 252 251 / 0.12); }
.jm-button--outline.jm-button--on-dark:active { --jm-button-bg: rgb(253 252 251 / 0.2); }

/* =========================================================================
   Motion: page transitions and scroll reveals.
   ========================================================================= */

/* Cross-document view transitions: REMOVED 2026-08-30. The 250ms crossfade
   laid the old page's purples over the new page's on every navigation, which
   Phil saw as a subtle shade band on load — transient, so never in a
   screenshot. The entrance animations carry the transition feel instead. */

/* Scroll reveals (reveal.js). The hidden state only ever comes from the
   script, so a no-JS page renders complete; reduced motion never enters here
   because the script no-ops, but the guard below stands anyway in case the
   class arrives some other way. */
.jm-will-reveal {
  opacity: 0;
  transform: translateY(0.75rem);      /* the default: fade up — short travel */
}
.jm-will-reveal--left  { transform: translateX(-1.25rem); }  /* slide in from the left */
.jm-will-reveal--right { transform: translateX(1.25rem); }   /* slide in from the right */
.jm-will-reveal.is-revealed {
  opacity: 1;
  transform: none;
  transition: opacity var(--motion-base) var(--motion-ease-out),
              transform var(--motion-base) var(--motion-ease-out);
  transition-delay: var(--jm-reveal-delay, 0ms);
}
/* Wide screens can afford the fuller move; small ones settle quickly (the
   long float on a single column is what read as clumsy on mobile). */
@media (min-width: 48rem) {
  .jm-will-reveal { transform: translateY(1.25rem); }
  .jm-will-reveal--left  { transform: translateX(-1.75rem); }
  .jm-will-reveal--right { transform: translateX(1.75rem); }
  .jm-will-reveal.is-revealed {
    transition-duration: var(--motion-slow), var(--motion-slow);
  }
}
@media (prefers-reduced-motion: reduce) {
  .jm-will-reveal { opacity: 1; transform: none; transition: none; }
}

/* Hero entrance animation REMOVED 2026-08-30: transform-animating the hero's
   wrapper blocks promoted them to their own GPU layers for the first half
   second, and on Phil's hardware the layerised purple rendered a hair off the
   section's purple — a shade band that flashed at load and vanished with the
   animation. He confirmed the timing twice. Heroes render instantly and
   solid; the scroll reveals, sticky bar and map scene carry the motion. */

/* ---- Back to top ---- */

.jm-top {
  position: fixed;
  right: 1.5rem;
  bottom: 1.5rem;
  z-index: 900;                        /* under the mobile menu's 1000 */
  display: flex;
  align-items: center;
  justify-content: center;
  width: max(3rem, 48px);
  height: max(3rem, 48px);
  border: 0;
  /* Square, no shadow — the site's language is hard edges on flat colour
     (Phil, 2026-08-31). */
  background: var(--purple-500);
  cursor: pointer;
  opacity: 0.92;
  transition: opacity var(--motion-fast) var(--motion-ease-out),
              transform var(--motion-fast) var(--motion-ease-out);
}
.jm-top:hover { opacity: 1; transform: translateY(-0.125rem); }
.jm-top:focus-visible { outline: 3px solid var(--gold-500); outline-offset: 2px; }
.jm-top[hidden] { display: none; }

.jm-top__chev {
  display: block;
  width: 0.625rem;
  height: 0.625rem;
  border-right: 2px solid var(--gold-500);
  border-bottom: 2px solid var(--gold-500);
  transform: rotate(225deg) translate(-0.0625rem, -0.0625rem);
}

@media (prefers-reduced-motion: reduce) {
  .jm-top { transition: none; }
  .jm-top:hover { transform: none; }
}

/* Anchor links glide instead of jumping, and land clear of the sticky bar.
   Reduced motion jumps, as it should. */
html { scroll-behavior: smooth; }
:target,
[id] { scroll-margin-top: 6rem; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

/* ---- Page transitions (Phil, 2026-08-31, fourth and FINAL pass) ----
   A simple smooth fade, and nothing else. The lesson, learned twice now
   (2026-08-30 and again today with the lift choreography): any frame where
   BOTH pages are semi-transparent flashes a bright purple blend on the
   banner pages. So the incoming page is solid from its first frame
   (animation: none) and only the OUTGOING page dissolves over it — every
   frame is new-page-solid with the old melting away. Reads exactly like an
   Astro ClientRouter fade, costs one compositor-only opacity animation,
   and cannot produce the flash. Do not add movement or a new-page
   animation here again. Reduced motion swaps instantly. */
@view-transition {
  navigation: auto;
}
::view-transition-new(root) {
  animation: none;
}
::view-transition-old(root) {
  animation: jm-page-out 180ms var(--motion-ease-out) both;
}
@keyframes jm-page-out {
  to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) { animation: none; }
}

/* =========================================================================
   Scrollbars, branded (Phil, 2026-08-31).

   scrollbar-color only — deliberately NOT ::-webkit-scrollbar, which forces
   macOS out of overlay scrollbars into permanent gutters. This inherits to
   every inner scroller, and browsers without it keep their native bars.
   ========================================================================= */

html {
  scrollbar-color: var(--purple-400) var(--stone-500);
}

/* Dark surfaces get a lighter thumb so it reads against the purple ground. */
.jm-convo,
.jm-people__dialog {
  scrollbar-color: var(--purple-300) transparent;
}
