/* ==========================================================================
   News Pulse - shared styles for the public feed and the admin panel.

   Theme: light by default, dark via [data-theme="dark"] on <html>, and dark
   by default when the OS prefers it and the reader has not chosen. Category
   colours come in pairs (--cat / --cat-dark) because no single hue clears
   4.5:1 against both a white and a near-black background - see
   scripts/check-contrast.js.
   ========================================================================== */

:root {
    --bg: #ffffff;
    --bg-elev: #f6f7f9;
    --bg-sunken: #eef0f3;
    --fg: #16191d;
    --fg-muted: #5b6672;
    --fg-faint: #8a95a1;
    --border: #dfe3e8;
    --border-strong: #c6ccd4;
    --accent: #1d4ed8;
    --accent-fg: #ffffff;
    --danger: #b91c1c;
    --ok: #047857;
    --shadow: 0 1px 2px rgba(16, 24, 40, .06), 0 8px 24px rgba(16, 24, 40, .06);
    --radius: 14px;
    --radius-sm: 9px;
    --maxw: 620px;
    /* Height of the fixed top bar. The bar sets this as its OWN height (see
       .topbar), so the value cannot drift out of sync with what renders -
       it used to be a hand-maintained guess at the height of two stacked
       rows. Every 8px saved here is 8px the card gets for story text. */
    --topbar-h: 56px;
    /* Poppins carries headings, Afacad the body. Both are self-hosted (see
       public/css/fonts.css) with real fallback stacks, so a card is readable
       even in the moment before the woff2 lands or if it fails outright. */
    --font-head: Poppins, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    --font: Afacad, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
    /* Kept as an alias: the body used to be set in a serif, and several rules
       still reference it. Afacad is the body face now. */
    --serif: var(--font);
}

html[data-theme="dark"] {
    --bg: #111418;
    --bg-elev: #181c22;
    --bg-sunken: #0d1014;
    --fg: #e8ecf1;
    --fg-muted: #a2adba;
    --fg-faint: #6f7c8a;
    --border: #262c34;
    --border-strong: #39424d;
    --accent: #60a5fa;
    --accent-fg: #0b0e12;
    --danger: #f87171;
    --ok: #34d399;
    --shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px rgba(0, 0, 0, .35);
}

@media (prefers-color-scheme: dark) {
    html:not([data-theme="light"]) {
        --bg: #111418;
        --bg-elev: #181c22;
        --bg-sunken: #0d1014;
        --fg: #e8ecf1;
        --fg-muted: #a2adba;
        --fg-faint: #6f7c8a;
        --border: #262c34;
        --border-strong: #39424d;
        --accent: #60a5fa;
        --accent-fg: #0b0e12;
        --danger: #f87171;
        --ok: #34d399;
        --shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px rgba(0, 0, 0, .35);
    }
}

* { box-sizing: border-box; }

/* [hidden] only implies `display: none`; any explicit display rule beats it.
   Keep this so hiding an element by attribute actually hides it. */
[hidden] { display: none !important; }

html, body {
    margin: 0;
    padding: 0;
    height: 100%;
}

body {
    background: var(--bg);
    color: var(--fg);
    font-family: var(--font);
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
    overscroll-behavior-y: none;
}

a { color: var(--accent); }

/* ---------------------------------------------------------------- top bar */

/* The bar floats over the hero photo rather than reserving space above it,
   which is what lets the image run to the top of the screen. The blur plus
   the scrim below keeps the brand and chips legible over any photo. */
/* The bar is ONE row: logo, then the scrolling category strip, then the
   theme toggle. It used to stack a brand row above a chips row, which wrapped
   the navigation onto two lines and - because the bar floats over the card -
   cost the card 32px of height it needs for the story text.

   Its height is now DECLARED rather than guessed: --topbar-h used to be a
   hardcoded estimate of the rendered height, with nothing keeping the two in
   step, so any padding change silently misaligned the cards below. Setting an
   explicit height makes the token true by construction. */
.topbar {
    position: fixed;
    top: 0; left: 0; right: 0;
    z-index: 20;
    height: calc(var(--topbar-h) + env(safe-area-inset-top));
    padding-top: env(safe-area-inset-top);
    display: flex;
    align-items: center;
    gap: 12px;
    padding-inline: 14px;
    background: color-mix(in srgb, var(--bg) 88%, transparent);
    backdrop-filter: saturate(180%) blur(14px);
    -webkit-backdrop-filter: saturate(180%) blur(14px);
    border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
}

/* Logo and toggle hold their size; only the chip strip flexes and scrolls.
   The brand is the mark alone - no wordmark - so the logo IS the link and
   needs its own accessible name, which lives on the anchor's aria-label. */
.brand {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    text-decoration: none;
}
.brand-logo {
    display: block;
    /* Sized by height so the mark's own 1.478 ratio decides the width; a
       fixed width and height would squash it. */
    height: 30px;
    width: auto;
    flex: 0 0 auto;
}

.theme-toggle {
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    width: 32px; height: 32px;
    border-radius: 50%;
    border: 1px solid var(--border);
    background: var(--bg-elev);
    color: var(--fg-muted);
    cursor: pointer;
}
.theme-toggle:hover { color: var(--fg); border-color: var(--border-strong); }

/* The category strip: takes the space between logo and toggle, scrolls
   horizontally, never wraps. min-width:0 is what actually allows a flex child
   to shrink below its content width and therefore to scroll at all. */
