/*
 * Fullscreen stories — the news about the reader's follows.
 *
 * Constrained to the shell width rather than the window: on a phone that is
 * genuinely full-bleed, and on desktop it keeps the mini app inside its own
 * frame instead of taking over the browser.
 *
 * Three transform layers, each owning exactly one job, because they need
 * different origins and different timing:
 *
 *   __stage                     the shared-element morph. Origin parked on the
 *                               tapped avatar.
 *   __card                      the dismiss drag. Origin its own centre, which
 *                               is where a card has to shrink towards.
 *   .story-pane / .story-card   the chain drag, as a 3D hinge. Origin on the
 *                               card's own leading or trailing edge.
 *
 * Collapsing any two of these into one element means one transform-origin
 * serving two gestures, and the dismiss would shrink towards the avatar
 * instead of towards itself.
 *
 * The perspective sits on .story-pane and not on __stage, because `perspective`
 * only reaches an element's *direct* children: declared on the stage it would
 * foreshorten __card — the dismiss layer — and never arrive at the cards two
 * levels further down. Every pane is inset:0 over the same deck, so they all
 * resolve the same perspective origin and the cards hinge in one shared space.
 */

.story-viewer {
  /*
   * Dwell per item. Not a motion token — --duration-max caps *animation*, and
   * this is reading time, which is content pacing. It lives here because it is
   * the story viewer's own pacing and nothing else in the app has an opinion.
   */
  --story-duration: 4200ms;

  /*
   * Hinge geometry. The two stacks are a pair of doors on a shared jamb: the
   * outgoing card swings shut on its trailing edge while the incoming one
   * opens on its leading edge, both driven by the single --progress the drag
   * writes onto the deck.
   *
   * Geometry, not motion — every duration and easing still comes from
   * tokens.css. These live here for the same reason --story-duration does:
   * they are this component's own shape and nothing else has an opinion.
   *
   * The perspective was picked against the real card, ~360px wide inside the
   * gutters, with the swing frozen at its midpoint. 1000px puts the headline
   * on a visibly steep slant — the last word of "Preview story 1" rides a good
   * deal higher than the first — and fans the corners out hard. 1500px flattens
   * the taper until the fold reads as a crease rather than a card standing at
   * an angle. 1200px keeps the depth and leaves the copy square enough to read
   * mid-drag, which is the whole point of a gesture the thumb is still on.
   */
  --hinge-perspective: calc(1200px * var(--scale));
  --hinge-angle: 70deg;
  --hinge-scale: 0.92;
  /*
   * Opacity lost at full swing, and the gain that compresses the fade into the
   * last quarter of it — so the card only dims once it is already near
   * edge-on, rather than greying out through the whole drag.
   */
  --hinge-fade: 0.4;
  --hinge-fade-gain: 4;

  position: fixed;
  inset: 0;
  z-index: 100;
  max-width: var(--app-max-width);
  margin-inline: auto;
}

/*
 * Translucent, so the strip stays visible behind the card. That is what makes
 * the dismiss legible: as the card shrinks away, the avatar it is heading back
 * into is already on screen. --dim rises with the drag.
 */
.story-viewer__backdrop {
  position: absolute;
  inset: 0;
  background: var(--surface-media);
  opacity: 0;
  transition: opacity var(--duration) var(--easing);
}

.story-viewer[data-open="true"] .story-viewer__backdrop {
  opacity: var(--dim, 0.62);
}

/* --- Morph layer ---------------------------------------------------------- */

.story-viewer__stage {
  position: absolute;
  inset: 0;
  opacity: 0;
  transform: scale(var(--morph-scale, 0.1));
  transform-origin: 50% 40%;
  /* The stage owns every gesture; nothing inside competes for the finger. */
  touch-action: none;
  will-change: transform, opacity;
  transition:
    transform var(--spring-duration) var(--spring),
    opacity var(--duration) var(--easing);
}

.story-viewer[data-open="true"] .story-viewer__stage {
  opacity: 1;
  transform: scale(1);
}

