/* tehran.university — TV rendition
   Sized for a 10-foot viewing distance. All sizing is in vw/vh rather than px
   so the page is immune to the CSS-viewport width a TV browser happens to
   report: an Android TV may claim 960px on a 4K panel, which would drop a
   px-and-breakpoint layout into its phone styles. */

:root {
  --safe-x: 4vw;          /* overscan guard — many sets crop ~5% of the panel */
  --safe-y: 4.2vh;
  /* Near-black rather than the warm #16140f used elsewhere on the site.
     TVs run far brighter than a monitor and many lift shadow detail, so a
     dark brown reads as washed grey across the room. #070706 keeps a trace
     of warmth without being a lifted black, and on OLED it is close enough
     to pixel-off to cut power and halo. Every accent gains contrast against
     it: headline 16.66:1, deck 8.93:1, chrome 4.60:1. */
  --ink: #efe9dc; --ink-soft: #b3ac9d; --paper: #070706; --paper-2: #1a1815;
  --line: #2e2a22; --teal: #3fb8a8; --teal-deep: #2b8a7d; --clay: #d97a56;
  --chrome: #7f7869;
  --bar-h: 6px;   /* whole pixels: see .bar */
  --hdr-h: 12.1vh;  /* header height; the stage offset derives from it */
  --fs: 1;        /* published font scale; multiplies the type sizes */
  --fade: 600ms;
  --fx-ease: cubic-bezier(.4, 0, .2, 1);  /* how a transition accelerates */
  --fx-dist: 4vw;                          /* how far travelling effects move */
  --imgfx-ease: cubic-bezier(.4, 0, .2, 1); /* easing for the image effects */
}
:root[data-theme="light"] {
  --ink: #1c1a17; --ink-soft: #55504a; --paper: #f6f2e9; --paper-2: #fffdf8;
  --line: #e2dccd; --teal: #0f6e63; --teal-deep: #0a4f47; --clay: #b45a3c;
  --chrome: #8b8478;
}

* { box-sizing: border-box; }

/* ---------- animated gradient backdrop ----------
   Only transform is animated. Animating background-position or the gradient
   stops themselves repaints the whole screen every frame, which a TV-class
   GPU will not hold at 60fps; translating an oversized layer is handled by
   the compositor and costs almost nothing.
   The layer is 200% wide and repeats its first colour at 100%, so translating
   by -50% lands exactly where it started and the loop has no visible seam. */
.bg { position: fixed; inset: 0; z-index: -2; overflow: hidden; background: var(--paper); }
/* One set per palette. A re-randomise adds a set at opacity 0 and fades the
   previous one out, so the change is never a cut. */
.bg-set { position: absolute; inset: 0; opacity: 1; transition: opacity 3.5s ease; }
.bg-layer {
  /* left:0 with width:200%, translated by -50% of its own width (one full
     container width), keeps 0–100% covered for the whole cycle. At left:-50%
     it ended up spanning -150%..50% and the right half of the screen fell
     back to the base colour. */
  position: absolute; top: -25%; left: 0; width: 200%; height: 150%;
  will-change: transform;
  animation: bgdrift var(--bg-dur, 90s) linear infinite;
  animation-direction: var(--bg-dir, normal);
}
/* Static: the gradient is still drawn, it simply does not move. */
.bg-layer.still, .bg-layer.still ~ .bg-glow { animation: none; }
/* Radial and conic are anchored to a point, so sliding them just moves the
   anchor off-screen. Each gets its own motion instead.
   Sizing matters: a layer rotating inside a 16:9 viewport must cover the
   diagonal at every angle — 115% of the height was 89 points short, and the
   bare corners showed the near-black base as black wedges. A centred square of
   200vmax always exceeds the diagonal, whatever the aspect ratio. */
.bg-layer.spun, .bg-layer.breathe {
  top: 50%; left: 50%; width: 200vmax; height: 200vmax;
  margin: -100vmax 0 0 -100vmax;
}
/* Conic rotates — the colour wheel turning is the point. */
.bg-layer.spun { animation-name: bgspin; }
@keyframes bgspin {
  from { transform: rotate(0deg); }
  to   { transform: rotate(360deg); }
}
/* Radial is rotationally symmetric, so rotating it is invisible work. It
   breathes instead, and alternates so the scale never snaps at the loop. */
.bg-layer.breathe { animation-name: bgbreathe; animation-direction: alternate; }
@keyframes bgbreathe {
  from { transform: scale(1) translate(0, 0); }
  to   { transform: scale(1.35) translate(-3%, 2%); }
}
@keyframes bgdrift {
  from { transform: translateX(0) rotate(0deg); }
  to   { transform: translateX(-50%) rotate(0deg); }
}
/* A second, slower layer at low opacity keeps the motion from reading as a
   single sliding band — the reference footage always has two rates at once. */
body.no-bgglow .bg-glow { display: none; }
.bg-glow {
  position: absolute; inset: -20%; z-index: -1; opacity: .55; will-change: transform;
  animation: bgpulse calc(var(--bg-dur, 90s) * 0.7) ease-in-out infinite alternate;
}
@keyframes bgpulse {
  from { transform: scale(1) translate(0, 0); }
  to   { transform: scale(1.25) translate(4%, -3%); }
}
/* Vector overlay: above the gradient, still behind the content. Sized to the
   diagonal for the same reason the rotating gradient layers are. */