.filters {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    align-items: center;
    /* Centred in the space between the logo and the toggle. Left-aligned,
       the chips bunched against the brand with a wide dead gap after them -
       measured at 1440px the last chip ended 12px short of the toggle while
       everything sat in the first third of the bar.
       `safe` keeps the first chip reachable when the row overflows and has
       to scroll, which plain `center` does not. */
    justify-content: safe center;
    gap: 22px;
    overflow-x: auto;
    scrollbar-width: none;
    -ms-overflow-style: none;
    /* Room for the focus ring on the first/last chip. */
    padding: 3px 2px;
    /* Fades the chips out at the right edge so it is visible that the strip
       continues, rather than the last chip appearing simply cut. */
    mask-image: linear-gradient(to right, #000 calc(100% - 22px), transparent 100%);
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 22px), transparent 100%);
}
.filters::-webkit-scrollbar { display: none; }

/* Plain text, not pills. Each chip used to carry an outline in its OWN
   category colour, so nine categories meant nine coloured outlines competing
   for attention - the bar read as decoration rather than navigation. The
   colour now appears once, on the underline of whichever item is selected. */
.chip {
    font-family: var(--font-head);
    flex: 0 0 auto;
    border: 0;
    background: transparent;
    color: var(--fg-muted);
    padding: 6px 2px;
    font-size: .86rem;
    font-weight: 600;
    letter-spacing: -.01em;
    cursor: pointer;
    white-space: nowrap;
    /* Reserves the underline's height on EVERY chip, so selecting one does
       not shift the row by 2px. */
    border-bottom: 2px solid transparent;
    transition: color .15s, border-color .15s;
}
.chip:hover { color: var(--fg); }

.chip[aria-pressed="true"] {
    color: var(--fg);
    border-bottom-color: var(--chip, var(--accent));
}

/* ------------------------------------------------------------------- feed */

/* One card per screen, snapped. 100dvh (not 100vh) so collapsing mobile
   browser chrome cannot clip a card. The desktop grid at the bottom of this
   file overrides all of it. */
.feed {
    height: 100dvh;
    overflow-y: scroll;
    scroll-snap-type: y mandatory;
    overscroll-behavior-y: contain;
    scroll-behavior: smooth;
    background: var(--bg-sunken);
    /* Snap positions are measured from the top of the scrollport. Without
       this a swipe settled ~10px short of the card boundary, leaving a sliver
       of the previous card visible at the top of every screen. */
    scroll-padding-top: 0;
    scroll-snap-stop: always;
}
.feed:focus { outline: none; }

/* The card is the photo itself, with the story laid over it.
   The previous design stacked a photo block ABOVE a text sheet; measured on
   real content that needed 100-214px more than any phone screen has, so the
   body was silently cut off mid-sentence. Overlaying shares the same space
   between photo and text instead of dividing it, which is what makes a full
   60-word story plus a 4-option poll fit. */
.card {
    position: relative;
    height: 100dvh;
    scroll-snap-align: start;
    scroll-snap-stop: always;
    display: flex;
    flex-direction: column;
    max-width: var(--maxw);
    margin: 0 auto;
    background: var(--bg-sunken);
    overflow: hidden;
    isolation: isolate;
}

/* ---- the photo ---- */

.card-bg {
    position: absolute;
    inset: 0;
    z-index: 0;
}
.card-bg img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    display: block;
}

/* PHONE: the picture fills the whole card and the story sits over it.
   `cover` crops slightly at the sides of a 4:5 image on a ~0.45-ratio screen,
   which is the right trade here: the alternative, `contain`, showed the whole
   image but shrank it to a band and left dead space around it.

   This works only because the phone is served a PORTRAIT image. A wide 2:1
   photo at 390px is 195px tall - 23% of the screen - and no CSS changes that,
   which is why the two shapes are uploaded separately. */
.card-bg-main {
    object-fit: cover;
    object-position: center;
    z-index: 1;
}

/* The blurred filler exists only to fill the letterbox bars left by
   `contain`. The phone uses `cover`, so there are no bars and it would just
   be a second decode of the same file for nothing. Desktop re-enables it. */
.card-bg-fill { display: none; }

/* No photo: the category colour becomes the field, so a card without an
   image keeps exactly the same silhouette rather than looking broken. */