/* --- Drag layer ----------------------------------------------------------- */

/*
 * The visible card. It carries the background and the radius, so the morph and
 * the dismiss share one rounding: 50% while collapsed into the avatar,
 * --drag-radius while being pulled down.
 *
 * user-select / touch-callout are load-bearing here, not hygiene. Left on, the
 * browser starts a native text selection on the headline and then a drag of
 * that selection, which fires pointercancel and silently kills the swipe — on
 * the second gesture only. On iOS the callout menu would likewise outrank the
 * press-and-hold that pauses the story.
 */
.story-viewer__card {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: calc(var(--space-3) + var(--safe-top)) 0
    calc(var(--space-5) + max(var(--safe-bottom), var(--tg-chrome-inset, 0px)));
  background: var(--surface-media);
  color: var(--text-inverse);
  border-radius: 50%;
  overflow: hidden;
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  transform: translateY(var(--drag-y, 0px)) scale(var(--drag-scale, 1));
  transform-origin: center;
  will-change: transform;
  transition:
    transform var(--spring-duration) var(--spring),
    border-radius var(--spring-duration) var(--easing);
}

.story-viewer[data-open="true"] .story-viewer__card {
  border-radius: var(--drag-radius, 0px);
}

/*
 * While the finger is down everything tracks it 1:1. No transition, or the card
 * lags the thumb by a whole spring.
 */
.story-viewer[data-dragging="true"] .story-viewer__card,
.story-viewer[data-dragging="true"] .story-viewer__backdrop,
.story-viewer[data-dragging="true"] .story-deck,
.story-viewer[data-dragging="true"] .story-card {
  transition: none;
}

/*
 * Re-indexing after a committed chain: the panes jump from their committed
 * positions back to prev/current/next, and that bookkeeping must not be
 * something the user can see.
 */
.story-viewer[data-settling="true"] .story-deck,
.story-viewer[data-settling="true"] .story-card {
  transition: none;
}

/* --- Chrome --------------------------------------------------------------- */

/*
 * Fixed above the deck: the progress bar swaps to the incoming league the
 * instant the commit threshold is crossed, so it must not be riding on the
 * panes that are still mid-slide.
 */
.story-chrome {
  flex: none;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding-inline: var(--space-4);
}

.story-progress {
  display: flex;
  gap: var(--space-05);
}

/* A class beats the UA [hidden] rule, so display has to be turned off by hand. */
.story-progress[hidden] {
  display: none;
}

.story-progress__segment {
  flex: 1;
  height: 2px;
  border-radius: var(--radius-full);
  background: rgb(var(--text-inverse-rgb) / 0.28);
  overflow: hidden;
}

/*
 * scaleX rather than width: the bar animates for four seconds straight, and it
 * is the one element on screen that must not cost a layout pass per frame.
 */
.story-progress__fill {
  display: block;
  height: 100%;
  background: var(--text-inverse);
  transform: scaleX(0);
  transform-origin: left center;
}

.story-progress__segment[data-state="seen"] .story-progress__fill {
  transform: scaleX(1);
}

/*
 * The dwell timer itself. StoryViewer.js advances on this animation's end, so
 * the bar and the clock are the same object and cannot drift apart — and
 * pausing the bar is what lets a cancelled dismiss resume mid-item rather than
 * restart the story.
 */
.story-progress__segment[data-state="active"] .story-progress__fill {
  animation: story-progress var(--story-duration) linear forwards;
}

.story-viewer[data-paused="true"] .story-progress__fill {
  animation-play-state: paused;
}

@keyframes story-progress {
  to {
    transform: scaleX(1);
  }
}

.story-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

/* Mirrors HEAD_CREST_SIZE in StoryViewer.js. */
.story-head__crest {
  display: grid;
  place-items: center;
  width: calc(28px * var(--scale));
  height: calc(28px * var(--scale));
  background: rgb(var(--text-inverse-rgb) / 0.16);
  border-radius: var(--radius-full);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
}

