/* Twote: cast / web feed page.
   GLASS REDESIGN 2026-07-25 (docs/design/web-feed-glass-redesign.md). The page is a dark forest
   photograph with TWO sheets of frosted glass over it: Panel A = featured detection + recent rail,
   Panel B = this-session tally + the app-baked mascot card. Per-item solid panels are gone; rows are
   content inside the glass, separated by hairlines and lit by a hover tint only.

   Rules that outlive this file:
   - tokens.css is a vendored copy of the handoff tokens. NEVER hand-edit it. Everything new here is
     DERIVED from those tokens.
   - The wordmark is the LIVE lockup ported from the handoff's own "Splash / cold open" card, not an
     image (CANON 11's 2026-07-25 amendment). The raster assets/twote-wordmark.png is still in the
     repo but UNUSED by this page: its background is opaque --canopy and read as a dark plate over
     the photograph. The radial .brand::before vignette that used to dissolve that plate is gone with
     it. Do not reintroduce either, and do not recreate the mark from spec, memory or a screenshot.
   - Concentric radii at the PANEL level: outer = inner + padding, so a 28px panel with 16px padding
     nests 12px children (--radius-panel / --pad-panel / --radius-inner). Deeper than that the
     arithmetic is deliberately NOT followed: a 60px art tile inside a 12px row with 9px padding
     would be 3px, which reads as a square. Small tiles use a flat 8px by eye. Keep the panel
     arithmetic; do not "correct" the tiles to match it.
   Mobile-first: base styles are the small-viewport styles; media queries ADD width back for the
   cast/TV target (>=1024px). */

* { box-sizing: border-box; }

:root {
  /* ---- Glass recipe: ONE definition, used by both panels, the header bar and the overlay ---- */
  /* Two 10-point steps down from the original 0.38 at Paul's direction (2026-07-25). At 0.18 the
     panel is doing very little tinting; legibility now rests mainly on the 20px backdrop blur and
     the scrim, so re-check text contrast before taking it any lower. */
  --glass-fill: rgba(28, 40, 33, 0.18);
  --glass-fill-deep: rgba(17, 26, 22, 0.52);     /* overlay: sits above a blurred backdrop */
  --glass-blur: blur(20px) saturate(1.2);
  --glass-sheen: linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0) 40%);
  /* Shadows carry elevation; the only borders on this page carry structure (dividers). */
  --glass-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.11),
    inset 0 0 0 1px rgba(255, 255, 255, 0.05),
    0 1px 2px rgba(0, 0, 0, 0.28),
    0 10px 28px rgba(0, 0, 0, 0.30),
    0 28px 64px rgba(0, 0, 0, 0.34);

  /* ---- Structure ---- */
  --hairline: rgba(255, 255, 255, 0.065);
  /* The faint-white family. Every raised-or-lit surface on this page uses one of these three and
     nothing else: seven rounds of incremental work had grown four near-identical hand-rolled values
     (0.055, 0.06, 0.07, 0.09, 0.11) doing three jobs, which is how a "system" stops being one.
     --tint-rest is for a resting chip or pill that is not a hover target; hover and press are the
     interactive pair. */
  --tint-rest: rgba(255, 255, 255, 0.07);
  --tint-hover: rgba(255, 255, 255, 0.055);
  --tint-press: rgba(255, 255, 255, 0.09);
  /* One duration for one gesture. The hover tint under the cursor is the same interaction whether the
     target is a 44px rail row or the whole featured lockup, so it reads wrong at two speeds (it was
     140ms on the rows and 160ms on the hero). Enters and exits of the overlay are a different job and
     keep their own longer timings. */
  --dur-tint: 150ms;
  --imgEdge: rgba(255, 255, 255, 0.10);          /* pure white, never a tinted neutral */
  /* Quiet metadata text. --moss (#4D6F5C) is the handoff's muted-label colour and works on the
     solid --canopy page it was designed for, but over the glass it lands near 2.8:1, under the 4.5:1
     floor for text this small. Derived from --paper so the hue stays in the forest family. */
  --textQuiet: rgba(250, 250, 245, 0.60);

  --radius-panel: 28px;
  --pad-panel: 16px;
  --radius-inner: 12px;                          /* 28 - 16, concentric */
  /* Space a long overlay title (and the anchored text column) must keep clear of the close button.
     The button is 40px wide inset 10px from the right, so it occupies the last 50px: reserving 40 or
     44, as two separate rules used to, let a long last word slide under it. One value, both places. */
  --closeClear: 54px;

  --ease-out: cubic-bezier(0.2, 0, 0, 1);
}

html {
  scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
}

body {
  margin: 0;
  min-height: 100dvh;
  background: var(--bg);
  color: var(--text);
  font-family: var(--fontUI);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden; /* belt-and-braces; layout below must never need this */
}

/* ---------- Forest backdrop ---------- */
/* Fixed layer rather than background-attachment: fixed (which forces a repaint on every scroll). */
.backdrop-photo {
  position: fixed;
  inset: 0;
  z-index: -1;
  background-color: var(--canopy); /* holds the frame until the photo decodes */
}
/* The photograph itself, on its own layer so the brightness lift does not also wash out the scrim.
   glade-2 is a genuinely dark image (median luminance 19); lifting it is what makes the glass read
   AS glass, because there has to be foliage detail behind the blur to see. */