.card-noimg .card-bg {
    background:
        radial-gradient(120% 90% at 22% 8%,
            color-mix(in srgb, var(--cat, var(--accent)) 88%, transparent) 0%,
            transparent 68%),
        linear-gradient(165deg,
            color-mix(in srgb, var(--cat, var(--accent)) 72%, #0b0e12) 0%,
            #0b0e12 100%);
}

/* ---- the scrim ---- */

/* Two layers doing two jobs:
     - a flat floor over the lower half, which is what GUARANTEES the text is
       legible over a white or otherwise bright photo (a gradient alone
       promises nothing when the image underneath is already light);
     - a gradient above it so the photo is still visible at the top and the
       transition does not read as a hard band. */
.card-scrim {
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    /* Nearly clear over the top 40%, where the photograph is the point, then
       darkening toward the bottom where the story and poll sit. A uniform
       wash would keep the text readable too, but it flattens the picture
       everywhere - and the picture is why a mobile image is uploaded at all.
       The floor on .byline::after is what actually GUARANTEES contrast; this
       gradient only shapes the image. */
    background:
        linear-gradient(to bottom,
            rgba(8, 11, 15, .06) 0%,
            rgba(8, 11, 15, .10) 34%,
            rgba(8, 11, 15, .30) 52%,
            rgba(8, 11, 15, .66) 74%,
            rgba(8, 11, 15, .86) 100%);
}

/* A second, LOCAL floor behind the text itself.
   The gradient above shapes the picture; it cannot guarantee legibility,
   because what is underneath is an arbitrary photograph - measured against a
   pure-white image the headline fell to 2.68:1 and the source name to 2.15:1
   against a 4.5:1 requirement. This panel sits behind the byline, headline
   and story only, so the top of the photo stays visible while the region
   that carries text has a floor dark enough to hold the ratio.
   Verified by scripts/check-overlay-contrast.js against a white image. */
.card-main {
    position: relative;
}

/* The floor is anchored to the BYLINE - the first real text in the stack -
   not to .card-main, which begins directly under the top bar. Anchored to
   .card-main it darkened everything from the top of the card down, including
   the ~290px of photograph above the headline, and the picture came out grey
   and washed out. */
.byline { position: relative; }
.byline::after {
    content: "";
    position: absolute;
    /* Bleeds to the card's edges so there is no visible seam, and stretches
       from just above the byline all the way to the bottom of the card -
       everything below this point is text or poll.

       The fade is a FIXED 44px band, not a percentage: as a percentage the
       transparent zone grew with the panel, and on a short screen the source
       name landed inside it at 2.85:1 against a 4.5:1 requirement. A fixed
       band gives the text below it the same floor at every card height. */
    inset: -44px -15px auto;
    height: 100vh;
    z-index: -1;
    background:
        linear-gradient(to bottom,
            rgba(8, 11, 15, 0) 0,
            rgba(8, 11, 15, .58) 44px,
            rgba(8, 11, 15, .70) 130px,
            rgba(8, 11, 15, .74) 100%);
    pointer-events: none;
}

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

.card-body {
    position: relative;
    z-index: 2;
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    padding: calc(var(--topbar-h) + env(safe-area-inset-top) + 10px)
             15px
             calc(14px + env(safe-area-inset-bottom));
}

.card-top {
    flex: 0 0 auto;
    margin-bottom: 8px;
}

/* The poll is pinned to the bottom (its own margin-top:auto) so that polls
   sit at a consistent height. On a short story that leaves slack above it,
   which as a single block at the top read as a hole punched in the card.
   Splitting the slack - a flexible spacer above the byline as well as the
   auto margin below the story - centres the text in the free space instead,
   so the card looks composed rather than broken. */
.card-main {
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
}

/* The spacer sets how far the story sits below the photo.
   It used to be `flex: 1 1 auto`, splitting the slack evenly so the text
   floated in the middle of the card - which made sense when the photo was
   centred behind the text. With the photo pinned to the TOP, that same
   spacer opened a 99px band of blurred filler between the picture and the
   byline. A small fixed gap keeps the story visually attached to the photo
   above it, and .poll's margin-top:auto still holds the poll at the bottom. */
.card-main::before {
    content: "";
    /* Takes ALL the slack, pushing the story to the bottom of the card.
       That is right again now the picture fills the whole screen: the top of
       the card is the photograph, and the text belongs beneath it. It was
       capped at 20% while the photo was a band at the top, because then the
       slack was a visible dead gap between picture and headline - with a
       full-bleed image there is no gap, only more photo. */
    flex: 1 1 auto;
    min-height: 0;
}

.hero-pill {
    display: inline-block;
    padding: 4px 11px;
    border-radius: 999px;
    font-size: .68rem;
    font-weight: 800;
    letter-spacing: .06em;
    text-transform: uppercase;
    color: #fff;
    background: var(--cat, var(--accent));
    box-shadow: 0 2px 10px rgba(0, 0, 0, .35);
}

/* ---------------------------------------------------------------- byline */

/* With the source byline gone the row holds only the share button, which
   the spacer pushes to the right. */
.byline {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 10px;
    flex: 0 0 auto;
}
.byline-spacer { flex: 1 1 auto; }

.avatar {
    flex: 0 0 auto;
    width: 34px;
    height: 34px;
    border-radius: 50%;
    display: grid;
    place-items: center;
    font-size: .64rem;
    font-weight: 800;
    letter-spacing: -.01em;
    color: #fff;
    background: linear-gradient(145deg,
        hsl(var(--hue, 220) 70% 56%),
        hsl(var(--hue, 220) 74% 42%));
    box-shadow: 0 0 0 2px rgba(255, 255, 255, .18);
    user-select: none;
}

.byline-text {
    display: flex;
    flex-direction: column;
    gap: 1px;
    min-width: 0;
    flex: 1 1 auto;
}

/* Text sits on a photo, so it is light in BOTH themes rather than following
   --fg: over a dark scrim, dark text would be unreadable in light mode. */
.byline-name {
    display: flex;
    align-items: center;
    gap: 5px;
    font-size: .88rem;
    font-weight: 750;
    color: #fff;
    letter-spacing: -.01em;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    text-shadow: 0 1px 3px rgba(0, 0, 0, .5);
}
.byline-name .tick { flex: 0 0 auto; color: #4db2ff; }

.byline-time {
    font-size: .74rem;
    color: rgba(255, 255, 255, .72);
    text-shadow: 0 1px 3px rgba(0, 0, 0, .5);
}

.share {
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    width: 34px;
    height: 34px;
    border-radius: 10px;
    border: 1px solid rgba(255, 255, 255, .28);
    background: rgba(255, 255, 255, .12);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    color: #fff;
    cursor: pointer;
    transition: background .15s, border-color .15s;
}
.share:hover { background: rgba(255, 255, 255, .22); border-color: rgba(255, 255, 255, .45); }
.share:active { transform: scale(.95); }

/* ------------------------------------------------------ headline and body */

.card h2 {
    font-family: var(--font-head);
    font-size: clamp(1.16rem, 4.7vw, 1.42rem);
    line-height: 1.2;
    letter-spacing: -.025em;
    margin: 0 0 5px;
    font-weight: 780;
    color: #fff;
    text-shadow: 0 1px 4px rgba(0, 0, 0, .55);
    flex: 0 0 auto;
}

/* The headline links to the story's own page but must still look like a
   headline, not a hyperlink. */
.card h2 .card-link { color: inherit; text-decoration: none; }
.card h2 .card-link:hover { text-decoration: underline; text-underline-offset: 3px; }

.end-links a { color: inherit; }

.dateline {
    margin: 0 0 9px;
    font-size: .74rem;
    color: rgba(255, 255, 255, .68);
    font-variant-numeric: tabular-nums;
    text-shadow: 0 1px 3px rgba(0, 0, 0, .5);
    flex: 0 0 auto;
}

.card .body {
    font-family: var(--serif);
    font-size: clamp(.93rem, 3.5vw, 1.02rem);
    line-height: 1.5;
    color: rgba(255, 255, 255, .95);
    text-shadow: 0 1px 4px rgba(0, 0, 0, .55);
    margin: 0 0 11px;
    /* The body is the one element that can lose height gracefully, so it
       yields before the poll does - a reader can scroll a story, but an
       option they cannot see is one they will not vote for. */
    flex: 0 1 auto;
    min-height: 0;
    /* NOT a scroll container by default. It used to carry overflow-y:auto and
       overscroll-behavior:contain unconditionally, and since the pointer sits
       on this paragraph over most of a card, every swipe was captured by an
       element that could not scroll - `contain` then stopped the gesture ever
       reaching .feed, so the feed did not move at all. */
    overflow: visible;
    scrollbar-width: none;
}
.card .body::-webkit-scrollbar { display: none; }

/* Only a story that ACTUALLY overflows becomes scrollable, and only then does
   it hold on to the gesture. feed.js sets this class from the measured
   overflow, the same signal that drives the fade. */
.card .body.is-clipped {
    overflow-y: auto;
    overscroll-behavior: contain;
    mask-image: linear-gradient(to bottom, #000 calc(100% - 24px), transparent 100%);
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 24px), transparent 100%);
}

.readmore {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font-size: .79rem;
    font-weight: 600;
    text-decoration: none;
    margin: 0 0 11px;
    color: rgba(255, 255, 255, .8);
    text-shadow: 0 1px 3px rgba(0, 0, 0, .5);
    align-self: flex-start;
    flex: 0 0 auto;
}
.readmore:hover { color: #fff; }

/* ------------------------------------------------------------------- poll */

/* The poll gets its own solid surface rather than sitting transparent on the
   photo: options are tap targets and must read as controls, and a percentage
   bar over a photograph is unreadable once results are revealed. */
.poll {
    margin-top: auto;
    flex: 0 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    border: 1px solid rgba(255, 255, 255, .14);
    background: rgba(12, 16, 22, .72);
    -webkit-backdrop-filter: saturate(150%) blur(14px);
    backdrop-filter: saturate(150%) blur(14px);
    border-radius: var(--radius);
    padding: 12px;
    box-shadow: 0 6px 24px rgba(0, 0, 0, .3);
}

.poll h3 {
    font-family: var(--font-head);
    flex: 0 0 auto;
    margin: 0 0 9px;
    font-size: .92rem;
    font-weight: 700;
    line-height: 1.3;
    color: #fff;
}

/* The question stays put; the options scroll under it when a 4-option poll
   cannot fit a very small screen. */
.options {
    flex: 1 1 auto;
    min-height: 0;
    /* Same reasoning as .body above: a list that fits must not capture the
       swipe. It becomes a scroller only when it overflows. */
    overflow: visible;
    scrollbar-width: none;
    display: flex;
    flex-direction: column;
    gap: 7px;
    padding: 1px;
}
.options::-webkit-scrollbar { display: none; }

/* Only a list that actually scrolls is faded, so a plain two-option poll is
   never dimmed for no reason. */
.options.is-clipped {
    overflow-y: auto;
    overscroll-behavior: contain;
    mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent 100%);
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent 100%);
}

.opt {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 10px;
    width: 100%;
    /* Never squashed below its own text: the container scrolls, the buttons
       keep their height. */
    flex: 0 0 auto;
    text-align: left;
    font: inherit;
    font-size: .9rem;
    font-weight: 550;
    color: #fff;
    background: rgba(255, 255, 255, .1);
    border: 1.5px solid rgba(255, 255, 255, .26);
    border-radius: var(--radius-sm);
    padding: 9px 12px;
    cursor: pointer;
    overflow: hidden;
    transition: border-color .15s, background .15s, transform .08s;
}
.opt:hover:not(:disabled) { background: rgba(255, 255, 255, .18); border-color: rgba(255, 255, 255, .5); }
.opt:active:not(:disabled) { transform: scale(.99); }
.opt:disabled { cursor: default; }

/* The result bar is painted behind the label once results are revealed. */
.opt .fill {
    position: absolute;
    inset: 0 auto 0 0;
    width: var(--pct, 0%);
    background: color-mix(in srgb, var(--cat, var(--accent)) 62%, transparent);
    transition: width .5s cubic-bezier(.22,.61,.36,1);
    z-index: 0;
}
.opt .label, .opt .pct { position: relative; z-index: 1; }
.opt .pct { font-variant-numeric: tabular-nums; font-weight: 700; color: rgba(255, 255, 255, .9); }

.opt.chosen {
    border-color: #fff;
    background: rgba(255, 255, 255, .22);
}
.opt.chosen .label::after {
    content: " ✓";
    color: #fff;
    font-weight: 800;
}

.poll-note {
    flex: 0 0 auto;
    margin: 9px 0 0;
    font-size: .76rem;
    color: rgba(255, 255, 255, .75);
    display: flex;
    align-items: center;
    gap: 6px;
}
.poll-note .lock { opacity: .8; }
.poll-note strong { color: #fff; font-variant-numeric: tabular-nums; }

/* The infinite-scroll sentinel: a zero-height marker after the last card.
   It must not occupy a grid cell of its own, or the last row would show an
   empty gap beside the final card. */
#feedSentinel {
    height: 1px;
    grid-column: 1 / -1;
    pointer-events: none;
}

/* ------------------------------------------------------- states / helpers */

.state {
    min-height: 100dvh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 14px;
    color: var(--fg-muted);
    text-align: center;
    padding: 40px 24px;
}
.state h2 { color: var(--fg); margin: 0; font-size: 1.15rem; }
.state p { margin: 0; max-width: 34ch; }

.spinner {
    width: 26px; height: 26px;
    border: 2.5px solid var(--border-strong);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

.end {
    min-height: 45dvh;
    scroll-snap-align: start;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    color: var(--fg-faint);
    font-size: .88rem;
    text-align: center;
    padding: 30px 20px calc(40px + env(safe-area-inset-bottom));
}

.toast {
    position: fixed;
    left: 50%;
    bottom: calc(22px + env(safe-area-inset-bottom));
    transform: translate(-50%, 140%);
    background: var(--fg);
    color: var(--bg);
    padding: 10px 16px;
    border-radius: 999px;
    font-size: .86rem;
    font-weight: 600;
    z-index: 60;
    max-width: min(90vw, 420px);
    text-align: center;
    transition: transform .28s cubic-bezier(.22,.61,.36,1), visibility 0s linear .28s;
    pointer-events: none;
    /* Translating it down is not enough on its own: an EMPTY toast is only
       its padding tall, so 140% of that leaves a dark sliver peeking above
       the bottom edge of every page. Hidden until shown, and kept visible
       until the slide-out finishes. */
    visibility: hidden;
}
.toast.show { transform: translate(-50%, 0); visibility: visible; transition-delay: 0s; }

.btn {
    font: inherit;
    font-weight: 650;
    border-radius: var(--radius-sm);
    border: 1px solid var(--border-strong);
    background: var(--bg-elev);
    color: var(--fg);
    padding: 9px 15px;
    cursor: pointer;
}
.btn:hover { border-color: var(--fg-faint); }
.btn-primary {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-fg);
}
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }

/* Very short viewports - a phone held sideways - cannot fit a hero, a
   headline, a body and a 4-option poll at any type size a person would want
   to read. This is the one case where the card gives up the one-screen
   promise and scrolls as a whole. Snapping is relaxed with it: snapping a
   card that is taller than the viewport strands the reader at its top with
   the poll permanently out of reach. */
/* Very short viewports - a phone held sideways - cannot fit a headline, a
   story and a 4-option poll at any type size a person would want to read.
   This is the one case where the card gives up the one-screen promise and
   scrolls as a whole. Snapping is relaxed with it: snapping a card taller
   than the viewport strands the reader at its top with the poll out of
   reach. Written BEFORE the max-height:700px block below, so where both
   match the later one wins on shared properties - keep that order in mind
   when editing either. */
@media (max-height: 460px) {
    .feed { scroll-snap-type: none; }

    .card {
        height: auto;
        min-height: 100dvh;
        scroll-snap-align: none;
        overflow: visible;
    }

    /* The photo becomes a fixed band again rather than a full-bleed field:
       over a card that is now taller than the screen, a background image
       would scroll away from the text it is meant to sit behind. */
    .card-bg { position: absolute; inset: 0; }

    /* Everything scrolls with the page now, so nothing needs its own
       scroller - an inner scroller here would be a trap. */
    .card .body {
        flex: 0 0 auto;
        overflow: visible;
        mask-image: none;
        -webkit-mask-image: none;
    }
    .poll { flex: 0 0 auto; }
    .options { overflow: visible; }
}