/* Fill mode: a square larger than the viewport diagonal, so rotation never
   exposes a corner. Position moves the whole field. */
.bg-ov {
  position: absolute; top: var(--ov-y, 50%); left: var(--ov-x, 50%);
  width: 200vmax; height: 200vmax; margin: -100vmax 0 0 -100vmax;
  transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1));
  transform-origin: center; pointer-events: none;
}
/* Place mode: a bounded box holding the whole motif, so it becomes an object
   that can be put somewhere rather than a field that covers everything. */
.bg-ov.ov-place {
  width: var(--ov-size, 55vmin); height: var(--ov-size, 55vmin);
  margin: calc(var(--ov-size, 55vmin) / -2) 0 0 calc(var(--ov-size, 55vmin) / -2);
}
.bg-ov svg { width: 100%; height: 100%; display: block; }
/* A referenced file is drawn as a background image, which gives repeat and
   sizing for free and keeps the file entirely out of the document. */
.bg-ov .bg-svg-img { width: 100%; height: 100%; display: block; }
/* Marks a layer whose artwork is a referenced file rather than a drawn motif.
   The stroke and gradient controls do not apply to it, so nothing here tries
   to paint it — the rule exists so the class is styled rather than dangling. */
.bg-ov.ov-custom { --ov-drawn: 0; }
/* Overlay motions. All transform/opacity only, so none of them costs layout.
   --ov-rot and --ov-scale are the published resting values; every keyframe
   carries them so a motion never discards the operator's rotation or size. */
.bg-ov.ov-spin   { animation: ovspin   var(--ov-dur, 120s) linear infinite; }
.bg-ov.ov-drift  { animation: ovdrift  var(--ov-dur, 120s) ease-in-out infinite alternate; }
.bg-ov.ov-pulse  { animation: ovpulse  var(--ov-dur, 120s) ease-in-out infinite alternate; }
.bg-ov.ov-fade   { animation: ovfade   var(--ov-dur, 120s) ease-in-out infinite alternate; }
.bg-ov.ov-zoomin { animation: ovzoomin var(--ov-dur, 120s) ease-in-out infinite alternate; }
.bg-ov.ov-zoomout{ animation: ovzoomout var(--ov-dur, 120s) ease-in-out infinite alternate; }
/* Travel: crosses the screen and re-enters, direction set by --ov-tx/--ov-ty. */
.bg-ov.ov-travel { animation: ovtravel var(--ov-dur, 120s) linear infinite; }
/* Travel used to run 0 -> offset and snap back, which reads as the motif
   stopping and restarting. Two ways out, chosen in tv.js:
   - a tiled layer can move by exactly one tile period, so the frame after the
     loop is identical to the frame before it and nothing is seen to reset;
   - anything else eases back and forth instead, which never snaps. */
.bg-ov.ov-travel.seamless { animation-timing-function: linear; }
.bg-ov.ov-travel.pingpong {
  animation-direction: alternate;
  animation-timing-function: ease-in-out;
}
/* Truly continuous travel: the motif is drawn twice, end to end along the
   direction of travel, and the pair slides by exactly one copy. The frame after
   the loop is pixel-identical to the frame before it, so there is no reset to
   see — this works for any motif, tiled or not, unlike the tile-period trick
   which needs a fine repeat. The wrap keeps rotation and scale; only the track
   moves, so the two never fight. */
.bg-ov.ov-travel.endless { animation: none; }
.bg-ov .ov-track { position: absolute; inset: 0; display: flex; will-change: transform; }
.bg-ov .ov-track > * { flex: none; }
.bg-ov.ov-travel.endless.horiz .ov-track {
  width: 200%; height: 100%; flex-direction: row;
  animation: ovtrackx var(--ov-dur, 120s) linear infinite;
}
.bg-ov.ov-travel.endless.horiz .ov-track > * { width: 50%; height: 100%; }
.bg-ov.ov-travel.endless.vert .ov-track {
  width: 100%; height: 200%; flex-direction: column;
  animation: ovtracky var(--ov-dur, 120s) linear infinite;
}
.bg-ov.ov-travel.endless.vert .ov-track > * { width: 100%; height: 50%; }
.bg-ov.ov-travel.endless.rev .ov-track { animation-direction: reverse; }
@keyframes ovtrackx { from { transform: translateX(0); } to { transform: translateX(-50%); } }
@keyframes ovtracky { from { transform: translateY(0); } to { transform: translateY(-50%); } }
@media (prefers-reduced-motion: reduce) { .bg-ov .ov-track { animation: none; } }
/* Enter from one edge and leave by another, with a pause visible in between. */
.bg-ov.ov-pass   { animation: ovpass   var(--ov-dur, 120s) ease-in-out infinite alternate; }

@keyframes ovspin {
  from { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1)); }
  to   { transform: rotate(calc(var(--ov-rot, 0deg) + 360deg)) scale(var(--ov-scale, 1)); }
}
@keyframes ovdrift {
  from { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1)) translate(0, 0); }
  to   { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1)) translate(3%, -2%); }
}
@keyframes ovpulse {
  from { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1)); }
  to   { transform: rotate(var(--ov-rot, 0deg)) scale(calc(var(--ov-scale, 1) * 1.18)); }
}
/* Continuity rules for every motion.

   Coverage: the layer is a 200vmax square, and the worst case (a square
   viewport) needs it to reach 70.7vmax from centre. So no motion may scale
   below 0.707 or translate more than 29.3vmax, or bare background shows at an
   edge. Zoom used to reach 0.55 and did exactly that.

   Presence: nothing fades to zero. An overlay that disappears entirely leaves
   the operator watching a blank stretch and wondering if it broke. Fades now
   breathe between a floor and full, so the motif is always on screen. */
