/* ============================================================================
   base.css — reset, font, page shell, aurora, shared primitives.
   ========================================================================== */

@font-face {
  font-family: "Newsreader";
  src: url("../assets/fonts/newsreader.woff2") format("woff2");
  font-weight: 340 660;
  font-display: swap;
  font-style: normal;
}

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
}

html {
  -webkit-text-size-adjust: 100%;
  text-underline-offset: 0.18em;
  /* Kill the translucent blue box Chrome/Safari paint over anything tappable.
     The property inherits, so setting it here covers every descendant. Every
     tappable thing earns its feedback back through an :active rule instead —
     see the press states in components.css. */
  -webkit-tap-highlight-color: transparent;
  /* No 300ms click delay, and no double-tap-to-zoom swallowing taps. */
  touch-action: manipulation;

  /* — the scrollbar ---------------------------------------------------------
     Three separate problems, one block.

     `overflow-y: scroll` is the load-bearing one. Screens here are wildly
     different heights — a fourteen-card feed, then one short post — and a
     scrollbar that comes and goes takes fifteen pixels of page width with it
     every time. Every line of every card re-wraps and the whole layout slides
     sideways and back, twice per visit to a post. Reserving the track always
     costs those fifteen pixels once and never moves them again.

     `scrollbar-gutter: stable` says the same thing to browsers that would
     rather hear it that way; where both apply they agree.

     `overflow-x` moved up here from body, and it had to. With overflow on html
     left at `visible`, the body's value propagates to the viewport — which is
     what used to make this work at all. Set overflow on html and that
     propagation stops, body becomes a scroll container of its own, and
     `position: sticky` on the header starts measuring itself against *that*
     instead of the page: the header scrolls straight off the top and never
     comes back. Both properties belong to whichever element is the scroller,
     and now that's this one.

     Then the looks. `thin` + a neutral thumb from the same family as --border,
     on a track that isn't drawn at all. A scrollbar is not part of what anyone
     came here to read. */
  overflow-x: hidden;
  overflow-y: scroll;
  scrollbar-gutter: stable;
  scrollbar-width: thin;
  scrollbar-color: var(--scrollbar-thumb) transparent;
}

/* While a view transition runs the page is a pair of still images, and the
   scrollbar — which is browser chrome, outside the transition — is the one
   thing still moving: it snaps to the top because the swap scrolled there, and
   changes size because the next screen is a different height. Taking the thumb
   out for those two hundred milliseconds and putting it back, already correct,
   is the whole fix. It reflows nothing: the track above is reserved either
   way. Set by tryTransition() in js/transitions.js. */
html.is-transitioning {
  scrollbar-color: transparent transparent;
}

/* Safari only learned `scrollbar-color` in 18.2, and older WebKit needs the
   pseudo-elements instead. Behind @supports because Chrome honours whichever
   it is given and would otherwise take the wide legacy scrollbar over the thin
   modern one. The transparent border plus background-clip is what insets the
   thumb from the edge of the track without drawing the track. */
@supports not (scrollbar-color: transparent transparent) {
  ::-webkit-scrollbar {
    width: 12px;
    height: 12px;
  }
  ::-webkit-scrollbar-track,
  ::-webkit-scrollbar-corner {
    background: transparent;
  }
  ::-webkit-scrollbar-thumb {
    background-color: var(--scrollbar-thumb);
    background-clip: padding-box;
    border: 4px solid transparent;
    border-radius: var(--r-pill);
  }
  ::-webkit-scrollbar-thumb:hover {
    background-color: var(--scrollbar-thumb-hover);
  }
  html.is-transitioning ::-webkit-scrollbar-thumb {
    background-color: transparent;
  }
}

body {
  min-height: 100dvh;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-ui);
  font-size: var(--fs-body);
  line-height: var(--lh-ui);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* overflow-x lives on html now — see the scrollbar note above. Leaving a
     copy here would hand body its own scroll container and break the sticky
     header. */
}

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

/* the [hidden] attribute must win over component display rules */
[hidden] {
  display: none !important;
}

button,
input,
textarea,
select {
  font: inherit;
  color: inherit;
}

a {
  color: var(--link);
  text-decoration-color: color-mix(in srgb, var(--link) 45%, transparent);
}
@media (hover: hover) {
  a:hover {
    text-decoration-color: currentColor;
  }
}
a:active {
  text-decoration-color: currentColor;
}

h1,
h2,
h3 {
  font-family: var(--font-serif);
  font-weight: 460;
  line-height: var(--lh-tight);
  letter-spacing: 0.005em;
  /* `balance`, not the `pretty` prose gets below. A heading is short enough
     for the browser to even out every line of it, and what that prevents —
     one word left stranded on a line of its own — is what a wrapped card title
     does on a phone almost every time. `pretty` only guards the last line,
     which is the whole problem in a two-line headline. Nothing here is deep
     enough to hit the four-line cap the spec puts on balancing. */
  text-wrap: balance;
}