/* Short screens - a 640px-tall phone - with a 3- or 4-option poll are the
   worst case the API allows. Measured: even with the photo as the background
   rather than a separate block, a 55-word story plus four long options is
   ~35px over on a 360x640 screen. The type tightens here, and the option
   list keeps its scroll-with-fade as the last resort, so the STORY stays
   fully visible and only the options move. */
@media (max-height: 700px) {
    .card-body {
        padding: calc(var(--topbar-h) + env(safe-area-inset-top) + 8px)
                 13px
                 calc(11px + env(safe-area-inset-bottom));
    }
    .card-top { margin-bottom: 6px; }
    .card h2 { font-size: clamp(1.06rem, 4.5vw, 1.24rem); margin: 0 0 4px; }
    .byline { margin-bottom: 8px; }
    .avatar { width: 30px; height: 30px; font-size: .6rem; }
    .byline-name { font-size: .84rem; }
    .share { width: 31px; height: 31px; }
    .dateline { margin: 0 0 7px; font-size: .71rem; }
    .card .body { font-size: .89rem; line-height: 1.46; margin: 0 0 8px; }
    .readmore { margin: 0 0 8px; font-size: .75rem; }
    .poll { padding: 10px; }
    .poll h3 { margin: 0 0 7px; font-size: .87rem; }
    .options { gap: 6px; }
    .opt { padding: 7px 11px; font-size: .86rem; }
    .poll-note { margin: 7px 0 0; font-size: .72rem; }
}