@keyframes ovfade {
  0%, 100% { opacity: calc(var(--ov-op, .18) * 0.4); }
  50%      { opacity: var(--ov-op, .18); }
}
@keyframes ovzoomin {
  from { transform: rotate(var(--ov-rot, 0deg)) scale(calc(var(--ov-scale, 1) * .78)); }
  to   { transform: rotate(var(--ov-rot, 0deg)) scale(calc(var(--ov-scale, 1) * 1.45)); }
}
@keyframes ovzoomout {
  from { transform: rotate(var(--ov-rot, 0deg)) scale(calc(var(--ov-scale, 1) * 1.45)); }
  to   { transform: rotate(var(--ov-rot, 0deg)) scale(calc(var(--ov-scale, 1) * .78)); }
}
/* Pass: a slow cross-drift with a swell, not an exit and re-entry. Staying on
   screen is the point — it never leaves and never blanks. */
@keyframes ovpass {
  0%   { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1))
         translate(calc(var(--ov-tx, 12%) * -1), calc(var(--ov-ty, 0%) * -1));
         opacity: calc(var(--ov-op, .18) * 0.55); }
  50%  { opacity: var(--ov-op, .18); }
  100% { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1))
         translate(var(--ov-tx, 12%), var(--ov-ty, 0%));
         opacity: calc(var(--ov-op, .18) * 0.55); }
}
@keyframes ovtravel {
  from { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1)) translate(0, 0); }
  to   { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1))
         translate(var(--ov-tx, 12%), var(--ov-ty, 0%)); }
}
@keyframes ovpass {
  0%   { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1))
         translate(calc(var(--ov-tx, 25%) * -1), calc(var(--ov-ty, 0%) * -1)); opacity: 0; }
  18%  { opacity: var(--ov-op, .18); }
  82%  { opacity: var(--ov-op, .18); }
  100% { transform: rotate(var(--ov-rot, 0deg)) scale(var(--ov-scale, 1))
         translate(var(--ov-tx, 25%), var(--ov-ty, 0%)); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .bg-ov { animation: none; } }

/* Scrim: the operator picks the colours, so contrast cannot be guaranteed.
   This keeps headline text legible over any gradient without dulling it. */
.bg-scrim { position: fixed; inset: 0; z-index: -1;
  background: rgba(0, 0, 0, var(--bg-scrim, .35)); }
:root[data-theme="light"] .bg-scrim {
  background: rgba(255, 255, 255, var(--bg-scrim, .45)); }
@media (prefers-reduced-motion: reduce) {
  .bg-layer, .bg-glow { animation: none; }
}
/* Visually hidden but available to screen readers and to any tool that looks
   for a document outline. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* The pointer is hidden only after a few seconds of stillness, never
   outright: an earlier build set cursor:none globally, which combined with
   having no click targets made the settings unreachable by mouse entirely.
   Any movement brings it back. */
body.idle, body.idle * { cursor: none; }
html, body { height: 100%; margin: 0; overflow: hidden; }
body {
  background: var(--paper); color: var(--ink);
  font-family: Vazirmatn, 'Noto Naskh Arabic', system-ui, sans-serif;
  /* A TV has no pointer. Hiding the caret and selection stops stray focus
     artifacts appearing when the remote sends synthetic clicks. */
  user-select: none; -webkit-user-select: none;
  transition: background-color 400ms linear, color 400ms linear;
}

/* ---------- header: one line, logo → date → status (RTL) ----------
   Sized so the header reads clearly at three metres without claiming a
   quarter of the panel; the stage below is what the viewer is here for. */
.hdr {
  position: absolute; top: var(--safe-y); right: var(--safe-x); left: var(--safe-x);
  height: var(--hdr-h); display: flex; align-items: center; gap: 1.6vw;
  /* No bottom border: the header and the stage are separated by space
     and by the difference in weight, not by a rule. On a dark panel a
     hairline reads as an artifact rather than structure. */
  padding-bottom: 1.05vh;
}
/* 11vh is the ceiling: the header is 12.1vh with 1.05vh of padding-bottom,
   leaving 11.05vh of content box. Anything larger pushes the header taller
   and shifts the stage — going beyond this means paying for it from the
   story area, not from nowhere. Raised from 9.7vh (+13%). */
/* 11vh is the ceiling — the header content box is 11.05vh. */
.hdr .logo { width: var(--logo-h, 11vh); height: var(--logo-h, 11vh); border-radius: 1.8vh; flex: none; display: block; opacity: .88; }
.hdr .wm { display: flex; flex-direction: column; gap: .25vh; flex: none; }
.hdr .wm b { font-size: 3.25vh; font-weight: 800; letter-spacing: -.01em; line-height: 1.2; color: var(--chrome); }
.hdr .wm span { font-size: 1.95vh; color: var(--chrome); }
/* Date leads in bold on the first line; the clock sits beneath it in regular
   weight, so the eye lands on the day rather than the minute. */
/* The clock sits centred beneath the date rather than starting at the same
   edge: the date is a long string and the time a short one, so a shared start
   edge left the time looking stranded at one end. */