:where(p, li) {
  text-wrap: pretty;
}

/* — focus: restyled, never removed ---------------------------------------- */
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: 3px;
}
:focus:not(:focus-visible) {
  outline: none;
}

::selection {
  background: var(--accent-soft);
}

.visually-hidden {
  position: absolute !important;
  width: 1px;
  height: 1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* — page shell ----------------------------------------------------------------- */
#view {
  display: block;
  width: 100%;
  max-width: var(--page-max);
  margin-inline: auto;
  padding: var(--s-6) var(--s-4) var(--s-9);
  padding-inline: max(var(--s-4), env(safe-area-inset-left))
    max(var(--s-4), env(safe-area-inset-right));
  padding-bottom: calc(var(--s-9) + env(safe-area-inset-bottom));
}
#view:focus {
  outline: none;
}

/* Route cross-fade — opacity only, short. */
.route-enter {
  animation: route-in var(--t-mid) var(--ease) both;
}
@keyframes route-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* — aurora: two slow blooms, well behind the text ------------------------- */
.aurora {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  background:
    radial-gradient(
      120% 80% at 50% -20%,
      var(--bg-2),
      transparent 60%
    ),
    var(--bg);
  opacity: var(--aurora-alpha, 1);
}
.aurora-bloom {
  position: absolute;
  width: 70vmax;
  height: 70vmax;
  border-radius: 50%;
  filter: blur(60px);
  will-change: transform;
}
.aurora-bloom--warm {
  top: -30vmax;
  left: -15vmax;
  background: radial-gradient(circle, var(--aurora-warm), transparent 68%);
  animation: drift-a 34s var(--ease) infinite alternate;
}
.aurora-bloom--cool {
  bottom: -34vmax;
  right: -18vmax;
  background: radial-gradient(circle, var(--aurora-cool), transparent 68%);
  animation: drift-b 42s var(--ease) infinite alternate;
}
@keyframes drift-a {
  to {
    transform: translate3d(8vmax, 6vmax, 0) scale(1.12);
  }
}
@keyframes drift-b {
  to {
    transform: translate3d(-7vmax, -5vmax, 0) scale(1.08);
  }
}

/* On a phone the aurora is two 70vmax layers under a 60px blur, re-rastering
   as they drift — the single most expensive thing on the page and the least
   load-bearing. Below 640px keep one small, static bloom: the warmth survives,
   the compositing cost doesn't. */
@media (max-width: 640px) {
  .aurora-bloom {
    width: 40vmax;
    height: 40vmax;
    filter: blur(40px);
    animation: none;
    will-change: auto;
  }
  .aurora-bloom--cool {
    display: none;
  }
}

/* — reduced motion: keep state changes, drop the ambient stuff ----------- */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-delay: 0s !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .aurora-bloom {
    animation: none;
  }
}

/* — view transitions --------------------------------------------------------
   The one orchestrated movement in the app: the title you tapped becomes the
   heading of the screen you land on. js/transitions.js decides when that's
   possible and hands the name to the two elements involved.

   Note that prefers-reduced-motion is NOT handled here. The reduced-motion
   block above can't reach ::view-transition-* — they aren't descendants of any
   element it matches — and the API runs its own default cross-fade regardless.
   So the decision is made in JS, before the transition starts, and under
   reduced motion no transition is started at all.
   -------------------------------------------------------------------------- */

/* The chrome isn't what changed, so it shouldn't cross-fade with the page.
   Giving it its own name lifts it out of the root snapshot and leaves it
   exactly where it is while the content underneath moves. */
.site-header {
  view-transition-name: site-header;
}

/* Kept deliberately short. While a transition runs, the browser replaces the
   live document with its snapshots and the page does not take input — so the
   duration here is also how long the app ignores a tap. 320ms was long enough
   to swallow one. These are the shortest values that still read as movement
   rather than a cut. */
::view-transition-group(root) {
  animation-duration: 180ms;
  animation-timing-function: var(--ease);
}