.backdrop-photo::before {
  content: '';
  position: absolute;
  inset: 0;
  /* The user's chosen backdrop (T4.24), set as a custom property by `applyBackdrop` in app.js once
     the feed code resolves. The fallback is the forest, so the page renders correctly with no
     JavaScript, against a pre-migration database, and for the sample/demo state. */
  background-image: var(--backdrop-1280, url('assets/glade-1280.jpg'));
  background-size: cover;
  /* ALWAYS PAINTED, because by the time this rule applies the right photograph is already known:
     `_worker.js` resolves the feed code at the Cloudflare edge and injects `--backdrop-1280` /
     `--backdrop-1920` into the head before the browser parses it (T4.24, settled 2026-07-28).

     THE HOLD THAT USED TO LIVE HERE IS GONE, and it should not come back. It kept this layer
     transparent until a client-side round trip answered, which was the right instinct (Paul: a
     first visit showing the forest "is not an acceptable outcome") aimed at the wrong layer: the
     answer takes 54ms and the client was asking it last, so the 3s failsafe fired on slow
     connections and painted the forest anyway. Resolving at the edge removes the question instead
     of timing it better. If the worker ever fails, this layer paints the forest fallback below and
     `applyBackdrop` corrects it, which is the old behaviour and is never a black page. */
  opacity: 1;
  transition: opacity 320ms ease-out;
  /* Biased down the frame so the lighter fern/forest-floor band sits BEHIND the panels rather than
     the near-black trunks: the blur needs something to blur. */
  background-position: center 72%;
  background-repeat: no-repeat;
  filter: brightness(1.24) saturate(1.06);
}
@media (min-width: 1281px) {
  .backdrop-photo::before { background-image: var(--backdrop-1920, url('assets/glade-1920.jpg')); }
}
/* THE `html[data-backdrop-pending]` HOLD WAS DELETED HERE (2026-07-28) when the resolve moved to
   the edge. It is recorded rather than silently dropped because its history is a warning: shipped
   first as `html:not([data-backdrop-ready])`, it made hiding the DEFAULT, and `home/index.html`
   shares this stylesheet and runs no JavaScript, so the homepage went to near-black. Any page
   reusing this block would have. If a hold is ever reintroduced, make it opt-IN for that reason.
   Better still, do not reintroduce one: the reason it existed is now handled before the HTML is
   sent. */
/* A fade is decoration; the reveal is the content. Reduced motion gets the image, instantly. */
@media (prefers-reduced-motion: reduce) {
  .backdrop-photo::before { transition: none; }
}
/* Legibility scrim. glade-2 measures at median luminance 19, near-identical to --canopy #0F1814,
   so this is a light touch: darken the edges, keep the middle readable behind the glass. */
.backdrop-photo::after {
  content: '';
  position: absolute;
  inset: 0;
  background:
    radial-gradient(130% 100% at 50% 0%,
      rgba(15, 24, 20, 0.10) 0%,
      rgba(15, 24, 20, 0.38) 55%,
      rgba(15, 24, 20, 0.70) 100%);
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--surface);
  color: var(--text);
  padding: 12px 20px;
  border-radius: 8px;
  z-index: 100;
  border: 1px solid var(--outline);
}
.skip-link:focus {
  left: 12px;
  top: 12px;
}

.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;
}

:focus-visible {
  outline: 2px solid var(--swiftGreenBright);
  outline-offset: 2px;
}

a {
  color: var(--link);
  text-decoration: none;
}
a:hover,
a:focus-visible {
  text-decoration: underline;
}

.page {
  max-width: 1280px;
  margin: 0 auto;
  padding: 20px 16px 28px;
}

/* ---------- Glass ---------- */
.glass {
  background-color: var(--glass-fill);
  background-image: var(--glass-sheen);
  -webkit-backdrop-filter: var(--glass-blur);
  backdrop-filter: var(--glass-blur);
  border-radius: var(--radius-panel);
  box-shadow: var(--glass-shadow);
}
/* Browsers without backdrop-filter get an opaque-enough fill so text never sits on bare photo.
   This raises the VARIABLES rather than setting background-color directly, and that is deliberate:
   .overlay-panel declares `background-color: var(--glass-fill-deep)` in its own rule further down
   this file, at equal specificity, so a `background-color` override here LOST the cascade on source
   order alone and the overlay fell back to 0.52 alpha over bare photograph, which is the exact
   failure this block exists to prevent. Overriding the custom property is resolved at use time, so
   it cannot be beaten by a later rule reordering. Keep it this way. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .glass { --glass-fill: rgba(19, 29, 24, 0.93); }
  .overlay-panel { --glass-fill-deep: rgba(15, 24, 20, 0.97); }
}

/* ---------- Header (floats over the photo) ---------- */
.site-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 14px 24px;
  padding-bottom: 18px;
  /* The row centres on the WHOLE brand lockup, and the hanging "talk is cheep" line drags everything
     beside it below the wordmark's own centre. This is the measured correction that puts a
     neighbouring item back on the wordmark's centre line.
     It is applied as a TRANSFORM, not a margin: a negative margin on a centred flex item shrinks its
     margin box, so the container re-centres and gives back about three quarters of the shift
     (measured: 4px of margin moved it 1px). A transform does not touch layout, so it moves 1:1. */
  --capLift: 7px;
  /* The header mark is the CHROME role, so its size comes from the lockup block's `--wm-chrome` and
     is NOT written here (CANON 16). The place line beside it is a ratio of the same `--wmSize`.
     They used to scale by three different factors across the 1024px breakpoint (wordmark x1.40,
     tagline x1.27, place line x1.00, because it was a flat 12.5px). That is why their relationship
     inverted: the caption read oversized next to the small wordmark and undersized next to the large
     one. */
}

.brand {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  flex: none;
}

/* The mark links home (Paul, 2026-07-28). DELIBERATELY OUTSIDE the LOCKUP sentinels below: this is
   the anchor wrapping the mark, not the mark, so it must not enter the byte-identical block that
   `node web/lockup.test.js` pins across surfaces. It carries no colour, underline or size of its
   own, because a link decoration on the wordmark would be a second opinion on CANON 16. */
.brand-home {
  display: inline-block;
  color: inherit;
  text-decoration: none;
  border-radius: 8px;
}
/* Keyboard users get a visible target; pointer users get nothing, since the mark already reads as
   the way home and a hover state on the wordmark would compete with the sonar. */