.hdr .when {
  /* No text-align here: that was a half-finished attempt to centre the
     clock, and it centred the date line too. The clock has its own
     --clock-align token now. */
  white-space: nowrap; line-height: 1.4; align-self: center;

}
.hdr .when b { color: var(--chrome); font-weight: 700; font-size: 2.65vh; display: block; }
/* The clock sits on its own line under the date, and the date is the wider
   of the two, so the clock has room to move within that block. Physical
   values rather than start/end: "right" should mean right on an RTL page. */
.hdr .when span { color: var(--chrome); font-weight: 400; font-size: 1.95vh; display: block;
  text-align: var(--clock-align, right); }
/* Two equal spacers put the banner in the centre of the row rather than
   beside the date. Absolute centring would be truer to the page midpoint but
   can overlap a long title; flexible spacers can only ever squeeze. */
.hdr .spacer { flex: 1 1 auto; min-width: 0; }
.hdr .bannerslot { display: flex; align-items: center; justify-content: center;
  flex: 0 1 auto; min-width: 0; }
/* Banner: height is pinned to the header and width is capped, with
   object-fit:contain so any aspect ratio letterboxes inside that box instead
   of stretching the header. flex:none keeps it from absorbing free space. */
/* Height and max-width are published per page. The header is 12.1vh with
   1.05vh of padding, so 11vh is the hard ceiling — the largest option is 9vh,
   which leaves the logo and date untouched no matter what is uploaded. */
.hdr-banner {
  /* Default: the whole image, ratio intact. Width is then height x ratio, so
     a 3:1 logo can only ever be ~3x tall no matter how much room is free —
     that is why the row looks empty with a squarish image. */
  height: var(--banner-h, 7vh); width: auto; max-width: 36vw;
  object-fit: contain; object-position: center;
  flex: 0 1 auto; min-width: 0; display: block; border-radius: .8vh;
}
/* Fill mode: the banner takes the whole gap between the brand block and the
   date and crops vertically to fill it. Right for a photographic strip, wrong
   for a logo with text — that is why it is a choice rather than the default. */
.hdr-banner.fill {
  width: 100%; max-width: none; flex: 1 1 auto;
  object-fit: cover; object-position: center;
}
.hdr .bannerslot.fill { flex: 1 1 auto; }
/* Fill mode: the slot takes the free space and the image covers it. A 3:1
   source in a much wider box crops top and bottom, which suits a photographic
   banner and ruins a logo — hence a setting rather than a default. */
body.banner-fill .hdr .bannerslot { flex: 1 1 auto; }
body.banner-fill .hdr-banner { width: 100%; object-fit: cover; }

/* ---------- stage ----------
   Top offset tracks the header: safe-y + header height (16.1) + its
   padding-bottom (1.05) + a 1.5vh gap, expressed against --hdr-h so an
   enlarged header moves the stage automatically instead of overlapping it.
   The bottom offset clears the control strip: the buttons are ~7vh tall
   sitting at safe-y, so a 3vh offset put the progress bar underneath them. */
.stage {
  position: absolute; top: calc(var(--safe-y) + var(--hdr-h) + 2.6vh); right: var(--safe-x);
  left: var(--safe-x); bottom: calc(var(--safe-y) + 9.5vh);
  display: flex; flex-direction: column; justify-content: center;
}
/* .inner carries the measurable content. Fixed type sizes meant budgeting for
   the worst-case headline, so short stories — the common case — floated in a
   sea of empty space. Instead the type scales per story: --ts is solved in JS
   so each block fills the stage without overflowing it. */
/* Bottom-aligned rather than centred: the source box must sit flush on the
   progress bar, and with centred content the gap between them would drift
   with every headline length. */
.story { display: flex; align-items: stretch; height: 100%; overflow: hidden; padding-bottom: var(--bar-h); }
.story .inner { width: 100%; display: flex; flex-direction: column; min-height: 0; }
/* Two columns: text on the RTL start side (right), photo panel on the left.
   min-height:0 on both is what lets the flex children actually shrink instead
   of forcing the row taller than the stage. */
.body { flex: 1; display: flex; gap: 2.4vw; min-height: 0; }
.txt { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: center; overflow: hidden; }
/* Applied only while fitText() is measuring. The line clamps truncate text
   before it can overflow, so scrollHeight would equal clientHeight and the
   solver would see nothing to shrink — the story got clipped mid-sentence
   instead of scaled. Lifting the clamps for the measurement exposes the real
   content height; they stay in place afterwards as a guard for the handful of
   360-character headlines that even the smallest scale cannot fit. */
.txt.measuring .hl, .txt.measuring .sum { -webkit-line-clamp: unset; display: block; }
.story.out { opacity: 0; }

/* Transition variants — set via data-fx on <html>. All animate only opacity
   and transform, the two properties a compositor can handle without laying
   the page out again; anything touching width or filter drops frames badly
   on TV-class hardware.
   `.out` is the state during the swap, so each rule below describes where the
   outgoing story goes and, by symmetry, where the incoming one comes from. */
:root[data-fx="fade"]   .story { transition: opacity var(--fade) var(--fx-ease); }

/* Horizontal slide, following RTL reading direction: stories exit leftward. */
:root[data-fx="slide"]  .story { transition: opacity var(--fade) var(--fx-ease), transform var(--fade) var(--fx-ease); }

/* Vertical — reads like a wire ticker advancing. */
:root[data-fx="up"]     .story { transition: opacity var(--fade) var(--fx-ease), transform var(--fade) var(--fx-ease); }