/*
 * The crest or portrait in the disc. `.logo`'s own sunken fill and hairline
 * are stripped — the disc already has a fill — and the monogram it falls
 * back to keeps the head's own colour, so a stack with no crest reads as
 * it always did.
 */
.story-head__logo {
  background: transparent;
  border: 0;
  color: inherit;
}

.story-head__league {
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
}

.story-head__counter {
  flex: 1;
  color: rgb(var(--text-inverse-rgb) / 0.6);
  font-size: var(--text-xs);
}

.story-head__close {
  display: grid;
  place-items: center;
  width: var(--control-xs);
  height: var(--control-xs);
  border-radius: var(--radius-full);
  color: inherit;
}

/* --- Deck ----------------------------------------------------------------- */

/*
 * --cross-y is the damped remainder of a vertical wobble during a horizontal
 * drag, and --dx the same remainder the other way round during a dismiss.
 * Deliberately tiny: enough that a diagonal finger is acknowledged, never
 * enough to read as the gesture the drag did not lock to.
 */
.story-deck {
  position: relative;
  flex: 1;
  transform: translate(var(--dx, 0px), var(--cross-y, 0px));
  transition: transform var(--spring-duration) var(--spring);
}

/*
 * The hinge's frame. Every pane is inset:0 over the deck — the cards are a
 * stack, not a filmstrip, and are revealed by rotating rather than by sliding
 * out of the way.
 *
 * transform-style is left at its default `flat`. preserve-3d would hand the
 * depth ordering to the geometry, which sounds right until the two cards cross
 * at the midpoint: coplanar faces get split and sorted per fragment, and that
 * is exactly the z-fighting Telegram's WebView is worst at. Flat keeps the
 * ordering in z-index, where a single crossing point can own it.
 */
.story-pane {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  padding-inline: var(--space-4);
  perspective: var(--hinge-perspective);
}

/*
 * Rest. The current card lies flat and the neighbours are already fully swung
 * open on the edge they will come in from — a pair of closed doors either side.
 * At full swing they foreshorten to about a third of their width and sit behind
 * the current card, so nothing of them shows until the drag starts opening one.
 */
.story-pane--current {
  z-index: 2;
  --hinge-t: 0;
}

.story-pane--next {
  --hinge-origin: left;
  --hinge-sign: 1;
  --hinge-t: 1;
}

.story-pane--prev {
  --hinge-origin: right;
  --hinge-sign: -1;
  --hinge-t: 1;
}

/*
 * Under the finger. --progress is the drag's own 0..1, written once onto the
 * deck; each pane reads it as its own distance from flat, so one number drives
 * both halves of the hinge. The outgoing card leaves flat as progress rises,
 * the incoming one arrives at flat as it does.
 *
 * --dir is set at the start of the drag and never cleared. At rest a stale
 * value is inert — the current card sits at t: 0, where neither the origin nor
 * the sign can be seen — and leaving it in place is what lets a cancelled drag
 * spring back around the same edge it swung out on, rather than switching
 * hinge mid-flight.
 */
.story-viewer[data-dir="forward"] .story-pane--current {
  --hinge-origin: right;
  --hinge-sign: -1;
  --hinge-t: var(--progress, 0);
}

.story-viewer[data-dir="forward"] .story-pane--next {
  --hinge-t: calc(1 - var(--progress, 0));
}

.story-viewer[data-dir="back"] .story-pane--current {
  --hinge-origin: left;
  --hinge-sign: 1;
  --hinge-t: var(--progress, 0);
}

.story-viewer[data-dir="back"] .story-pane--prev {
  --hinge-t: calc(1 - var(--progress, 0));
}

/*
 * Depth ordering. Cover flow reads correctly only while the card nearer the
 * viewer paints last, and which card that is changes at the crossing — so the
 * swap happens there, at the midpoint, where the two are mirror images of each
 * other and it cannot be seen. JS owns the crossing; this is only its result.
 */
.story-viewer[data-front="incoming"] .story-pane--current {
  z-index: 1;
}