.brand-home:focus-visible {
  outline: 2px solid var(--swift-green-bright, #34d399);
  outline-offset: 6px;
}

/* ==LOCKUP-BEGIN== THE LOCKUP CONTRACT, docs/CANON.md decision 16.
   Everything between the LOCKUP-BEGIN and LOCKUP-END sentinels is BYTE-IDENTICAL in
   web/feed/styles.css and web/site/site.css, and `node web/lockup.test.js` fails if it ever stops
   being. Its numbers are the same table as shared/.../design/LockupSpec.kt, asserted on that side by
   LockupSpecTest. To change a value: edit CANON 16 first, then all four places. Never edit one copy.

   THE MARK IS DEFINED ONCE, in docs/design/handoff-t2/Twote Handoff.dc.html, the "Splash / cold
   open" card. Never recreate it from the written spec, from memory, or from a screenshot. Never
   substitute an SVG file, a font trick, or the raster twote-wordmark.png, whose background is opaque
   --canopy and reads as a dark plate over the photograph. Never let a text letter "O" stand in for
   the drawn ring, which is what the mascot card's fallback wordmark was doing until T5.7.

   AUTHORITATIVE RULE, carried verbatim from CANON 11: the pulse echo rings are CONCENTRIC with the
   green O and share its EXACT centre, computed by construction, NEVER a separate centred box and
   NEVER an eyeballed offset. Here that is satisfied by the SVG viewBox being `0 0 100 100` over the
   O's own square box, so cx/cy = 50,50 IS the measured centre. */

/* OUTFIT IS SELF-HOSTED (CANON 16, Paul 2026-07-26). The lockup's typeface ships with the page,
   because a substituted typeface is a different mark and a webfont that arrives late, or not at all,
   substitutes. Both surfaces used to pull Outfit from fonts.googleapis.com, which is a third-party
   request on the critical path for the brand. `font-display: block` is deliberate and is the opposite
   of the usual advice: a brief blank mark is better than the mark rendering in a system sans and then
   jumping. Fraunces is a separate matter and still comes from the CDN; it sets italic species names
   only, where a serif fallback keeps the voice and costs nothing.
   The four files are the fontsource latin subsets, 14 KB each. Paths are relative to this stylesheet,
   so each surface serves its own copy from its own assets/fonts/. */
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 400;
  font-display: block;
  src: url('assets/fonts/outfit-latin-400-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 500;
  font-display: block;
  src: url('assets/fonts/outfit-latin-500-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 600;
  font-display: block;
  src: url('assets/fonts/outfit-latin-600-normal.woff2') format('woff2');
}
@font-face {
  font-family: 'Outfit';
  font-style: normal;
  font-weight: 700;
  font-display: block;
  src: url('assets/fonts/outfit-latin-700-normal.woff2') format('woff2');
}

/* THE THREE ROLES. A placement names a role; it never writes a px value. ONE breakpoint, 1024px,
   shared by both web surfaces: the site's old 700px step and its 30px nav mark are gone. The app has
   no breakpoint, so it always takes the base values, which is why MARK's base is the splash's 58. */
:root {
  --wm-mark: 58px;         /* splash, the app's cold-open stage, the site hero */
  --wm-chrome: 44px;       /* feed header, the app's Listen header, the site nav */
  --wmSize: var(--wm-chrome);
}
@media (min-width: 1024px) {
  :root {
    --wm-mark: 84px;
    --wm-chrome: 56px;
  }
}

/* ONE NUMBER, S = --wmSize. Every value below is a ratio of it, so the lockup is the same shape at
   any size on any surface. Three independent scale factors are what made the relationship invert
   across the old breakpoint: the tagline read oversized beside the small mark and undersized beside
   the large one. */
.wordmark-lockup {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  /* A flat 0, not a ratio: the wordmark's own line box already carries slack that scales with its
     font-size (measured 4.8px at 40px, 7.1px at 56px), so 0 still reads as a proportional gap.
     DO NOT write a negative value here. This was `calc(var(--wmSize) * -0.02)` until 2026-07-26, and
     `gap` accepts only non-negative lengths, so the declaration was invalid, dropped, and the
     computed row-gap was 0px the whole time. To go tighter than 0, pull the tagline up with a
     negative margin-top, which is legal, then re-check that the rings still clear the letterforms. */
  gap: 0;
}
.wordmark {
  font-family: var(--fontUI);
  font-weight: 700;
  font-size: var(--wmSize);
  line-height: 1;
  letter-spacing: -0.02em;
  color: var(--text);
  display: inline-flex;
  align-items: baseline;
  position: relative;
  isolation: isolate;
  /* There is no plate behind the mark, so the letterforms carry their own legibility. Invisible
     against the dark canopy; it only does work over a bright patch of foliage. */
  text-shadow: 0 2px 16px rgba(15, 24, 20, 0.85), 0 1px 3px rgba(15, 24, 20, 0.7);
}
.wordmark-part { position: relative; z-index: 1; }
.wordmark-o {
  position: relative;
  display: inline-block;
  width: 0.67em;
  height: 0.67em;
  /* THE SIDE MARGIN IS A RAMP, NOT A STEP. A drawn circle has no sidebearing, so an em-proportional
     margin over-opens as the mark grows: hence two anchors, 0.032em at S=44 (1.408px) and 0.018em at
     S=56 (1.008px). Both are preserved to the pixel. It used to STEP between them, at a 1024px
     VIEWPORT on the feed, at a 50sp MARK SIZE on Android, and never at all on the site, which is why
     the site hero was running 2.688px of margin where CANON already said 1.008. A viewport is not
     the mark, so it cannot be what the mark's geometry keys off. This clamp is exactly
     LockupSpec.oMargin(S), including the min/max ordering. */
  margin: 0 clamp(
    calc(var(--wmSize) * 0.018),
    calc(1.408px - (var(--wmSize) - 44px) * 0.033333),
    calc(var(--wmSize) * 0.032)
  );
  top: 0.02em;
}
/* THE RING-O IS AN SVG STROKE, NOT A CSS BORDER (2026-07-26).
   It was `border: 0.114em`, and Chrome FLOORS a computed border-width to a whole device pixel: at
   CHROME 44 on a 1.1-dpr display the 5.016px ring rendered 4.545px, about 9% thin, and every measured
   value came back an exact multiple of 1/1.1. The error is bounded by one device pixel, so it is
   worst at the small sizes the mark is used at most, and it moves with the viewer's zoom. Android
   draws this ring on a canvas and was always exact, so the two platforms disagreed by up to 9% on the
   one value the whole lockdown is about.

   An SVG stroke is rasterised with subpixel antialiasing instead of snapped, so it stays proportional
   at every size and dpr. This is the same change, for the same reason, that CANON 11 already made to
   the echoes: the technique changes, the geometry does not.

   The stroke is CENTRED on the path, so the radius is set to put its OUTER edge exactly on the box
   edge, which is what `box-sizing: border-box` was doing for the border:
     stroke-width = 0.114 / 0.67 x 100 = 17.0149 units of the 100-unit viewBox
     r            = (100 - 17.0149) / 2 = 41.4926          (an ATTRIBUTE in the markup, not CSS `r`,
                                                            so the mark still draws in a browser
                                                            without CSS geometry properties)
   The ring is the LAST child of the pulse SVG, so it paints over the echoes; the letterforms carry
   z-index 1 and the SVG does not, so they still paint over both. */
.wordmark-ring {
  fill: none;
  stroke: var(--swiftGreenBright);
  stroke-width: 17.0149;
}

/* ---- Sonar echoes ----
   The handoff draws these as bordered boxes animating `inset` from 0 to -0.448em. That reads as
   jerky: `inset` is a LAYOUT property, so every frame runs layout on a main thread already busy
   compositing backdrop-filter panels over a 1920px photo, and frames get dropped. It also cannot
   taper, because scaling a bordered box makes its border look THICKER as it grows, the opposite of a
   ping. So the echoes are SVG strokes instead. The handoff's GEOMETRY is preserved exactly, converted
   into the viewBox where 100 units = the O's outer diameter (0.67em): the ring starts flush with the
   O and ends 0.448em beyond it, which is 0.448 / 0.67 * 100 = 66.9 units, so the outer edge travels
   50 -> 116.9. Only the rendering technique and the stroke taper are new (Paul, 2026-07-25). */
.wordmark-pulse {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;       /* the rings expand well past the O's own box */
  pointer-events: none;
}
.wordmark-echo {
  stroke: var(--swiftGreenBright);
  opacity: 0;
  /* Start heavy, near the O's own weight, so the ring reads as the O shedding a pulse. */
  stroke-width: 11;
}
/* Three separate animations, because spread, taper and fade each want a DIFFERENT curve. Driving
   them from one set of keyframes is what made the first attempt read badly: the ring covered 92% of
   its travel in the first 900ms of a 3s cycle, then hung nearly motionless while it faded, and only
   reached full opacity after the motion was already over.
   r is the CENTRELINE radius, so each end value is the target outer edge minus half the stroke:
   start 50 - 11/2 = 44.5 (flush with the O, so the pulse reads as shed BY the O);
   end 116.9 - 0.9/2 = 116.5 (the handoff's 0.448em of travel). */
/* CADENCE (Paul, 2026-07-26): the time BETWEEN pings is doubled, the pings themselves travel at the
   same speed. So the cycle is 6s with all the motion packed into the first 50% (an unchanged 3s of
   travel) and the ring dormant for the rest; the second ring is delayed a full 3s instead of 1.5s.
   Emissions therefore land every 3s rather than every 1.5s. Keep the 50% marks and the 6s duration
   in step if either changes, or the pulses drift out of their rest. */
@keyframes tw-sonar-spread {
  0%   { r: 44.5; }
  50%  { r: 116.5; }
  100% { r: 116.5; }
}
/* The wavefront thins as it spreads, but more slowly than it travels: a stroke that tapers on the
   spread curve is a hairline a third of the way in and has nothing left to show. */
@keyframes tw-sonar-taper {
  0%   { stroke-width: 11; }
  50%  { stroke-width: 0.9; }
  100% { stroke-width: 0.9; }
}
/* Fast attack at emission, then a long decay held high enough that the ring is brightest WHILE it is
   travelling fastest, not after it has stopped. Peak lowered to 0.68 (Paul): the mark should breathe
   behind the wordmark, not compete with it. */
@keyframes tw-sonar-fade {
  0%    { opacity: 0; }
  2%    { opacity: 0.68; }
  27.5% { opacity: 0.30; }
  50%   { opacity: 0; }
  100%  { opacity: 0; }
}
/* ONE GATE, `.is-pulsing`, on both surfaces (CANON 16). At most one mark pulses in a view and it is
   the largest one present; every other mark shows the static echo. On the feed, app.js sets this on
   the header lockup only while the sensor heartbeat is genuinely live, because a mark is a heartbeat
   and should not beat for a feed that is offline. On the site there is no sensor, so the class is
   authored in the markup, on the hero and nowhere else: one motion moment per page. The two surfaces
   used to use two different class names for this, `.sensor-live` and `.is-pulsing`, which is why
   this rule could not be shared. */
@media (prefers-reduced-motion: no-preference) {
  .is-pulsing .wordmark-echo {
    /* A moderate ease-out, not an exponential one. The wavefront should still be visibly moving
       through the middle of the cycle; expo-out parks it. */
    animation:
      tw-sonar-spread 6s cubic-bezier(0.18, 0.58, 0.33, 1) infinite,
      tw-sonar-taper 6s cubic-bezier(0.35, 0.25, 0.5, 1) infinite,
      tw-sonar-fade 6s linear infinite;
  }
  .is-pulsing .wordmark-echo--b { animation-delay: 3s, 3s, 3s; }
  .is-pulsing .wordmark-echo--a { opacity: 0; }
}
/* Reduced motion, offline, and every non-pulsing mark: the handoff's own static fallback, one faint
   ring sitting just outside the O (inset -0.12em = 17.9 units, so outer edge 67.9). */
.wordmark-echo--a { r: 66.9; stroke-width: 2; opacity: 0.4; }
.wordmark-echo--b { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .is-pulsing .wordmark-echo { animation: none; }
  .is-pulsing .wordmark-echo--a { r: 66.9; stroke-width: 2; opacity: 0.4; }
}
.wordmark-tagline {
  /* The rings expand past the wordmark's own box and reach this line, so the tagline is explicitly
     lifted above the pulse. Every letterform sits in front of the ping; nothing is drawn over type. */
  position: relative;
  z-index: 2;
  font-size: calc(var(--wmSize) * 0.25);
  font-weight: 400;
  letter-spacing: 0.02em;
  color: var(--textQuiet);
  text-shadow: 0 1px 10px rgba(15, 24, 20, 0.85);
}
/* ==LOCKUP-END== */

/* The place line sits on the SAME row as the wordmark and the status bar (Paul, 2026-07-25). Its two
   parts stack rather than running on, so a long location never forces the row to wrap. */
.feed-place {
  margin: 0;
  /* Grouped with the wordmark rather than floated into the middle of the row: it captions the feed,
     and the status bar on the right is a separate concern. */
  margin-right: auto;
  /* Lifted to sit level with the wordmark rather than with the lockup's centre; see --capLift.
     Applies to the one-line and the two-line variant alike, because it shifts the block, not a line. */
  transform: translateY(calc(var(--capLift) * -1));
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1px;
  min-width: 0;
  /* Same ratio as the tagline: they are the same rank of secondary text and sit on the same line. */
  font-size: calc(var(--wmSize) * 0.25);
  line-height: 1.35;
  letter-spacing: 0.02em;
  color: var(--fog);
  text-shadow: 0 1px 10px rgba(15, 24, 20, 0.85);
}
.feed-place-lead { color: var(--textQuiet); }
.feed-place-loc:empty { display: none; }

/* The live-status cluster gets its own slim glass bar so it stays legible over the photograph. */
.header-bar {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
  padding: 8px 12px;
  border-radius: 999px;
  /* It shares the glass RECIPE but not the panels' elevation: shadow scale should track the size of
     the object casting it, and .glass carries a 680px panel's shadow (0 28px 64px). On a 36px pill
     that read as a small object floating implausibly high. Same lit top hairline, a third of the
     drop. */
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.11),
    inset 0 0 0 1px rgba(255, 255, 255, 0.05),
    0 1px 2px rgba(0, 0, 0, 0.24),
    0 6px 18px rgba(0, 0, 0, 0.26);
}

.tally {
  color: var(--fog);
  font-size: 13.5px;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* THE STATE PILL SLOT. Its width is the widest of the three labels and never changes, so a flip
   between LOADING, OFFLINE and LIVE moves nothing to its left. The pill sits at the START of that
   slot, which leaves visible space to its right on the shorter labels; Paul chose that over the
   whole bar sliding 26.5px on every status change (measured, 2026-07-26).

   The width comes from two hidden copies of the pill in the markup rather than a magic number. All
   three share one grid cell, so the column resolves to the widest of them automatically, and it
   stays correct if the font, the size or the wording ever changes. */
.live-slot {
  display: grid;
  justify-items: start;
  align-items: center;
  flex: none;
}
.live-slot > * {
  grid-area: 1 / 1;
}
.live-pill--sizer {
  visibility: hidden;
  pointer-events: none;
}

.live-pill {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  border: 1px solid var(--leaf);
  color: var(--fog);
  border-radius: 999px;
  padding: 5px 13px;
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.02em;
  white-space: nowrap;
  transition-property: border-color, color;
  transition-duration: 200ms;
  transition-timing-function: var(--ease-out);
}
.live-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--moss);
  flex: none;
  transition: background-color 200ms var(--ease-out);
}