/* The default page change was the browser's own, and the browser's own is a
   symmetric cross-fade in `plus-lighter`: both screens at half strength, added
   together, for the whole duration. On a dark page that does not read as one
   screen becoming another. It reads as a third screen — the post's heading and
   byline glowing through a feed full of cards, brighter than either, for a
   tenth of a second. That ghost is the "something else in between" the
   recording catches on the way back to the feed, and it was never a bug in
   this app's code so much as a default nobody had overruled.

   Overruled here, in two parts.

   `mix-blend-mode: normal` stops the addition. Two half-strength images
   blended normally are still two images, but they no longer light each other
   up, which is most of what made the ghost legible.

   Then only one of them moves. The outgoing screen holds still at full
   opacity for the whole transition and the incoming one is painted over it —
   so the page background is never uncovered (a gap between the two would flash
   the bare canvas, which on this palette is a black frame), and the blend is
   always new-over-solid rather than half-over-half. The fade is front-loaded:
   --ease puts the incoming screen past three quarters opaque inside the first
   sixty milliseconds, so the window where both are readable is short enough to
   be movement rather than an image. The rise is four pixels — enough that the
   eye reads a direction and follows it, small enough that the sliver of the
   old screen it uncovers at the top is a hairline for two frames. */
::view-transition-old(root),
::view-transition-new(root) {
  mix-blend-mode: normal;
}
::view-transition-old(root) {
  animation: none;
  opacity: 1;
}
::view-transition-new(root) {
  animation: screen-arrive 180ms var(--ease) both;
}
@keyframes screen-arrive {
  from {
    opacity: 0;
    transform: translateY(4px);
  }
  /* Opaque at two fifths of the way through — seventy milliseconds — while the
     rise carries on to the end. Splitting the two is what keeps the window
     where both screens are legible down to something the eye reads as a change
     of state rather than as a picture of two pages at once. The remaining
     travel happens against a screen that is already solid. */
  40% {
    opacity: 1;
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* The header is its own group, so it is not in the root snapshot above, and it
   changes between screens: the search belongs to the feed and to nothing else.
   On a phone that costs it a whole row — two rows on the feed, one everywhere
   else — and the group animates the forty pixels.

   It does *not* get the recipe above, and the reason is worth writing down. Up
   there the outgoing screen can be left where it is because the incoming one
   is opaque and covers it. This bar is glass: --glass-bg is 55% alpha, so its
   snapshot is mostly transparent, and an opaque new image painted over a held
   old one hides nothing at all. The search pill sat there through the whole
   transition and the account cluster printed twice, half a word apart.

   So both ends move, which is the one honest way to dissolve something
   see-through. The blend stays normal — `plus-lighter` on a blurred bar
   mid-resize was a white flare across the top of the screen — and --ease
   front-loads it hard enough that the old bar is under two percent by seventy
   milliseconds. Its opacity and the new one's add back up to about the alpha
   the bar is supposed to have, so the glass never visibly thins. */
::view-transition-old(site-header),
::view-transition-new(site-header) {
  mix-blend-mode: normal;
}
::view-transition-old(site-header) {
  animation: chrome-leave 180ms var(--ease) both;
}
::view-transition-new(site-header) {
  animation: chrome-arrive 180ms var(--ease) both;
}
@keyframes chrome-leave {
  to {
    opacity: 0;
  }
}
@keyframes chrome-arrive {
  from {
    opacity: 0;
  }
}
::view-transition-group(site-header) {
  animation-duration: 180ms;
  animation-timing-function: var(--ease);
}

/* The morphing title outlasts the page fade slightly, so it stays legible the
   whole way across, and paints above the content it travels over. */
::view-transition-group(post-title) {
  animation-duration: 240ms;
  animation-timing-function: var(--ease);
  z-index: 2;
}
/* The two snapshots are the same words at two different sizes, anchored to the
   same corner. object-fit:none keeps each at its natural size rather than
   letting the group stretch it, which is what stops the letters distorting as
   the group grows.
   
   What they must never do is both be solid at once. Holding both fully opaque
   — which is what `animation: none` here used to do — doesn't read as one
   title travelling; it reads as two copies of the title stacked on top of each
   other, a small one and a large one, for the whole 240ms. Going back it was
   the post's heading sitting opaque over an already-drawn feed.
   
   An even cross-fade doesn't fix it either: at the midpoint that's the same
   two copies at half strength, which is the same ghost with less contrast. So
   they hand over instead — the old one is most of the way out before the new
   one is meaningfully in, and the group's transform carries the position
   across underneath. The overlap is short enough to read as motion rather than
   as two things. */
::view-transition-old(post-title),
::view-transition-new(post-title) {
  mix-blend-mode: normal;
  height: 100%;
  object-fit: none;
  object-position: top left;
}
::view-transition-old(post-title) {
  animation: title-hand-off-out 120ms var(--ease) both;
}
::view-transition-new(post-title) {
  animation: title-hand-off-in 180ms var(--ease) 60ms both;
}
@keyframes title-hand-off-out {
  to {
    opacity: 0;
  }
}
@keyframes title-hand-off-in {
  from {
    opacity: 0;
  }
}