.story-viewer[data-front="incoming"] .story-pane--next,
.story-viewer[data-front="incoming"] .story-pane--prev {
  z-index: 2;
}

/*
 * Committed. --hinge-t is pinned to its end value rather than left reading
 * --progress, because the inline --progress the drag writes onto the deck
 * would outrank anything this rule could inherit. Same specificity as the
 * --dir rules above, so source order is what carries it — these must stay
 * below them.
 */
.story-viewer[data-committing="forward"] .story-pane--current,
.story-viewer[data-committing="back"] .story-pane--current {
  --hinge-t: 1;
}

.story-viewer[data-committing="forward"] .story-pane--next,
.story-viewer[data-committing="back"] .story-pane--prev {
  --hinge-t: 0;
}

/* --- Card content --------------------------------------------------------- */

/*
 * The hinged leaf. It carries the whole chain transform: rotation about the
 * edge it is hinged on, and a scale away from the viewer as it swings.
 *
 * --hinge-sign folds the two directions into one rule. Going forward, the
 * outgoing card hinges on its right edge at -1 and the incoming one on its
 * left at +1; going back, the pair swap. The sign is the only thing that
 * changes, so the transform below never has to know which way the finger went.
 *
 * The background is opaque on purpose, and layered rather than picked: the
 * tint is the same alpha step of --text-inverse-rgb the crest and the progress
 * track already use, but composited over --surface-inverse instead of over
 * whatever happens to be behind it. A translucent card would show the other
 * card through itself at every angle of the swing, which reads as a bug rather
 * than as depth.
 */
.story-card {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: var(--space-2);
  padding: var(--space-5);
  background:
    linear-gradient(
      rgb(var(--text-inverse-rgb) / 0.06),
      rgb(var(--text-inverse-rgb) / 0.06)
    ),
    var(--surface-media);
  border: 1px solid rgb(var(--text-inverse-rgb) / 0.12);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-floating);
  /* Clips the picture to the radius; nothing on the card needs to overflow. */
  overflow: hidden;
  transform-origin: var(--hinge-origin, center) center;
  transform: rotateY(calc(var(--hinge-sign, 0) * var(--hinge-angle) * var(--hinge-t, 0)))
    scale(calc(1 - (1 - var(--hinge-scale)) * var(--hinge-t, 0)));
  /*
   * The fade, remapped so it only bites over the last quarter of the swing —
   * anything before that lands above 1 and is clamped away.
   */
  --hinge-fade-t: calc(var(--hinge-t, 0) * var(--hinge-fade-gain) - (var(--hinge-fade-gain) - 1));
  opacity: clamp(
    calc(1 - var(--hinge-fade)),
    calc(1 - var(--hinge-fade) * var(--hinge-fade-t)),
    1
  );
  will-change: transform, opacity;
  transition:
    transform var(--spring-duration) var(--spring),
    opacity var(--duration) var(--easing);
}

/*
 * The flight home. Ease, never the spring: --spring overshoots by design, and
 * a rotation that overshoots 0deg swings the arriving card visibly past flat
 * and back — a wobble on a door that has already shut. --duration-slow is the
 * app's non-spring length at the same 320ms, so a committed chain still lands
 * in step with everything else that travels this far.
 *
 * A cancelled drag keeps the spring above. That one is a snap back to rest,
 * which is exactly what an overshoot is for.
 */
.story-viewer[data-committing] .story-card {
  transition:
    transform var(--duration-slow) var(--easing),
    opacity var(--duration-slow) var(--easing);
}

/*
 * A tap is a cut, not a glide. The copy has already been swapped by the time
 * this runs; it only softens the edge of the swap. --duration is the app's fade
 * token — anything springy here would make prev/next feel slower than the drag
 * it sits alongside.
 */
.story-pane[data-cut] .story-card {
  animation: story-cut var(--duration) var(--easing);
}

@keyframes story-cut {
  from {
    opacity: 0;
  }
}