/* LOADING and OFFLINE are both quiet and grey; only the word separates them, which is the point.
   The bar carries no information yet, so nothing about it should draw the eye. */
.live-pill.is-loading,
.live-pill.is-offline {
  border-color: var(--leaf);
  color: var(--fog);
}
.live-pill.is-loading .live-dot,
.live-pill.is-offline .live-dot {
  background: var(--moss);
}

/* LIVE. The border is the SAME green as the dot (Paul, 2026-07-26) so the pill reads as one object
   rather than a grey capsule with a green bead in it.

   There is deliberately NO pulse here. The sonar pulse belongs to the O in the wordmark and nowhere
   else (CANON 15's sibling rule in the lockup canon): this dot used to run its own 2.4s ease-out
   animation next to the wordmark's 6s three-curve one, so two different heartbeats were visible in
   the same header. Liveness is signalled by colour and word, never by motion alone, which also
   means there is nothing here for a reduced-motion query to switch off. */
.live-pill.is-live {
  border-color: var(--swiftGreenBright);
  color: var(--paper);
}
.live-pill.is-live .live-dot {
  background: var(--swiftGreenBright);
}

/* THE ONE-SHOT REVEAL. Everything left of the pill stays hidden until every value has resolved,
   then appears together (Paul, answer 32). Fading rather than snapping, at the same duration as
   every other tint change on the page, so it reads as the bar arriving rather than a flash. */