/* Gentle scale, as though the story recedes before the next steps forward. */
:root[data-fx="zoom"]   .story { transition: opacity var(--fade) var(--fx-ease), transform var(--fade) cubic-bezier(.4,0,.2,1); }
:root[data-fx="zoom"]   .story.out { transform: scale(.965); }

/* Push: a firmer slide with no easing at the tail, for a newsroom feel. */
:root[data-fx="push"]   .story { transition: opacity calc(var(--fade) * .7) var(--fx-ease),
                                             transform var(--fade) var(--fx-ease); }

/* Blur-out. Kept last because it is the most expensive of the set — on a
   weaker set this is the one to drop first if frames stutter. */
:root[data-fx="soft"]   .story { transition: opacity var(--fade) var(--fx-ease), filter var(--fade) var(--fx-ease); }
:root[data-fx="soft"]   .story.out { filter: blur(1.1vh); }

:root[data-fx="none"]   .story { transition: none; }

:root[data-fx="wipe"]   .story,
:root[data-fx="iris"]   .story { transition: clip-path var(--fade) var(--fx-ease), opacity var(--fade) var(--fx-ease); }
:root[data-fx="blur"]   .story { transition: opacity var(--fade) var(--fx-ease), filter var(--fade) var(--fx-ease), transform var(--fade) var(--fx-ease); }
:root[data-fx="flip"]   .story,
:root[data-fx="drop"]   .story,
:root[data-fx="rise"]   .story,
:root[data-fx="shrink"] .story,
:root[data-fx="grow"]   .story { transition: opacity var(--fade) var(--fx-ease), transform var(--fade) var(--fx-ease); }

/* ---------- additional story transitions ----------
   All compositor-only (transform / opacity / filter / clip-path). Distance and
   easing are tokens so the same effect can be subtle or emphatic without a
   separate variant for each. */
:root[data-fx="wipe"]   .story { clip-path: inset(0 0 0 0); }
:root[data-fx="wipe"]   .story.out { clip-path: inset(0 0 0 100%); }
:root[data-fx="iris"]   .story { clip-path: circle(85% at 50% 50%); }
:root[data-fx="iris"]   .story.out { clip-path: circle(0% at 50% 50%); }
:root[data-fx="blur"]   .story.out { opacity: 0; filter: blur(1.6vh); transform: scale(1.03); }
:root[data-fx="flip"]   .stage { perspective: 1400px; }
:root[data-fx="flip"]   .story.out { opacity: 0; transform: rotateX(12deg) translateY(calc(var(--fx-dist) * -1)); }
:root[data-fx="drop"]   .story.out { opacity: 0; transform: translateY(var(--fx-dist)); }
:root[data-fx="rise"]   .story.out { opacity: 0; transform: translateY(calc(var(--fx-dist) * -1)); }
:root[data-fx="shrink"] .story.out { opacity: 0; transform: scale(.9); }
:root[data-fx="grow"]   .story.out { opacity: 0; transform: scale(1.1); }

/* Distance drives the travelling effects, so "slide" can be a nudge or a
   sweep without needing two transitions. */
:root[data-fx="slide"] .story.out { transform: translateX(var(--fx-dist)); }
:root[data-fx="up"]    .story.out { transform: translateY(calc(var(--fx-dist) * -1)); }
:root[data-fx="push"]  .story.out { transform: translateX(calc(var(--fx-dist) * 2)); }

/* With a backdrop the same set applies to the text and image only. */
:root[data-fx="wipe"] body.has-bg .story.out .txt,
:root[data-fx="wipe"] body.has-bg .story.out .media { clip-path: inset(0 0 0 100%); }
:root[data-fx="iris"] body.has-bg .story.out .txt,
:root[data-fx="iris"] body.has-bg .story.out .media { clip-path: circle(0% at 50% 50%); }
:root[data-fx="blur"] body.has-bg .story.out .txt,
:root[data-fx="blur"] body.has-bg .story.out .media { filter: blur(1.6vh); transform: scale(1.03); }
:root[data-fx="drop"] body.has-bg .story.out .txt,
:root[data-fx="drop"] body.has-bg .story.out .media { transform: translateY(var(--fx-dist)); }
:root[data-fx="rise"] body.has-bg .story.out .txt,
:root[data-fx="rise"] body.has-bg .story.out .media { transform: translateY(calc(var(--fx-dist) * -1)); }
:root[data-fx="shrink"] body.has-bg .story.out .txt,
:root[data-fx="shrink"] body.has-bg .story.out .media { transform: scale(.9); }
:root[data-fx="grow"] body.has-bg .story.out .txt,
:root[data-fx="grow"] body.has-bg .story.out .media { transform: scale(1.1); }
:root[data-fx="flip"] body.has-bg .story.out .txt,
:root[data-fx="flip"] body.has-bg .story.out .media { transform: rotateX(12deg); }


/* With a moving backdrop, fading the whole story block means the footer
   capsule and the progress bar pulse in and out too, and against a gradient
   that is already drifting that reads as the background stuttering. When a
   backdrop is active the transition is scoped to the text and the image only;
   the structural chrome holds still and the motion stays legible. */