/*
 * The picture, behind everything, darkened towards the copy by the scrim
 * below so a light photograph does not take the headline with it.
 *
 * Publishers' feeds carry anything from a 140px thumbnail to a 2880px still,
 * so the card does not commit to one treatment. StoryViewer measures the
 * picture once it has loaded and writes the pane's `data-fit`:
 *
 *   cover    — big enough to fill the card without being stretched past
 *              COVER_MAX_STRETCH: the full-bleed card, as before.
 *   contain  — drawn at its own size, centred in the room above the copy,
 *              over a blurred copy of itself (`__backdrop`, same URL) that
 *              fills the card. `--picture-w` caps its width at the same
 *              stretch, so a thumbnail is a crisp small picture on a soft
 *              field rather than mush edge to edge.
 *
 * Until the fit is decided nothing is shown and the picture then fades in,
 * so it never lands in one shape and jumps to the other. A story without a
 * picture draws no image at all — the card's own dark fill is the design,
 * and the picture is the exception laid over it — so a stack that mixes
 * the two does not jump between designs.
 */
.story-card__picture {
  position: absolute;
  inset: 0;
}

.story-card__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity var(--duration) var(--easing);
}

.story-pane[data-fit="cover"] .story-card__image {
  /* Quietened rather than shown raw: the copy is the story, the picture is its setting. */
  opacity: 0.82;
}

/*
 * The soft field behind a contained picture. Oversized and blurred, so the
 * blur's transparent fringe falls outside the card's clip rather than along
 * its edges; explicit width and height because an absolutely positioned
 * image ignores inset for its size. Shown only with a picture over it,
 * which is also the only time it has a URL.
 */
.story-card__backdrop {
  position: absolute;
  inset: calc(-1 * var(--space-6));
  width: calc(100% + 2 * var(--space-6));
  height: calc(100% + 2 * var(--space-6));
  object-fit: cover;
  filter: blur(28px) saturate(1.2);
  opacity: 0;
  transition: opacity var(--duration) var(--easing);
}

.story-pane[data-fit="contain"] .story-card__backdrop {
  opacity: 0.55;
}

/*
 * Contained: the picture's frame joins the card's flow and takes the room
 * above the copy; the picture sits centred in it at its own ratio, no wider
 * than the frame or its cap, no taller than the room. Positioned, so it
 * paints above the backdrop the way the copy does.
 */
.story-pane[data-fit="contain"] .story-card__picture {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.story-pane[data-fit="contain"] .story-card__image {
  width: 100%;
  height: auto;
  max-width: var(--picture-w, none);
  max-height: 100%;
  object-fit: contain;
  border-radius: var(--radius-md);
  opacity: 1;
}

.story-pane[data-image="false"] .story-card__picture,
.story-pane[data-image="false"] .story-card__backdrop {
  display: none;
}

/*
 * The scrim and the copy share one layer, positioned so both paint above the
 * picture. The gradient is the card's own colour fading in from the middle,
 * so the top of the picture shows clear and the text sits on solid ground.
 */
.story-card__copy {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  /* Bleeds the scrim out to the card's edges, then puts the padding back. */
  margin: calc(-1 * var(--space-5));
  margin-top: auto;
  padding: var(--space-5);
  padding-top: calc(var(--space-5) * 3);
}

.story-pane[data-image="true"] .story-card__copy {
  background: linear-gradient(
    to bottom,
    rgb(var(--surface-media-rgb) / 0) 0%,
    rgb(var(--surface-media-rgb) / 0.78) 38%,
    rgb(var(--surface-media-rgb) / 0.94) 100%
  );
}

/* Publisher and age on one line, the pill leading. */
.story-card__meta {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
}

.story-card__eyebrow {
  flex: none;
  padding: var(--space-05) var(--space-2);
  background: var(--text-inverse);
  border-radius: var(--radius-full);
  color: var(--surface-media);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wider);
}

.story-card__time {
  color: rgb(var(--text-inverse-rgb) / 0.6);
  font-size: var(--text-xs);
  white-space: nowrap;
}