.status-content {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
  opacity: 0;
  transition: opacity var(--dur-tint) var(--ease-out);
}
.header-bar.is-ready .status-content {
  opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
  .status-content { transition: none; }
}

/* Sensor status (device identity + battery/charging), driven by the live biosys heartbeat. */
.sensor-status {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  line-height: 1.2;
  white-space: nowrap;
}
.sensor-device {
  font-size: 13px;
  color: var(--fog);
  white-space: nowrap;
  max-width: 240px;
  overflow: hidden;
  text-overflow: ellipsis;
}
.sensor-batt {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  font-size: 12px;
  font-weight: 600;
  color: var(--swiftGreenBright);
  font-variant-numeric: tabular-nums;
}
.sensor-status.is-offline .sensor-device,
.sensor-status.is-offline .sensor-batt {
  color: var(--textQuiet);
}

/* ---------- Layout: two glass panels ---------- */
main {
  display: grid;
  grid-template-columns: 1fr;
  gap: 18px;
}

.panel {
  padding: var(--pad-panel);
  min-width: 0;
}
.panel--feed {
  display: grid;
  grid-template-columns: 1fr;
  gap: 16px;
}
.panel--session {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* One-off staged entrance (hierarchy cue), 100ms apart, on load only. */
@media (prefers-reduced-motion: no-preference) {
  .panel {
    animation: panel-in 460ms var(--ease-out) both;
  }
  .panel--session { animation-delay: 100ms; }
}
@keyframes panel-in {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: none; }
}

/* Tablet: split Panel A so the hero stops being a 600px square, but keep the page scrolling
   (the bounded single-screen rectangle is a desktop-only idea). */
@media (min-width: 700px) and (max-width: 1023.98px) {
  .panel--feed {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 0;
  }
  .panel-recent {
    border-left: 1px solid var(--hairline);
    padding-left: 14px;
    margin-left: 14px;
  }
}