body.has-bg .story { transition: none !important; opacity: 1 !important; transform: none !important; }
body.has-bg .story .txt,
body.has-bg .story .media {
  transition: opacity var(--fade) var(--fx-ease), transform var(--fade) var(--fx-ease),
              filter var(--fade) var(--fx-ease), clip-path var(--fade) var(--fx-ease);
}
body.has-bg .story.out .txt,
body.has-bg .story.out .media { opacity: 0; }
:root[data-fx="slide"] body.has-bg .story.out .txt,
:root[data-fx="slide"] body.has-bg .story.out .media { transform: translateX(var(--fx-dist)); }
:root[data-fx="up"]    body.has-bg .story.out .txt,
:root[data-fx="up"]    body.has-bg .story.out .media { transform: translateY(calc(var(--fx-dist) * -1)); }
:root[data-fx="zoom"]  body.has-bg .story.out .txt,
:root[data-fx="zoom"]  body.has-bg .story.out .media { transform: scale(.965); }
:root[data-fx="push"]  body.has-bg .story.out .txt,
:root[data-fx="push"]  body.has-bg .story.out .media { transform: translateX(calc(var(--fx-dist) * 2)); }
:root[data-fx="soft"]  body.has-bg .story.out .txt,
:root[data-fx="soft"]  body.has-bg .story.out .media { filter: blur(1.1vh); }
:root[data-fx="none"]  body.has-bg .story .txt,
:root[data-fx="none"]  body.has-bg .story .media { transition: none; }

/* A custom property is not interpolatable unless registered, so without this
   the bars effect would jump rather than close. */
@property --bar-open { syntax: '<percentage>'; inherits: false; initial-value: 100%; }

/* ---------- image-only motion ----------
   Independent of the story transition, so the picture can move differently
   from the words — or move when they do not.
   These animate transform, opacity, filter, clip-path or a registered custom
   property only. The former "pixelate" animated width/height on the <img>,
   which fights object-fit and cannot sample cleanly — it looked like a fault
   and has been replaced. */
.shot > img, .shot > .art { transform-origin: center; }
.shot { overflow: hidden; }
body[class*="imgfx-"] .shot > img,
body[class*="imgfx-"] .shot > .art {
  /* Every property eases the same way. Three of these used a hard-coded
     'ease' while the others read the token, so opacity and the blur drifted
     out of step with the transform on any non-default curve. */
  transition: opacity var(--imgfx-dur, 700ms) var(--imgfx-ease),
              transform var(--imgfx-dur, 700ms) var(--imgfx-ease),
              filter var(--imgfx-dur, 700ms) var(--imgfx-ease),
              clip-path var(--imgfx-dur, 700ms) var(--imgfx-ease),
              --bar-open var(--imgfx-dur, 700ms) var(--imgfx-ease);
}
body.imgfx-fade .story.out .shot > img, body.imgfx-fade .story.out .shot > .art { opacity: 0; }
body.imgfx-zoom .story.out .shot > img, body.imgfx-zoom .story.out .shot > .art {
  opacity: 0; transform: scale(1.14); }
body.imgfx-flip .shot { perspective: 1000px; }
body.imgfx-flip .shot > img, body.imgfx-flip .shot > .art { backface-visibility: hidden; }
body.imgfx-flip .story.out .shot > img, body.imgfx-flip .story.out .shot > .art {
  transform: rotateY(88deg) scale(.96); opacity: 0; }
body.imgfx-blur .story.out .shot > img, body.imgfx-blur .story.out .shot > .art {
  filter: blur(2.2vh); opacity: 0; transform: scale(1.06); }
body.imgfx-wipe .shot > img, body.imgfx-wipe .shot > .art { clip-path: inset(0 0 0 0); }
body.imgfx-wipe .story.out .shot > img, body.imgfx-wipe .story.out .shot > .art {
  clip-path: inset(0 0 0 100%); }
body.imgfx-iris .shot > img, body.imgfx-iris .shot > .art { clip-path: circle(75% at 50% 50%); }
body.imgfx-iris .story.out .shot > img, body.imgfx-iris .story.out .shot > .art {
  clip-path: circle(0% at 50% 50%); }