.story-card__headline {
  font-size: var(--text-lg);
  font-weight: var(--weight-semibold);
  line-height: var(--leading-tight);
}

.story-card__body {
  color: rgb(var(--text-inverse-rgb) / 0.72);
  font-size: var(--text-sm);
  /* The summary is a lead, not the article: four lines, then the link. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 4;
  overflow: hidden;
}

/*
 * The way through to the article. A light pill on the dark card, sized as
 * a control (44px tall) because it is the one thing on the card that takes
 * a tap of its own — the stage's pointerdown leaves it alone by class.
 */
.story-card__link {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  min-height: var(--tap-floor);
  margin-top: var(--space-2);
  padding: 0 var(--space-4);
  background: rgb(var(--text-inverse-rgb) / 0.14);
  border: 1px solid rgb(var(--text-inverse-rgb) / 0.18);
  border-radius: var(--radius-full);
  color: var(--text-inverse);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
}

.story-card__link:focus-visible {
  border-radius: var(--radius-full);
}

/* --- PrezzBox frames ------------------------------------------------------ */

/*
 * A frame of a PrezzBox story set (the Catch-Up room's circles): the chart
 * PrezzBox draws, framed, in the room above the copy — a white graphic on
 * the dark card, the way a post shared to a story sits on its ground. The
 * copy is then under it rather than laid over a picture.
 *
 * A chart is read, not glanced at, so a frame dwells twice as long as an
 * article's headline. Hold-to-pause still stops it.
 */
.story-viewer[data-kind="prezzbox"] {
  --story-duration: 8400ms;
}

.story-card__embed {
  display: none;
}

.story-pane[data-kind="prezzbox"] .story-card {
  justify-content: flex-start;
}

.story-pane[data-kind="prezzbox"] .story-card__embed {
  position: relative;
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  /* So the frame can size itself against the room's height as well as its
     width — see below. */
  container-type: size;
}

/*
 * The frame keeps the publication's shape inside whatever room the copy
 * leaves — contained, the way a picture is: as wide as the room allows
 * unless that would make it taller than the room, in which case the height
 * leads. pointer-events off — the stage is the gesture surface, and an
 * iframe under the thumb would take the swipe.
 */
.story-card__frame {
  display: block;
  width: 100%;
  max-height: 100%;
  aspect-ratio: var(--frame-aspect, 0.8);
  border: 0;
  border-radius: var(--radius-md);
  background: var(--surface);
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--duration) var(--easing);
}

@supports (width: 1cqh) {
  .story-card__frame {
    width: min(100cqw, calc(100cqh * var(--frame-aspect, 0.8)));
    max-height: none;
  }
}

.story-pane[data-embed-ready] .story-card__frame {
  opacity: 1;
}

/* The copy sits under the frame on the card's own ground. */
.story-pane[data-kind="prezzbox"] .story-card__copy {
  margin-top: 0;
  padding-top: var(--space-4);
}

/* The page behind must not scroll under the overlay. */
body[data-story-open] {
  overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
  .story-pane[data-cut] .story-card {
    animation: none;
  }

  /*
   * The morph collapses to a cross-fade. Neutralising the scale rather than
   * the whole transform matters: --drag-y shares that property, and
   * drag-to-dismiss still has to track the finger here.
   */
  .story-viewer__stage {
    --morph-scale: 1;
  }

  /*
   * The hinge is finger-tracked, so zeroing the durations does not reach it —
   * a 70deg rotation would still play out under the thumb, which is the shape
   * of motion this media query exists to remove. Flattened back to the
   * filmstrip it replaced, reusing the same two variables: --hinge-sign
   * already encodes which way each card travels, and --hinge-t how far.
   */
  .story-viewer {
    --hinge-angle: 0deg;
    --hinge-scale: 1;
    --hinge-fade: 0;
  }

  .story-card {
    transform: translateX(calc(var(--hinge-sign, 0) * 100% * var(--hinge-t, 0)));
  }
}