@media (min-width: 1024px) {
  /* DESKTOP cast surface (deliberately NOT the mobile-first stacking above). The interface stays a
     compact, bounded rectangle: header on top, two equal-height glass panels below, sized to ONE
     screen and never stretched to fill a tall monitor with empty gaps. Both panels share one bounded
     height, so the mascot card's bottom aligns with the recent rail's. */
  .page {
    display: flex;
    flex-direction: column;
    justify-content: center;
    min-height: 100dvh;
  }
  /* The rectangle is capped at 680px, so on a tall monitor it used to sit top-anchored with all the
     spare room dumped underneath. Centring splits that room evenly and the composition reads as a
     deliberate object on the photograph rather than something that fell to the top of the page. */
  .site-header { flex: none; }
  /* The wide CHROME size and the tighter O margin both come from the lockup block now: `--wm-chrome`
     steps to 56px at this same 1024px breakpoint, and the margin ramp reaches its tight anchor there
     by itself. Writing either of them here is what let the site drift, because the site had this
     override and the feed did not. */
  .site-header { --capLift: 9px; }
  main {
    flex: 0 1 auto;
    min-height: 0;
    /* Was a bare `100dvh - 176px`, which read as "the height of the chrome above" and no longer was:
       the header measures 92px and .page adds 20 top + 28 bottom, so the real chrome is 140px and the
       remaining 36px is breathing room that keeps the rectangle from touching the viewport edges.
       Split into named parts so neither number gets "corrected" into the other by mistake. The sum is
       unchanged at 176px, so this moves nothing on screen. */
    --pageChrome: 140px;   /* header 92 + .page padding 20 top + 28 bottom */
    --mainBreathe: 36px;   /* deliberate slack above and below the rectangle */
    height: min(calc(100dvh - var(--pageChrome) - var(--mainBreathe)), 680px);
    align-items: stretch;
    grid-template-columns: minmax(0, 2.4fr) minmax(280px, 0.92fr);
  }
  .panel { min-height: 0; }
  .panel--feed {
    grid-template-columns: minmax(0, 1.2fr) minmax(320px, 1fr);
    gap: 0;
  }
  /* A hairline is the only border here, and it is doing real structural work: separating the
     featured lockup from the rail inside one sheet of glass. */
  .panel-recent {
    border-left: 1px solid var(--hairline);
    padding-left: 16px;
    margin-left: 16px;
  }
}

.panel-featured,
.panel-recent {
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* Thin scrollbars styled to the page furniture (leaf on transparent), for the internal-scroll lists. */
#recent-list,
#session-list {
  scrollbar-width: thin;
  scrollbar-color: var(--leaf) transparent;
}
#recent-list::-webkit-scrollbar,
#session-list::-webkit-scrollbar { width: 6px; }
#recent-list::-webkit-scrollbar-thumb,
#session-list::-webkit-scrollbar-thumb { background: var(--leaf); border-radius: 3px; }
#recent-list::-webkit-scrollbar-thumb:hover,
#session-list::-webkit-scrollbar-thumb:hover { background: var(--moss); }
#recent-list::-webkit-scrollbar-track,
#session-list::-webkit-scrollbar-track { background: transparent; }

.column-heading {
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 11.5px;
  font-weight: 600;
  color: var(--textQuiet);
  margin: 0 0 10px;
  padding: 0 10px;
  text-wrap: balance;
}
.column-heading span { font-variant-numeric: tabular-nums; }
/* A figure and the noun it counts are one unit: text-wrap: balance must not split "31 species". */
.column-heading .nb { white-space: nowrap; }

.empty-note {
  color: var(--fog);
  font-size: 14px;
  margin: 0;
  padding: 0 10px;
}

/* Scientific (Latin) names are set in Fraunces italic, per the handoff. */
.species-name {
  font-family: var(--fontSpecies);
  font-style: italic;
}

/* ---------- Shared row-trigger behaviour ---------- */
/* Rows are real buttons (keyboard reachable, >=40px tall) with no chrome of their own: the panel is
   the container, the row is content. Feedback is a tint, not a transform, because these are high-frequency
   targets and a scale on a full-width row reads as the layout twitching. */
.row-trigger {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 44px;
  padding: 9px 10px;
  margin: 0;
  border: 0;
  border-radius: var(--radius-inner);
  background-color: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition-property: background-color;
  transition-duration: var(--dur-tint);
  transition-timing-function: var(--ease-out);
}
.row-trigger:hover { background-color: var(--tint-hover); }
.row-trigger:active { background-color: var(--tint-press); }

/* Reduced motion, in ONE place for every hover/press transition on the page. It used to sit on
   .row-trigger alone, so the rail rows honoured the preference while the featured lockup, the pill
   and the overlay links quietly ignored it. Same gesture, same accommodation. */
@media (prefers-reduced-motion: reduce) {
  .row-trigger,
  .featured-trigger,
  .live-pill,
  .overlay-close,
  .overlay-link { transition-duration: 0ms; }
}

/* ---------- (a) Featured latest detection ---------- */
#featured {
  display: flex;
  flex-direction: column;
  height: 100%;
  min-height: 0;
}
.featured-trigger {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 0;
  height: 100%;
  min-height: 0;
  padding: 6px;
  border: 0;
  border-radius: var(--radius-inner);
  background-color: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition-property: background-color;
  transition-duration: var(--dur-tint);
  transition-timing-function: var(--ease-out);
}
.featured-trigger:hover { background-color: var(--tint-hover); }
.featured-trigger:active { background-color: var(--tint-press); }
/* The featured lockup lives inside a <button>, whose content model is phrasing content only, so
   every part of it is a <span> that CSS promotes back to a block. */