/* ---------------------------------------------------------------- desktop */

/* Three cards per row. Below this width the feed stays a single snapped
   column - the overlay card is designed for a phone screen and a 3-up grid
   of them would be unreadably narrow.

   On desktop the card reverts to PHOTO ON TOP rather than the overlay: a grid
   tile is short and wide, so text over a background image reads as noise,
   and there is no vertical-space problem here for the overlay to solve. */
@media (min-width: 900px) {
    .feed {
        display: grid;
        grid-template-columns: repeat(3, minmax(0, 1fr));
        /* The page scrolls here, not the feed element. Left as overflow-y:
           scroll (the phone value) the feed became a scroll container that
           could not scroll - it was exactly as tall as its content - and it
           swallowed every wheel event, so nothing moved at all. */
        overflow: visible;
        overscroll-behavior: auto;
        /* `auto` rows, and a height that is NOT the viewport: the phone rules
           set height:100dvh, which gave the grid a fixed box to divide among
           its rows instead of sizing each row to its content - every card
           came out 119px tall with its story spilling out. */
        grid-auto-rows: auto;
        align-content: start;
        height: auto;
        min-height: 100dvh;
        gap: 20px;
        padding: calc(var(--topbar-h) + 22px) 22px 40px;
        max-width: 1320px;
        margin: 0 auto;
        /* Snapping is for the one-card-per-screen phone view; in a grid it
           would fight every scroll. */
        scroll-snap-type: none;
        scroll-behavior: auto;
        background: var(--bg);
    }

    .card {
        height: auto;
        /* The card must be at least as tall as its row so the polls in a row
           line up; `stretch` is the grid default but is spelled out here
           because the phone rules above set an explicit height. */
        align-self: stretch;
        max-width: none;
        margin: 0;
        scroll-snap-align: none;
        scroll-snap-stop: normal;
        border: 1px solid var(--border);
        border-radius: var(--radius);
        overflow: hidden;
        background: var(--bg);
        box-shadow: var(--shadow);
    }

    /* The phone layout gives these min-height:0 so the story can scroll
       inside a fixed-height card. Here the card has no fixed height, so
       min-height:0 would let them collapse to nothing rather than ask for
       the height their content needs - which is exactly what happened. */
    .card-body,
    .card-main {
        min-height: auto;
    }

    /* Photo back on top, as a fixed-ratio band. 16/9 made the card 932px -
       taller than the screen, so only one row was ever visible and the point
       of a grid was lost. A shallower crop keeps the photo present while
       leaving room for two rows per screen. */
    .card-bg {
        position: relative;
        inset: auto;
        aspect-ratio: 2 / 1;
        width: 100%;
        flex: 0 0 auto;
        background: var(--bg-sunken);
    }

    /* A card with no photo does not need a big empty colour field: it gets a
       slim band instead, so the story starts near the top of the tile. */
    .card-noimg .card-bg { aspect-ratio: 7 / 1; }

    /* The image must be clipped BY the ratio box, not size it.
       The phone rules give the img height:100%, which resolved against the
       image's own intrinsic height once .card-bg became position:relative -
       a 1024x1536 portrait photo stretched the band to 615px and pushed the
       poll off the tile. Pinning the img to the box makes object-fit:cover
       crop to the ratio, which is what it was always meant to do. */
    .card-bg img {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
    }

    /* Desktop shows the WHOLE photo inside a fixed 2:1 band, so `contain` -
       the opposite of the phone, which crops to fill the screen. A 2:1 upload
       fills this band exactly; any other shape leaves slack, and centring is
       what looks composed in a band with no text over it. */
    .card-bg-main {
        object-fit: contain;
        object-position: center;
    }

    /* The blurred copy fills the slack that `contain` leaves. It is hidden on
       the phone (no bars there) so it has to be switched back on here. */
    .card-bg-fill {
        display: block;
        object-fit: cover;
        object-position: center;
        transform: scale(1.12);
        filter: blur(18px) saturate(130%) brightness(.9);
        z-index: 0;
    }
    /* No scrim over a photo that no longer has text on it, and no text floor
       either - on desktop the story sits on the card's own surface. */
    .card-scrim { display: none; }
    .byline::after { display: none; }
    .card-main::before { display: none; }

    .card-body {
        padding: 13px 14px 14px;
        flex: 1 1 auto;
    }
    .card-top {
        /* The pill floats up onto the photo's lower edge. */
        margin: -29px 0 9px;
    }

    /* A tile is narrower than a phone card, so the headline wants a smaller
       size than the phone clamp's upper bound. */
    .card h2 {
        font-size: 1.12rem;
        line-height: 1.25;
        margin: 0 0 4px;
    }
    .card .body {
        font-size: .92rem;
        line-height: 1.5;
        margin: 0 0 10px;
    }
    .dateline { margin: 0 0 8px; }
    .readmore { margin: 0 0 12px; }
    .byline { margin-bottom: 9px; }
    .poll { padding: 11px; }
    .poll h3 { font-size: .88rem; margin: 0 0 8px; }
    .opt { font-size: .86rem; padding: 8px 11px; }
    .options { gap: 6px; }
    .poll-note { font-size: .73rem; margin: 8px 0 0; }

    /* Text is on the card's own surface again, so it follows the theme
       instead of being forced light for a photo. */
    .card h2, .byline-name, .poll h3 { color: var(--fg); text-shadow: none; }
    .card .body { color: var(--fg); text-shadow: none; }
    .dateline, .byline-time { color: var(--fg-faint); text-shadow: none; }
    .readmore { color: var(--fg-muted); text-shadow: none; }
    .readmore:hover { color: var(--accent); }

    .share {
        border-color: var(--border);
        background: var(--bg-elev);
        color: var(--fg-muted);
        -webkit-backdrop-filter: none;
        backdrop-filter: none;
    }
    .share:hover { background: var(--bg-sunken); border-color: var(--border-strong); color: var(--fg); }

    .avatar { box-shadow: none; }

    .poll {
        border-color: var(--border);
        background: var(--bg-elev);
        -webkit-backdrop-filter: none;
        backdrop-filter: none;
        box-shadow: none;
    }
    .poll-note { color: var(--fg-muted); }
    .poll-note strong { color: var(--fg); }

    .opt {
        color: var(--fg);
        background: var(--bg);
        border-color: var(--border-strong);
    }
    .opt:hover:not(:disabled) {
        background: var(--bg);
        border-color: var(--cat, var(--accent));
    }
    .opt .fill { background: color-mix(in srgb, var(--cat, var(--accent)) 20%, transparent); }
    .opt .pct { color: var(--fg-muted); }
    .opt.chosen {
        border-color: var(--cat, var(--accent));
        background: color-mix(in srgb, var(--cat, var(--accent)) 8%, var(--bg));
    }
    .opt.chosen .label::after { color: var(--cat, var(--accent)); }

    /* In a grid the whole card is as tall as its row, so nothing inside needs
       its own scroller - and an inner scroller in a tile is a trap. The poll
       still sits at the bottom via its margin-top:auto, which is what keeps
       the polls in a row aligned. */
    .card .body {
        flex: 0 0 auto;
        overflow: visible;
        mask-image: none;
        -webkit-mask-image: none;
    }
    .options { overflow: visible; }
    .poll { flex: 0 0 auto; }

    /* The end-of-feed marker spans the full grid rather than sitting in one
       column like a card. */
    .end { grid-column: 1 / -1; min-height: 0; padding: 26px 20px 10px; }
    .state { grid-column: 1 / -1; min-height: 50vh; }
}