body.imgfx-bars .shot > img, body.imgfx-bars .shot > .art {
  -webkit-mask-image: repeating-linear-gradient(to bottom, #000 0 var(--bar-open, 100%), transparent var(--bar-open, 100%) 12.5%);
  mask-image: repeating-linear-gradient(to bottom, #000 0 var(--bar-open, 100%), transparent var(--bar-open, 100%) 12.5%); }
body.imgfx-bars .story.out .shot > img, body.imgfx-bars .story.out .shot > .art {
  --bar-open: 0%; opacity: 0; }

@media (prefers-reduced-motion: reduce) {
  .shot > img, .shot > .art { animation: none !important; transition: none !important; }
}

/* Full-height panel down the left. 38% keeps the text column wide enough that
   headlines do not wrap into four lines; the picture gains height instead. */
.media { flex: 0 0 38%; display: flex; min-height: 0; }
.src {
  /* Falls back to --paper so the badge text stays legible on the accent
     colour when no explicit choice is published. */
  color: var(--src-fg, var(--paper)); font-size: 3.1vh; font-weight: 700; white-space: nowrap;
  display: flex; align-items: center; padding: 0 1.8vw 0 1.4vw;
}
.age { font-size: 2.6vh; color: var(--chrome); white-space: nowrap; padding-bottom: 1.3vh; }
/* Capsule leads at the RTL start; the timestamp trails outside it so the
   capsule stays a single clean object. */
.byline { display: flex; align-items: flex-end; gap: 1vw; }

/* The media inset is always filled — a photo when the item has one, a news
   illustration when it doesn't (48% of items). Keeping the box occupied means
   the layout never shifts between stories, which matters a great deal during
   a slow crossfade.
   aspect-ratio is declared explicitly rather than inferred from vw+vh: those
   two units only coincide at 16:9, so on a 21:9 set a vw/vh pair would skew
   the box and crop every photo. */
/* No aspect-ratio here any more: the panel takes the column's shape, roughly
   3:4, and the photo is cropped to fill it. */
.shot {
  width: 100%; height: 100%;
  border-radius: 1.2vh; overflow: hidden; flex: none;
  background: var(--paper-2); position: relative;
}
/* Illustration panel: the tint fills the column, the drawing stays centred
   and whole. A slow drift keeps the panel from being a static block of colour
   for hours — welcome on its own terms, and the single best defence against
   OLED ghosting. Duration, direction and origin are set per story in art.js,
   so no two consecutive panels move alike. Only transform is animated, which
   the compositor handles without re-laying out the page. */
.art { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.art svg {
  width: 74%; height: auto; max-height: 62%;
  animation-name: kenburns; animation-timing-function: ease-in-out;
  animation-iteration-count: infinite; will-change: transform;
}
@keyframes kenburns {
  from { transform: scale(1) rotate(0deg); }
  to   { transform: scale(1.16) rotate(1.2deg); }
}
@media (prefers-reduced-motion: reduce) { .art svg { animation: none; } }
/* News photos are mostly people; biasing the crop above centre keeps faces in
   frame when a portrait-orientation image lands in a landscape box. */
.shot img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 35%; display: block; }
.shot svg { width: 100%; height: 100%; display: block; }

/* Base sizes are deliberately generous; --ts (solved per story in JS) scales
   them down only when a headline is genuinely long. Line clamps remain as a
   final guard for the 364-character outliers. */
.hl {
  margin: 0; font-weight: 800; line-height: 1.74; color: var(--ink);
  /* max() sets a legibility floor: scaling stops at a size still readable
     from three metres, and the line clamp truncates whatever is left.
     A 364-character headline cannot be shown whole AND legibly. */
  font-size: max(calc(2.6vh * var(--fs)), calc(6vh * var(--ts, 1) * var(--fs))); overflow: hidden;
  padding-bottom: max(1rem, 2.2vh);
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 6;
}
.sum {
  /* Same white as the headline. Hierarchy is carried by size (7.6vh vs 4vh)
     and weight (800 vs 400), which is ample at three metres — dimming the
     deck as well just cost legibility. */
  margin: 0; color: var(--ink); line-height: 2.4;
  font-size: max(calc(1.9vh * var(--fs)), calc(3.1vh * var(--ts, 1) * var(--fs)));
  overflow: hidden;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5;
}

/* RTL flow: byline leads at the right edge, QR pushed to the far left. */
.foot { display: flex; align-items: flex-end; gap: 1.6vw; margin-top: 2.4vh; padding-top: 2.4vh; flex: none; }
/* Source and QR fused into one teal capsule seated on the progress bar.
   A shared ground line does more to relate two dissimilar shapes than
   matching their sizes does: the capsule's flat bottom meets the bar, so
   the bar reads as the object's base rather than a separate rule.
   The QR tile is inset inside the capsule rather than matching its full
   height — a dense black grid carries more optical weight than teal with
   text on it, so equal heights would make the code look oversized. */
.tag {
  /* --src-bg carries either a flat colour or a gradient, so one token serves
     both cases; the shorthand accepts an image value here. */
  display: flex; align-items: stretch; background: var(--src-bg, var(--teal));
  border-radius: 1.5vh 1.5vh 0 0; overflow: hidden; flex: none;
}
.qrmini {
  background: #fff; margin: .8vh .8vh 0 0; padding: .45vh;
  border-radius: .6vh .6vh 0 0; display: flex; align-items: flex-end; flex: none;
}
.qrmini svg { width: 6.3vh; height: 6.3vh; display: block; }

/* The bar's thickness and the story's bottom padding must resolve to the
   SAME integer, or the capsule floats a hairline above it. Both were .55vh,
   which is 5.94px at 1080 and rounds independently — one to 6, one to 5,
   leaving a visible 1px seam. A whole-pixel token removes the rounding. */
.bar { position: absolute; bottom: 0; right: 0; height: var(--bar-h);
  background: var(--bar-bg, var(--teal)); width: 0;
  /* Only opacity is transitioned. Width is driven every frame by rAF, so
     transitioning width as well would fight the tick and lag the fill. */
  transition: opacity var(--fade, 600ms) var(--fx-ease, ease); }
/* During a story change the bar fades out with the story it measures; the
   reset to zero then happens behind that fade, never as a visible rewind. */
body.retiring .bar { opacity: 0; transition-duration: calc(var(--fade, 600ms) * .8); }
/* Paused is the one moment a QR can actually be scanned from a sofa: at
   footer size the code is ~3cm, readable only from arm's length, while this
   one is large enough to catch from across the room. */
.paused {
  position: absolute; inset: 0; display: none; align-items: center;
  justify-content: center; gap: 2.4vw; z-index: 15;
  background: color-mix(in srgb, var(--paper) 92%, transparent);
}
body.is-paused .paused { display: flex; }
.paused .pqr { background: #fff; padding: 1.2vh; border-radius: 1.2vh; display: block; }
.paused .pqr svg { width: 30vh; height: 30vh; display: block; }
.paused .ptxt { font-size: 3vh; font-weight: 700; color: var(--ink); line-height: 1.7; }
.paused .ptxt small { display: block; font-size: 2.1vh; font-weight: 400; color: var(--chrome); }
body.is-paused .bar { opacity: .35; }

/* ---------- overlays ---------- */
.ov {
  /* Opaque, not translucent: a 190-row list scanned from three metres cannot
     compete with a photo and a headline showing through it. */
  position: absolute; inset: 0; background: var(--paper);
  display: none; flex-direction: column; padding: var(--safe-y) var(--safe-x);
  z-index: 20;
}
.ov.on { display: flex; }
.ov h2 { margin: 0 0 2.4vh; font-size: 3.2vh; font-weight: 800; }
.ov h2 small { font-weight: 400; font-size: 2.1vh; color: var(--chrome); margin-inline-start: 1vw; }
.rows {
  flex: 1; overflow-y: auto; overscroll-behavior: contain;
  scrollbar-width: thin; scrollbar-color: var(--teal) transparent;
}
/* A visible scrollbar is the only cue a pointer user gets that the list
   continues past the fold — it must not be hidden on this page. */
.rows::-webkit-scrollbar { width: 1.1vh; }
.rows::-webkit-scrollbar-track { background: transparent; }
.rows::-webkit-scrollbar-thumb { background: var(--teal); border-radius: 1vh; }
.row {
  display: flex; align-items: center; gap: 1.2vw; padding: 1.5vh 1.6vw;
  border-radius: 1.2vh; font-size: 2.2vh; border: 2px solid transparent;
  cursor: pointer;
}
.row:hover { background: color-mix(in srgb, var(--paper-2) 70%, transparent); }
/* The exit row is an action, not another option — teal marks it apart from
   the list it terminates. */
.row.done .k { color: var(--teal); font-weight: 800; }
.row.done .n { color: var(--teal); }
.row .k { flex: 1; font-weight: 700; }
.row .v { color: var(--teal); font-weight: 800; }
.row .n { color: var(--ink-soft); font-size: 1.8vh; }
/* Focus is drawn with a heavy fill + border because at three metres a hairline
   outline is invisible. */
.row.sel { background: var(--paper-2); border-color: var(--teal); }
.hint {
  position: absolute; bottom: var(--safe-y); right: var(--safe-x); left: var(--safe-x);
  display: flex; gap: 1.2vw; justify-content: center; align-items: center; z-index: 25;
}
.hint b { color: var(--ink); font-weight: 700; }
/* Sized for a D-pad and a fingertip alike — a TV remote's virtual cursor is
   imprecise, so targets are large with a heavy focus ring visible at 3m. */
.hbtn {
  font: inherit; font-size: 2vh; font-weight: 700; color: var(--chrome);
  background: color-mix(in srgb, var(--paper-2) 80%, transparent);
  border: 1px solid var(--line); border-radius: 3vh;
  padding: 1.1vh 2vw; cursor: pointer; white-space: nowrap;
  transition: background .15s, color .15s, border-color .15s;
}
.hbtn:hover { background: var(--teal); border-color: var(--teal); color: var(--paper); }
.hbtn:focus-visible { outline: .45vh solid var(--teal); outline-offset: .4vh; color: var(--ink); }
.ov .hint { position: static; margin-top: auto; }

.boot { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  flex-direction: column; gap: 2vh; font-size: 2.4vh; color: var(--ink-soft); z-index: 30; }
.boot.off { display: none; }
.boot .qr { width: 16vh; height: 16vh; }

/* Transient message — used when a chosen source has nothing to show. */
#notice {
  position: absolute; left: 50%; transform: translateX(-50%);
  bottom: calc(var(--safe-y) + 12vh); z-index: 40;
  background: var(--clay); color: #fff; font-weight: 700; font-size: 2.4vh;
  padding: 1.4vh 2.6vw; border-radius: 4vh; white-space: nowrap;
  opacity: 0; pointer-events: none; transition: opacity 250ms ease;
}
#notice.on { opacity: 1; }

/* ---------- published-page element toggles ----------
   Set from window.TV_CONFIG via body classes in tv.js. Hiding the header or
   the control strip hands its space back to the stage, so each toggle also
   adjusts the stage bounds rather than leaving a gap. */
body.no-header .hdr { display: none; }
body.no-header .stage { top: var(--safe-y); }
body.no-clock .hdr .when span { display: none; }
body.no-date  .hdr .when b { display: none; }
body.no-date.no-clock .hdr .when { display: none; }
body.no-qr .qrmini { display: none; }
body.no-byline .byline { display: none; }
body.no-progress .bar { display: none; }
body.no-progress .story { padding-bottom: 0; }
body.no-controls #help { display: none; }
body.no-controls .stage { bottom: calc(var(--safe-y) + 3vh); }
body.no-photo .media { display: none; }
body.no-photo .body { gap: 0; }
/* A locked page shows no affordances for menus that will not open. */
body.locked #help [data-act="scope"], body.locked #help [data-act="settings"] { display: none; }
/* The source/topic button doubles as the indicator of what is selected, so
   its label changes with the scope. A min-width stops the strip jiggling as
   short and long source names replace one another. */
#help [data-act="scope"] { min-width: 14vw; justify-content: center; }