.featured-art {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  border-radius: 8px; /* 12 outer - 6 trigger padding + a touch, keeps the nest concentric */
  overflow: hidden;
  background-color: rgba(15, 24, 20, 0.5);
  background-size: cover;
  background-position: center;
  outline: 1px solid var(--imgEdge);
  outline-offset: -1px;
}
.featured-lockup {
  display: block;
  padding: 14px 0 2px; /* left edge flush with the art above it */
}
.featured-name {
  display: block;
  font-family: var(--fontUI);
  font-weight: 700;
  font-size: 30px;
  line-height: 1.08;
  margin: 0 0 6px;
  color: var(--text);
  text-wrap: balance;
}
@media (min-width: 1024px) {
  .featured-name { font-size: 42px; }
}
.featured-meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
}
.featured-sci {
  font-size: 20px;
  margin: 0;
  color: var(--fog);
}
.latest-label {
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 11.5px;
  color: var(--textQuiet);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

@media (min-width: 1024px) {
  .featured-art { flex: 1; min-height: 0; aspect-ratio: auto; }
}

/* Detection-arrival animation: the featured slot only. It sits on .featured-trigger, which is
   rebuilt on every render, so a genuinely new detection animates in; #featured itself persists and
   would only ever have played this once, on load. The rail used to replay the same animation on all
   40 rows on every realtime insert, which is what read as the page "flickering and reloading". */
@media (prefers-reduced-motion: no-preference) {
  .featured-trigger { animation: feed-arrive 200ms var(--ease-out); }
}
@keyframes feed-arrive {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}

/* ---------- Tier chips (shared) ---------- */
.tier-chip {
  display: inline-flex;
  align-items: center;
  border-radius: 999px;
  font-weight: 600;
  font-size: 12px;
  padding: 4px 11px;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.tier-chip--compact {
  padding: 3px 9px;
  font-size: 11.5px;
}

/* ---------- (b) Recent detections rail ---------- */
#recent-list {
  list-style: none;
  margin: 0;
  padding: 0;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}
/* Hairline dividers instead of per-row panels. */
#recent-list li + li { border-top: 1px solid var(--hairline); }
/* Rail rows live inside a <button> too: spans promoted back to blocks. */
.recent-art {
  display: block;
  width: 60px;
  height: 60px;
  border-radius: 8px;
  flex: none;
  background-color: rgba(15, 24, 20, 0.5);
  /* Zoomed rather than `cover`. The art is a whole forest scene with the bird occupying roughly half
     the frame, so at tile size `cover` renders the bird about 28 device pixels across and it reads
     as a smudge. The generation prompt guarantees "A single bird, centered", so cropping in on the
     centre roughly doubles the bird and discards background that carries nothing at this size.
     app.js separately requests a 200px server-resampled copy instead of scaling the 1024px source. */
  background-size: 145%;
  background-position: center;
  outline: 1px solid var(--imgEdge);
  outline-offset: -1px;
}
@media (min-width: 640px) {
  .recent-art { width: 70px; height: 70px; }
}
.recent-body {
  display: block;
  flex: 1;
  min-width: 0;
}
.recent-name {
  display: block;
  font-weight: 600;
  font-size: 14.5px;
  margin: 0 0 2px;
  color: var(--text);
  overflow-wrap: break-word;
}
.recent-sci {
  display: block;
  font-size: 12px;
  margin: 0;
  color: var(--fog);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.recent-ago {
  color: var(--textQuiet);
  font-size: 12.5px;
  flex: none;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* ---------- (c) This session tally ---------- */
#session-list {
  list-style: none;
  margin: 0;
  padding: 0;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}
#session-list li + li { border-top: 1px solid var(--hairline); }
.session-row { justify-content: space-between; }
.session-name {
  font-weight: 500;
  font-size: 15.5px;
  color: var(--text);
  min-width: 0;
  overflow-wrap: break-word;
}
.count-pill {
  background-color: var(--tint-rest);
  border-radius: 999px;
  padding: 3px 10px;
  font-size: 12.5px;
  font-weight: 600;
  color: var(--fog);
  flex: none;
  font-variant-numeric: tabular-nums;
}

/* Both scrolling lists fade out at their bottom edge instead of a row being guillotined by it: the
   session list into the mascot card below, the rail into the bottom of the glass. Applies at every
   width, because below 1024px the lists are bounded too (see the next rule). */
#session-list,
#recent-list {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 26px), transparent 100%);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 26px), transparent 100%);
}

/* BELOW the desktop breakpoint the panels are not height-bounded by `main`, so `flex: 1` on the
   lists let them grow to fit all 40 rendered rows: the Recent panel ran to several screens and
   pushed the session panel and the mascot card off the bottom of the page. Bounding them here keeps
   the two-panel composition intact at every width and keeps the scroll inside the list, where the
   fade and the styled scrollbar already live. */
@media (max-width: 1023.98px) {
  #recent-list { max-height: min(56vh, 440px); }
  #session-list { max-height: min(38vh, 300px); }
  /* The hero is a 1:1 square of the column width, so in the single-column range it grew with the
     viewport: at 699px that is a 570px square and a 2000px page. Capping the height lets it crop to
     a landscape frame instead (background-size: cover, so the art is not distorted). */
  .featured-art { max-height: min(52vh, 400px); }
}

/* ---------- Portrait mascot share card ---------- */
/* The app-generated canonical card (CANON 12 + 13): the web <img>s the exact object the app bakes
   and uploads. Its pixels are not ours to restyle: only the frame, position and surrounding chrome.
   The live-composite branch below is the pre-upload fallback, ported from
   docs/design/qr-mockups/compose_mascot_card.py. Sizes are in cqw (fractions of the card width). */
.mascot-card {
  margin: auto auto 0;
  position: relative;
  display: block;
  width: 100%;
  /* Capped so the card stays a companion to the session tally rather than eating the panel: at the
     desktop column width an uncapped 3:4 card left the list about three rows of room. */
  max-width: 236px;
  aspect-ratio: 3 / 4;
  border-radius: var(--radius-inner);
  overflow: hidden;
  background: var(--canopy);
  flex: none;
  container-type: inline-size;
  /* Held back until the artwork has decoded. It used to paint as an empty outlined box before any
     other content, which read as a layout glitch on every load. No hover or press state: it is not
     interactive, because the reader is already on the page this QR points at. */
  opacity: 0;
  transition: opacity 320ms var(--ease-out);
}
.mascot-card.is-ready {
  opacity: 1;
  outline: 1px solid var(--imgEdge);
  outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
  .mascot-card { transition-duration: 0ms; }
}
.mascot-card-img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 42%; /* permanent crop: bias slightly up so the bird's head is never clipped */
}
.mascot-card-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom,
    rgba(15, 24, 20, 0) 52%,
    rgba(15, 24, 20, 0.55) 72%,
    rgba(15, 24, 20, 0.95) 100%);
}
.mascot-card-echoes {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.mascot-card-qr {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: 15cqw;
  /* 51.2cqw = 0.512 of card width, matching the app card's qrFrac. Fallback path only. */
  width: 51.2cqw;
  height: 51.2cqw;
}
.mascot-card-qr svg { display: block; width: 100%; height: 100%; }
/* The card's URL stamp is the LOCKUP at the STAMP role (CANON 16): S is 0.060 of the card's own
   width, and the O inside it is the drawn `.wordmark-ring`, sharing every ratio with the header
   mark. It used to be a literal green text letter "O" at font-weight 600 and -0.01em tracking, a
   third way of drawing the mark and two more values that agreed with nothing. */
.mascot-card-wordmark {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 5cqw;
  text-align: center;
  color: #fff;
  font-family: var(--fontUI);
  font-weight: 700;
  --wmSize: 6cqw;
  font-size: var(--wmSize);
  letter-spacing: -0.02em;
  white-space: nowrap;
}

/* Baked mode: the app-generated PNG IS the whole card (mascot + QR + wordmark + echoes flattened at
   the canonical 3:4), so it drops straight into this frame; we only reset the crop bias to centre
   and hide the live DOM overlays that would double up on the baked-in ones. */
.mascot-card.baked .mascot-card-img {
  object-fit: cover;
  object-position: center;
}
.mascot-card.baked .mascot-card-scrim,
.mascot-card.baked .mascot-card-echoes,
.mascot-card.baked .mascot-card-qr,
.mascot-card.baked .mascot-card-wordmark { display: none; }

/* ---------- Species detail overlay ---------- */
.overlay {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  padding: 20px;
}
.overlay[hidden] { display: none; }

.overlay-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(8, 13, 11, 0.52);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity 240ms var(--ease-out);
}
.overlay.is-open .overlay-backdrop { opacity: 1; }