/* Wide desktops: the same three columns, just roomier. Deliberately NOT four
   or more - a 60-word story in a quarter-width tile is a stripe of text. */
@media (min-width: 1500px) {
    .feed { gap: 24px; max-width: 1440px; }
}

@media (prefers-reduced-motion: reduce) {
    * { animation-duration: .01ms !important; transition-duration: .01ms !important; }
    .feed { scroll-behavior: auto; }
}

/* =========================================================================
   Server-rendered pages: /story/<slug> and /category/<id>
   =========================================================================
   These are ordinary scrolling documents, NOT the snap feed. .feed is a
   scroll-snap container with a fixed viewport height; reusing it here would
   trap a full article in a one-screen box. So .story-page and .hub-page get
   their own flow layout and share only the design tokens. */

body.ssr {
    /* The snap feed locks body scrolling; an article page must scroll. */
    overflow-y: auto;
    height: auto;
}

.story-page,
.hub-page {
    max-width: var(--maxw);
    margin: 0 auto;
    padding: calc(var(--topbar-h) + 24px) 18px 64px;
}

.story-meta,
.hub-item time {
    display: flex;
    align-items: center;
    gap: 10px;
    font-family: var(--font-head);
    font-size: .78rem;
    letter-spacing: .04em;
    text-transform: uppercase;
    color: var(--fg-faint);
}

.story-cat {
    font-weight: 600;
    color: var(--accent);
    text-decoration: none;
}
.story-cat:hover { text-decoration: underline; }

.story-title {
    font-family: var(--font-head);
    font-size: clamp(1.55rem, 5vw, 2.1rem);
    line-height: 1.22;
    font-weight: 700;
    margin: 10px 0 18px;
    color: var(--fg);
}

.story-figure {
    margin: 0 0 20px;
    border-radius: var(--radius);
    overflow: hidden;
    background: var(--bg-sunken);
}
.story-figure img {
    display: block;
    width: 100%;
    height: auto;
}

.story-body {
    font-family: var(--font);
    font-size: 1.16rem;
    line-height: 1.65;
    color: var(--fg);
}

.story-source {
    margin-top: 18px;
    font-size: .92rem;
    color: var(--fg-muted);
}