.overlay-panel {
  position: relative;
  width: min(560px, 100%);
  max-height: min(86dvh, 760px);
  overflow-y: auto;
  padding: var(--pad-panel);
  background-color: var(--glass-fill-deep);
  opacity: 0;
  transform: scale(0.98);
  transition-property: opacity, transform;
  transition-duration: 240ms;
  transition-timing-function: var(--ease-out);
  scrollbar-width: thin;
  scrollbar-color: var(--leaf) transparent;
}
.overlay.is-open .overlay-panel {
  opacity: 1;
  transform: none;
}
/* Exits are shorter and softer than enters. */
.overlay.is-closing .overlay-backdrop,
.overlay.is-closing .overlay-panel { transition-duration: 160ms; }
@media (prefers-reduced-motion: reduce) {
  .overlay-backdrop,
  .overlay-panel { transition-duration: 0ms; }
  .overlay-panel { transform: none; }
}

/* ANCHORED MODE (Paul 2026-07-25): instead of a centred modal, the overlay takes exactly the
   footprint of Panel A, the hero + Recent rail sheet, so it reads as that panel turning over rather
   than as a dialog landing on top of it. app.js measures .panel--feed on open and on resize and
   writes left/top/width/height inline, so it tracks every responsive change. It falls back to the
   centred modal whenever the panel would not fit on screen (narrow viewports, where the panels stack
   and the page scrolls). */
.overlay.is-anchored {
  display: block;
  padding: 0;
}
.overlay.is-anchored .overlay-panel {
  position: fixed;
  display: flex;
  width: auto;          /* the inline left/top/width/height from app.js drive the box */
  max-height: none;
  overflow: hidden;
}
.overlay.is-anchored #overlay-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}
@media (min-width: 1024px) {
  /* Panel A is landscape at desktop, so the stacked art-over-text layout would leave the art
     enormous and the copy stranded. Side by side instead, art filling the height. */
  .overlay.is-anchored #overlay-body {
    display: grid;
    grid-template-columns: minmax(0, 0.9fr) minmax(0, 1fr);
    gap: 20px;
    overflow: hidden;
  }
  .overlay.is-anchored .overlay-art {
    height: 100%;
    aspect-ratio: auto;
  }
  .overlay.is-anchored .overlay-lockup {
    padding: 0;
    padding-right: var(--closeClear);
    /* Centred against the full-height art: short entries sit level with it instead of stranded at
       the top, and a long description still fills the column and scrolls from the top. */
    align-self: center;
    max-height: 100%;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: var(--leaf) transparent;
  }
  .overlay.is-anchored .overlay-title { padding-right: 0; }
}

.overlay-close {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border: 0;
  border-radius: 999px;
  background-color: rgba(15, 24, 20, 0.55);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: var(--text);
  cursor: pointer;
  transition-property: background-color, transform, color;
  transition-duration: var(--dur-tint);
  transition-timing-function: var(--ease-out);
}
.overlay-close:hover { background-color: rgba(15, 24, 20, 0.8); }
.overlay-close:active { transform: scale(0.96); }
@media (prefers-reduced-motion: reduce) {
  .overlay-close:active { transform: none; }
}

.overlay-art {
  width: 100%;
  aspect-ratio: 4 / 3;
  border-radius: var(--radius-inner);
  background-color: rgba(15, 24, 20, 0.5);
  background-size: cover;
  background-position: center;
  outline: 1px solid var(--imgEdge);
  outline-offset: -1px;
}
/* Zero side padding so the text block's left edge lines up exactly with the art's, rather than
   sitting a few pixels inside it. */
.overlay-lockup { padding: 16px 0 0; }
.overlay-title {
  margin: 0 0 4px;
  font-size: 27px;
  font-weight: 700;
  line-height: 1.1;
  color: var(--text);
  text-wrap: balance;
  padding-right: var(--closeClear);
}
.overlay-sci {
  margin: 0 0 12px;
  font-size: 17px;
  color: var(--fog);
}
.overlay-chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 10px;
  margin-bottom: 14px;
}
.overlay-heard {
  font-size: 12.5px;
  color: var(--textQuiet);
  font-variant-numeric: tabular-nums;
}
.overlay-desc {
  margin: 0 0 14px;
  font-size: 15px;
  line-height: 1.55;
  color: rgba(250, 250, 245, 0.86);
  text-wrap: pretty;
}
.overlay-desc.is-missing { color: var(--fog); font-style: italic; }
.overlay-stats {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 20px;
  margin: 0 0 16px;
  padding: 12px 0 0;
  border-top: 1px solid var(--hairline);
  font-size: 12.5px;
  color: var(--textQuiet);
  font-variant-numeric: tabular-nums;
}
.overlay-stats b {
  color: var(--fog);
  font-weight: 600;
}
.overlay-links {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.overlay-link {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  min-height: 40px;
  padding: 8px 14px;
  border-radius: 999px;
  background-color: var(--tint-hover);
  color: var(--cobaltMist);
  font-size: 13.5px;
  font-weight: 600;
  transition-property: background-color, color;
  transition-duration: var(--dur-tint);
  transition-timing-function: var(--ease-out);
}
.overlay-link:hover,
.overlay-link:focus-visible {
  background-color: var(--tint-press);
  text-decoration: none;
}
.overlay-link svg { flex: none; }