.story-poll {
    margin-top: 28px;
    padding: 18px;
    background: var(--bg-elev);
    border: 1px solid var(--border);
    border-radius: var(--radius);
}
.story-poll-q {
    font-family: var(--font-head);
    font-size: 1.05rem;
    font-weight: 600;
    margin: 0 0 12px;
    color: var(--fg);
}
.story-options { list-style: none; margin: 0; padding: 0; }
.story-option {
    padding: 10px 14px;
    margin-bottom: 8px;
    background: var(--bg);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    font-family: var(--font);
    color: var(--fg);
}
.story-poll-note {
    margin: 12px 0 0;
    font-size: .86rem;
    color: var(--fg-muted);
}

.story-back { margin-top: 32px; font-family: var(--font-head); font-size: .95rem; }

/* ---- category hub ---- */

.hub-title {
    font-family: var(--font-head);
    font-size: clamp(1.7rem, 5.5vw, 2.3rem);
    font-weight: 700;
    margin: 0 0 10px;
    color: var(--fg);
}
.hub-desc {
    font-family: var(--font);
    font-size: 1.05rem;
    line-height: 1.55;
    color: var(--fg-muted);
    margin: 0 0 26px;
}
.hub-list { list-style: none; margin: 0; padding: 0; }
.hub-item {
    padding: 16px 0;
    border-bottom: 1px solid var(--border);
}
.hub-link {
    display: block;
    font-family: var(--font-head);
    font-size: 1.12rem;
    font-weight: 600;
    line-height: 1.35;
    color: var(--fg);
    text-decoration: none;
    margin-bottom: 6px;
}
.hub-link:hover { color: var(--accent); }
.hub-empty { color: var(--fg-muted); font-family: var(--font); }

/* ---- live poll on the story page ----
   story.js swaps the static option list for the same buttons the feed uses.
   The feed's .opt is styled white-on-photo for the phone card; on this page
   it sits on the page surface at every width, so it takes the theme colours
   the desktop grid uses. Scoped to .story-poll so the feed is untouched. */

.story-poll .options { display: flex; flex-direction: column; gap: 8px; overflow: visible; }
.story-poll .opt {
    font-size: 1rem;
    padding: 11px 14px;
    color: var(--fg);
    background: var(--bg);
    border-color: var(--border-strong);
}
.story-poll .opt:hover:not(:disabled) { background: var(--bg); border-color: var(--cat, var(--accent)); }
.story-poll .opt .fill { background: color-mix(in srgb, var(--cat, var(--accent)) 20%, transparent); }
.story-poll .opt .pct { color: var(--fg-muted); }
.story-poll .opt.chosen {
    border-color: var(--cat, var(--accent));
    background: color-mix(in srgb, var(--cat, var(--accent)) 8%, var(--bg));
}
.story-poll .opt.chosen .label::after { color: var(--cat, var(--accent)); }
.story-poll .poll-note { margin: 12px 0 0; font-size: .86rem; color: var(--fg-muted); }
.story-poll .poll-note strong { color: var(--fg); }

/* ---- share bar ---- */

.share-bar {
    margin-top: 28px;
    padding-top: 22px;
    border-top: 1px solid var(--border);
}
.share-title {
    font-family: var(--font-head);
    font-size: 1rem;
    font-weight: 600;
    margin: 0 0 12px;
    color: var(--fg);
}
.share-links { display: flex; flex-wrap: wrap; gap: 8px; }
.share-btn {
    display: inline-flex;
    align-items: center;
    min-height: 40px;
    padding: 8px 14px;
    font: inherit;
    font-family: var(--font-head);
    font-size: .9rem;
    font-weight: 600;
    color: var(--fg);
    background: var(--bg-elev);
    border: 1px solid var(--border-strong);
    border-radius: 999px;
    text-decoration: none;
    cursor: pointer;
    transition: border-color .15s, background .15s;
}
.share-btn:hover { border-color: var(--accent); background: var(--bg-sunken); }
.share-native, .share-copy {
    background: var(--accent);
    border-color: var(--accent);
    color: var(--accent-fg);
}
.share-native:hover, .share-copy:hover { background: var(--accent); filter: brightness(1.08); }
.share-short {
    margin: 14px 0 0;
    font-size: .9rem;
    color: var(--fg-muted);
    overflow-wrap: anywhere;
}
.share-copy-short {
    font: inherit;
    font-size: .82rem;
    font-weight: 600;
    margin-left: 4px;
    padding: 3px 10px;
    color: var(--accent);
    background: none;
    border: 1px solid var(--border-strong);
    border-radius: 999px;
    cursor: pointer;
}

/* ---- related stories, footer, prose pages ---- */

.story-more { margin-top: 32px; }
.story-more-title {
    font-family: var(--font-head);
    font-size: 1.05rem;
    font-weight: 600;
    margin: 0 0 4px;
    color: var(--fg);
}

.site-foot {
    max-width: var(--maxw);
    margin: 0 auto;
    padding: 20px 18px calc(32px + env(safe-area-inset-bottom));
    display: flex;
    flex-wrap: wrap;
    gap: 6px 18px;
    border-top: 1px solid var(--border);
    font-family: var(--font-head);
    font-size: .88rem;
}
.site-foot a { color: var(--fg-muted); text-decoration: none; }
.site-foot a:hover { color: var(--accent); }

.prose p { font-family: var(--font); font-size: 1.05rem; line-height: 1.65; color: var(--fg); }
.prose h2 {
    font-family: var(--font-head);
    font-size: 1.15rem;
    font-weight: 600;
    margin: 28px 0 8px;
    color: var(--fg);
}
