/* The Delta Desk — design tokens and component styles.
 *
 * This file is the single source of truth for the look of the public site. It
 * is served as a static asset, so it is cached at the edge and is not re-sent
 * with every Worker response the way inline <style> was.
 *
 * Structure, in order:
 *   1. Tokens          — every colour, size and rhythm value, named
 *   2. Dark mode       — the same tokens, inverted
 *   3. Base            — element defaults
 *   4. Components      — card, pill, feed, rail, actions
 *   5. Desktop         — the one breakpoint
 *
 * The rule this file exists to enforce: components reference tokens, never
 * literal values. A redesign should be a diff of section 1 alone. If you find
 * yourself typing a hex code below section 2, a token is missing.
 *
 * Fonts are loaded from headTags() in src/index.js, not @import-ed here — an
 * @import blocks rendering until it resolves, a <link rel=preconnect> plus a
 * <link> in <head> does not.
 */

/* ---------- 1. Tokens ---------- */

/* --measure is TYPED (2026-08-30). syntax "<length>" makes the browser
 * COMPUTE the ch value on the element that declares it, and descendants
 * inherit the computed pixel value rather than the text "62ch". This kills
 * the whole class of bug the whym drift was: ch is font-relative, so the
 * untyped token computed a different width on every element that used it
 * (548px on the 14px whym, 430px on an 11px meta row, 587px on the 15px
 * desktop summary). Typed, body does the computing once and every consumer
 * gets the same number — one right edge by construction. Browsers without
 * @property (Firefox <128, Safari <16.4) fall back to per-element ch:
 * prose keeps its measure, utility rows cap narrower; cosmetic, accepted.
 * initial-value must be font-independent per spec, hence 0px; it is never
 * reached because :root and body both declare the token. */
@property --measure {
  syntax: "<length>";
  inherits: true;
  initial-value: 0px;
}

:root {
  /* Neutrals. Warm greige, chosen over the earlier cream because a grey ramp
   * has a genuine dark-mode counterpart and a yellow paper does not. These
   * move together — a greige background with brown-tinted body text is the
   * specific mistake that reads as broken without being nameable. */
  --paper:         #F4F2EF;  /* page and card ground */
  --paper-raised:  #E9E6E1;  /* why-it-matters band, neutral pill */
  --ink:           #1B1A18;  /* headlines */
  --ink-body:      #4F4C48;  /* summary prose */
  --ink-muted:     #74706A;  /* source line, tier, counts */
  --ink-faint:     #9A968F;  /* timestamps, scroll affordances */
  --rule:          #E2DFDA;  /* hairlines inside a card */
  --rule-strong:   #DCD8D2;  /* control outlines */

  /* Brand. Crimson is spent on three things only: links, the active/liked
   * state, and the why-it-matters rule. It is deliberately NOT available as a
   * topic hue — the moment a category is also crimson, a reader cannot tell a
   * label from an action. */
  --accent:        #D31145;
  --accent-deep:   #A50D36;  /* small text, and text on --accent-wash; #D31145
                              * at 9-10px uppercase loses too much contrast */
  --accent-wash:   #FBEEF1;
  --accent-border: #F0C6D1;
  --on-accent:     #FFFFFF;

  /* Semantic, kept separate from brand on purpose. In business coverage red
   * already means "down". When a price ticker eventually exists, this token
   * changes and the brand does not. Two names, two meanings, even though the
   * values are close today. */
  --negative:      #A32D2D;
  --positive:      #3B6D11;

  /* Topic hues. A closed set: colour is a coarse scanning aid, not an
   * identifier, so topics share hues by design. Past roughly seven, muted
   * colours stop being reliably distinguishable at pill size, which is why
   * this list is finite and adding a topic never adds a colour.
   * Keys must stay in step with TOPIC_HUES in src/index.js. */
  --topic-slate-bg:   #E7ECF1;  --topic-slate-text:   #2F4A63;
  --topic-moss-bg:    #EBEEE3;  --topic-moss-text:    #44562C;
  --topic-ochre-bg:   #F3ECDE;  --topic-ochre-text:   #6E5320;
  --topic-plum-bg:    #EDE7F0;  --topic-plum-text:    #4F3557;
  --topic-clay-bg:    #F1E9E6;  --topic-clay-text:    #6A4238;
  --topic-teal-bg:    #E2EDEA;  --topic-teal-text:    #2C5249;
  --topic-neutral-bg:   var(--paper-raised);
  --topic-neutral-text: var(--ink-body);

  /* Type. Serif carries the headline and the editorial voice; sans carries
   * reportage and all UI. That split is load-bearing: it is how a reader tells
   * fact from interpretation without reading a label. */
  --font-serif: "Fraunces", Georgia, "Times New Roman", serif;
  --font-sans:  "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;

  --fs-display:  1.6875rem;  /* 27px — desktop reading pane headline */
  --fs-headline: 1.1875rem;  /* 19px — card headline */
  --fs-lede:     0.9375rem;  /* 15px — desktop summary */
  --fs-body:     0.875rem;   /* 14px — card summary */
  --fs-meta:     0.6875rem;  /* 11px — sources, counts */
  --fs-label:    0.59375rem; /* 9.5px — pill and eyebrow text */

  /* Line heights. Headlines are set at 1.34 rather than the ~1.15 English
   * alone would allow. Vietnamese stacks marks above AND below the baseline
   * (ữ, ạ, ế); at 1.15 they collide with the neighbouring line. English is the
   * primary language today, so this costs a little vertical space now and
   * costs nothing to change later — Phase 5 translations render correctly on
   * day one instead of needing a type pass. */
  --lh-headline: 1.34;
  --lh-body:     1.65;
  --lh-tight:    1.45;  /* rail entries, pills */

  --tracking-label: 0.09em;

  /* Spacing. Multiples of 4, named by step rather than by use, so a component
   * changing its padding does not require renaming a token. */
  --sp-1: 0.25rem;  --sp-2: 0.5rem;   --sp-3: 0.75rem;
  --sp-4: 1rem;     --sp-5: 1.5rem;   --sp-6: 2rem;

  --radius-card: 12px;
  --radius-ctl:  8px;
  --radius-pill: 999px;

  --rail-width: 240px;
  --measure:    62ch;  /* reading width cap; prose beyond this loses the reader
                        * on the return sweep to the next line */
}

/* ---------- 2. Dark mode ---------- */

/* Only tokens are redefined here. No component rule below this block is
 * allowed to know which mode it is in.
 *
 * Three states since the toggle (2026-08-26 night): no data-theme attribute
 * means "system" (the media query decides), data-theme="dark" or "light" is
 * an explicit reader choice that must win in BOTH directions — hence the
 * :not() guard on the media query AND the duplicate token block below it.
 * The duplication is deliberate: plain CSS has no way to name a token set
 * once and apply it under two selectors, and a build step is off the table.
 * The two blocks must be edited together. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper:         #1A1917;
    --paper-raised:  #262421;
    --ink:           #F2F0EC;
    --ink-body:      #C4C0B9;
    --ink-muted:     #918D86;
    --ink-faint:     #6E6A64;
    --rule:          #33302C;
    --rule-strong:   #3D3A35;

    --accent:        #E8446E;  /* lifted: #D31145 on near-black fails contrast */
    --accent-deep:   #F08AA3;
    --accent-wash:   #3A1A22;
    --accent-border: #5C2833;

    --negative:      #E07272;
    --positive:      #8FBF5A;

    --topic-slate-bg:   #24303B;  --topic-slate-text:   #A8C2D8;
    --topic-moss-bg:    #262C1F;  --topic-moss-text:    #B3C795;
    --topic-ochre-bg:   #322A1C;  --topic-ochre-text:   #D6BA83;
    --topic-plum-bg:    #2C2331;  --topic-plum-text:    #C0A8CB;
    --topic-clay-bg:    #2E2320;  --topic-clay-text:    #CFA79A;
    --topic-teal-bg:    #1E2C29;  --topic-teal-text:    #93C0B5;
  }
}

:root[data-theme="dark"] {
    --paper:         #1A1917;
    --paper-raised:  #262421;
    --ink:           #F2F0EC;
    --ink-body:      #C4C0B9;
    --ink-muted:     #918D86;
    --ink-faint:     #6E6A64;
    --rule:          #33302C;
    --rule-strong:   #3D3A35;

    --accent:        #E8446E;  /* lifted: #D31145 on near-black fails contrast */
    --accent-deep:   #F08AA3;
    --accent-wash:   #3A1A22;
    --accent-border: #5C2833;

    --negative:      #E07272;
    --positive:      #8FBF5A;

    --topic-slate-bg:   #24303B;  --topic-slate-text:   #A8C2D8;
    --topic-moss-bg:    #262C1F;  --topic-moss-text:    #B3C795;
    --topic-ochre-bg:   #322A1C;  --topic-ochre-text:   #D6BA83;
    --topic-plum-bg:    #2C2331;  --topic-plum-text:    #C0A8CB;
    --topic-clay-bg:    #2E2320;  --topic-clay-text:    #CFA79A;
    --topic-teal-bg:    #1E2C29;  --topic-teal-text:    #93C0B5;
}

/* ---------- 3. Base ---------- */

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

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  /* Declared HERE, not just in :root, so the typed token computes against
   * the body font-size the card text actually uses. */
  --measure: 62ch;
  -webkit-font-smoothing: antialiased;
}

a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

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

/* ---------- 4. Components ---------- */

/* Page container. Replaces the old .wrap, which lived in layout()'s inline
 * block and is now admin-only. Named .page because it is the public surface's
 * container and nothing else. */
.page {
  max-width: 44rem;
  margin: 0 auto;
  padding: var(--sp-6) var(--sp-4) var(--sp-6);
}

/* Snap-feed wiring, 2026-08-26. `.short` carried scroll-snap-align from the
 * first day; nothing ever carried scroll-snap-type, so nothing snapped and the
 * cards were merely a viewport tall. Under `y mandatory` every section the
 * reader must be able to rest on needs its own snap point — without these two
 * the masthead at the top and the pager at the bottom are both pulled off. */
.masthead, .pager { scroll-snap-align: start; }

/* The rail is desktop-only and has no mobile design. Without this it renders as
 * a bare list of headlines stacked above the feed. */
.rail { display: none; }

.masthead {
  /* Sticky (2026-08-26 night): the title and the tag-menu control travel with
   * the page on every public surface. Works against whichever scroller is
   * live — .feed below 900px, the viewport above — because sticky binds to
   * the nearest scrolling ancestor. position:relative used to be here only to
   * anchor the hamburger; sticky is also a containing block, so that still
   * holds. The background is what lets cards pass underneath; z-index sits
   * below the sheet (60). */
  position: sticky;
  top: 0;
  z-index: 30;
  background: var(--paper);
  border-bottom: 0.5px solid var(--rule);
  /* The bar is one title line plus 4px to its rule — ~30px pinned. The
   * tagline is NOT in here: it sits in flow after the masthead
   * (.lede--tagline) and scrolls under the bar. The first version folded it
   * with a scroll-toggled class instead, which changed layout height
   * mid-scroll and put the mandatory snap into a feedback loop (feed trapped
   * at scrollTop 40, top unreachable) — under snap, nothing about the
   * pinned bar may change layout on scroll. */
  padding-bottom: var(--sp-1);
  margin-bottom: var(--sp-1);
}

/* First-card spacing, tuned so every page's header→tags gap totals 16px —
 * the same 16 that separates tags from headline. Home: masthead margin (4)
 * + tagline line + tagline margin (16), with the card's own padding zeroed.
 * Crumb pages (no tagline): masthead margin (4) + sp-3 (12). */
.masthead + .short { padding-top: var(--sp-3); }
.lede--tagline + .short { padding-top: 0; }

.masthead__title {
  font-family: var(--font-serif);
  /* 1.25rem, down from 1.5 — and the same size the crumb pages use, so the
   * masthead reads identically on the feed and on a tag page. line-height was
   * inherited from body (1.65), which alone added ~8px of air under the
   * title; 1.3 is what the serif actually needs. */
  font-size: 1.25rem;
  line-height: 1.3;
  font-weight: 500;
  letter-spacing: -0.01em;
  margin: 0;
}
.masthead__title a { color: var(--ink); }
.masthead__title a:hover { text-decoration: none; }

/* Tag and topic pages (2026-08-26): the site name and the page's own name
 * share one line — "The Delta Desk / Enterprise AI" — replacing a stacked
 * caption-plus-title that spent two masthead lines saying one thing. The h1
 * is still the page name alone, so the document outline is unchanged; the
 * site name is navigation, not part of the title. Baseline-aligned so the
 * sans crumb and the serif title sit on one visual line, wrapping only when
 * a long tag name forces it. The right padding keeps the line from running
 * under the 40px tag-menu control pinned to the masthead's corner. */
.masthead__crumb {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0 var(--sp-2);
  padding-right: 88px;
  /* No margin of its own: the masthead's sp-2 padding is the whole gap, so a
   * tag page's rule sits as close to its one line as the feed's does to the
   * tagline. */
  margin: 0;
  line-height: 1.3;
}
.masthead__crumbhome {
  font-family: var(--font-serif);
  font-size: 1.25rem;
  line-height: 1.3;
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--ink);
}
.masthead__crumbhome:hover { text-decoration: underline; }
.masthead__crumbsep {
  font-family: var(--font-serif);
  font-size: 1.25rem;
  color: var(--ink-faint);
}
.masthead__title--crumb {
  font-size: 1.25rem;
  margin: 0;
}

/* Supporting line under a masthead, and the copy used for empty states. */
.lede {
  font-size: var(--fs-meta);
  color: var(--ink-muted);
  margin: 0 0 var(--sp-3);
}

/* The tagline (and the edition page's date line, and the about page's
 * caption): ordinary flow content directly under the pinned bar, so scrolling
 * simply slides it beneath the title — the "fold" costs no script and no
 * layout change. 16px to the tags below, matching tags→headline. */
.lede--tagline { margin: 0 0 var(--sp-4); }

/* Newer/older. Deliberately unnumbered beyond the current page: a feed that
 * grows daily shifts page boundaries under the reader, so a page number is a
 * position, not an address. */
.pager {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  margin-top: var(--sp-6);
  padding-top: var(--sp-4);
  border-top: 0.5px solid var(--rule);
  font-size: var(--fs-meta);
}
.pager__label { color: var(--ink-faint); }

/* The load-more status line (2026-08-26). Takes the pager's slot — and,
 * via the shared .pager class, its snap point — when the script is driving
 * pagination. Empty except while a batch is fetching or at the end of the
 * archive; min-height keeps the snap target from collapsing to a border. */
.feed-status {
  justify-content: center;
  color: var(--ink-faint);
  min-height: 1.5rem;
}

.site-footer {
  margin-top: var(--sp-6);
  padding-top: var(--sp-4);
  border-top: 0.5px solid var(--rule);
  font-size: var(--fs-meta);
  color: var(--ink-muted);
}

/* The AI-disclosure line. Its own block rather than a trailing sentence,
 * because it is the editorial promise and should read as a statement, not as
 * small print appended to the contact links. */
.site-footer__disclosure {
  display: block;
  margin-top: var(--sp-2);
  color: var(--ink-faint);
  max-width: var(--measure);
}

/* Sources on a card. Muted rather than accent-coloured: they are corroboration,
 * and a row of crimson links would out-shout the headline above them. */
.short__sources {
  font-size: var(--fs-meta);
  color: var(--ink-muted);
  margin-top: var(--sp-3);
  max-width: var(--measure);  /* one right edge (2026-08-30) */
}
.short__sources a { color: var(--ink-muted); text-decoration: underline; }
.short__sources a:hover { color: var(--ink); }

.short__updated {
  font-size: var(--fs-label);
  color: var(--ink-faint);
  margin-top: var(--sp-2);
  max-width: var(--measure);  /* one right edge (2026-08-30) */
}

/* Definition list, used by the colophon. */
.dl { margin: var(--sp-5) 0 0; }
.dl dt { font-weight: 500; margin-top: var(--sp-4); }
.dl dd { margin: var(--sp-1) 0 0; color: var(--ink-body); max-width: var(--measure); }

/* Snap feed. The whole mobile reading experience is these declarations — no
 * JavaScript required. The viewport height comes in three layers (2026-09-02):
 *   100vh  — the ancient-browser floor.
 *   100svh — the SMALL viewport: never taller than the visible area in any
 *            browser-chrome state. This was 100dvh, and that unit is the bug
 *            the frozen-tab hunt kept circling: iOS Chrome pins dvh to the
 *            LARGE viewport and never updates it, so .feed and every card ran
 *            ~60px past the visible area whenever the toolbars were showing —
 *            first line clipped under the address bar (the body scrolled the
 *            overflow, taking the sticky masthead with it), why-band and
 *            actions below the fold. Not a freeze bug: a fresh load from a
 *            shared WhatsApp link showed identical geometry. svh is static,
 *            so snap points cannot move mid-scroll (the 08-26 rule).
 *   --vvh  — the measured truth, in the @supports block after .short:
 *            HEADER_SCRIPT publishes visualViewport.height, which no unit
 *            bug can misreport. The @supports guard exists because an
 *            unresolvable var() fallback does not yield to earlier
 *            declarations — it computes to unset and kills them. */
.feed {
  height: 100vh;
  height: 100svh;
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  overscroll-behavior-y: contain;
  /* The pinned masthead overlays the top of the scroller, so snapped cards
   * must land below it, and position 0 stays the first card's snap point
   * (its top minus this padding is exactly 0). Set by HEADER_SCRIPT; 0 for
   * no-JS readers, whose cards stay full-height. */
  scroll-padding-top: var(--mast-h, 0px);
}

.short {
  /* relative: the double-tap heart burst positions against the card.
   * touch-action manipulation: without it a double tap is also a browser
   * zoom gesture, and the like would fire mid-zoom. */
  position: relative;
  touch-action: manipulation;
  /* One screen per story MINUS the pinned header — a card sized to the full
   * viewport would run past the bottom by the header's height on every
   * snapped card. The clamp fitter measures rendered geometry, so it adapts
   * to this on its own. Same three viewport layers as .feed above. */
  min-height: calc(100vh - var(--mast-h, 0px));
  min-height: calc(100svh - var(--mast-h, 0px));
  scroll-snap-align: start;
  display: flex;
  flex-direction: column;
  padding: var(--sp-4) 0 var(--sp-5);
  border-bottom: 0.5px solid var(--rule);
}

/* The measured viewport (2026-09-02). Guarded so the var() lines only exist
 * where their 100svh fallback parses — see the .feed comment. The desktop
 * overrides (.feed height:auto at ≥900px, .short min-height:0 at
 * ≥900x900) sit later in the file and still win on source order. */
@supports (height: 100svh) {
  .feed { height: var(--vvh, 100svh); }
  .short { min-height: calc(var(--vvh, 100svh) - var(--mast-h, 0px)); }
}

.short__headline {
  font-family: var(--font-serif);
  font-size: var(--fs-headline);
  line-height: var(--lh-headline);
  font-weight: 400;
  letter-spacing: -0.005em;
  margin: var(--sp-2) 0 0;
  /* One right edge (2026-08-30): without this the 480-899px band let the
   * headline overshoot the shared measure by ~124px — its 24ch display cap
   * lives in the >=900px block only. The desktop block's tighter 24ch still
   * overrides this. Phones are column-constrained either way. */
  max-width: var(--measure);
  /* Descenders and Vietnamese below-baseline marks are clipped by tight line
   * boxes on some engines even at a generous line-height. */
  padding-bottom: 2px;
}

/* Headlines are links on the feed and plain text on a permalink. The global `a`
 * rule would paint them crimson — which is wrong: crimson marks an action, and a
 * headline is the content. Underline on hover is enough to say it is clickable. */
.short__headline a { color: var(--ink); }
.short__headline a:hover { text-decoration: underline; }

/* The date under the headline. Demoted from organising principle to a fact
 * about the story: the feed is continuous and tags are the taxonomy, so the
 * date no longer files anything. Muted and small, above the summary rather
 * than buried in the meta row, because it still answers "is this current?" —
 * the one question a reader genuinely has about a news item. */
.short__date {
  font-size: var(--fs-meta);
  color: var(--ink-faint);
  margin-top: var(--sp-2);
  max-width: var(--measure);  /* one right edge (2026-08-30) */
}

.short__summary {
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--ink-body);
  margin: var(--sp-3) 0 0;
  max-width: var(--measure);

  /* Tap to expand — Phase 4 item 9, built 2026-08-26 on measurement rather than
   * intent. The summary is 47% of a card's height, and without this clamp EVERY
   * card overflowed EVERY phone screen tested, the largest one included:
   * 20/20 over on a 393x659, median card 930px against a 659px viewport.
   *
   * Five lines is measured, not chosen. At five, twenty of twenty cards land
   * exactly on 100dvh at 393x659 with room still in hand, which is what makes
   * `min-height: 100dvh` on .short mean something for the first time. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 5;
  overflow: hidden;
}

.short.is-open .short__summary {
  -webkit-line-clamp: none;
  overflow: visible;
}

/* A TAP TARGET, never press-and-hold: press-and-hold collides with the OS
 * text-selection menu on both platforms, is undiscoverable, cannot be scrolled
 * while held, and locks out screen readers and anyone with a motor impairment.
 *
 * The clamp is CSS and the button is JavaScript, which is the right way round.
 * A reader without JS gets the whole summary and a taller card — degraded, not
 * broken (see the noscript rule in layout()). And a button that appears only
 * where text is actually cut off cannot lie about there being more to read. */
.short__more {
  align-self: flex-start;
  margin-top: var(--sp-2);
  padding: 0;
  border: none;
  background: none;
  color: var(--accent);
  font: inherit;
  font-size: var(--fs-meta);
  text-decoration: underline;
  cursor: pointer;
}
.short__more:hover { color: var(--accent-deep); }

/* The void above the why band, closed 2026-08-26.
 *
 * `.page` stops growing at 44rem, so every width from here to the desktop
 * breakpoint shares one measure — and at that measure five lines no longer
 * fills a card that `min-height: 100dvh` is still padding out to a full screen.
 * The leftover lands between the summary and the why band, because `.whym`
 * takes it with `margin-top: auto`.
 *
 * Seven lines is the largest clamp that still keeps 20/20 cards inside a 659px
 * screen at a 704px column, and it takes the median gap from 93px to 46px.
 * Eight would take it to 23px and push two cards over. Measured, not chosen —
 * same method as the five below 480px.
 *
 * 480px rather than the 900px breakpoint: the widest phone in circulation is
 * 440px, so no handset falls into this band and the five-line figure keeps
 * meaning what it was measured to mean.
 *
 * What is left is not a mistake to remove. Item 9 asked for "room for an
 * image" and this is that room — item 10 fills it. */
@media (min-width: 480px) and (max-width: 899px) {
  .short__summary { -webkit-line-clamp: 7; }
}

/* Why it matters. Pushed to the bottom of whatever space is left so that it
 * lands in the same place on every card regardless of summary length — a
 * consistent landmark matters when a reader is thumbing through 26 of these.
 *
 * A published short always has one: handleAdminPost refuses to publish without
 * it, and that gate predates this stylesheet. The conditional in renderShort is
 * therefore defensive rather than a supported state — it exists so that a draft
 * preview, or a row that somehow lost the field, renders as absent rather than
 * as an empty labelled band. Absent is not blank. */
.whym {
  margin-top: auto;
  /* One right edge (2026-08-30): --measure is typed (see its @property
   * block), so this is the same computed pixel value the summary gets —
   * the 08-27 drift ("fix only if the eye objects" — it objected) cannot
   * recur. border-box puts the band's edge exactly on the measure like the
   * summary text's. */
  max-width: var(--measure);
  /* auto pins the band to the card's foot — but only while the card has
   * slack. An expanded card has grown past 100dvh, so auto computes to ZERO
   * and the band sat flush against the Show less control. The is-open rule
   * below (and its desktop and noscript twins, where min-height:0 and the
   * unclamped summary leave no slack either) restores a real gap. */
  background: var(--paper-raised);
  border-left: 3px solid var(--accent);
  border-radius: 0;  /* single-sided borders and rounded corners never mix */
  padding: var(--sp-3);
}

.short.is-open .whym { margin-top: var(--sp-4); }

/* The comment bubble lives IN the commentary box (2026-08-30, editor's
 * call): comments feed the commentary — upvoted ones get featured — so the
 * control sits on the thing it feeds. Accent red (which is, to the digit,
 * AIA red) so it reads as the box's one interactive element; its old slot
 * in the engagement bar became the report triangle. */
.whym { position: relative; }
.whym__label { padding-right: 44px; }
/* .whym prefix beats the .action base rule that follows later in the file —
 * equal single-class specificity would let .action's gray win on color. */
.whym .whym__comment {
  position: absolute;
  top: var(--sp-2);
  right: var(--sp-2);
  color: var(--accent);
  border-color: var(--accent-border);
  background: var(--paper);
}
.whym .whym__comment:hover { background: var(--accent-wash); color: var(--accent-deep); }

.whym__label {
  font-size: var(--fs-label);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--accent-deep);
  margin-bottom: var(--sp-2);
}

.whym__text {
  font-family: var(--font-serif);
  font-style: italic;
  font-size: var(--fs-body);
  line-height: var(--lh-tight);
  color: var(--ink);
}

/* The tag row. Wraps rather than scrolls: this sits inside a card with a fixed
 * height, and a horizontally scrolling strip inside a vertically snapping feed
 * fights the reader's gesture. The topic nav below scrolls because it is page
 * chrome, not card content. */
.tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  /* sp-2, in step with .short__headline's top margin: tags→headline totals
   * 16px, the same as tagline→tags (masthead sp-2 padding + sp-2 margin). */
  margin-bottom: var(--sp-2);
}

/* Tag. Colour comes from two custom properties set inline by the server from
 * tags.hue, so this one rule serves every tag that will ever exist. Most tags
 * carry no hue and fall through to neutral.
 *
 * Named .tag, not .pill: layout() in src/index.js already uses .pill for admin
 * run-status badges (.pill.ok, .pill.failed). Two different meanings on one
 * class name is how a stylesheet starts lying. */
.tag {
  display: inline-block;
  background: var(--tag-bg, var(--topic-neutral-bg));
  color: var(--tag-text, var(--topic-neutral-text));
  border-radius: var(--radius-pill);
  padding: 3px 10px;
  font-size: var(--fs-label);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  /* No fixed width. Topic labels vary in length now and will vary more once
   * they exist in two languages. */
  white-space: nowrap;
}

/* Tags are links, so the global `a` rule would paint them crimson and underline
 * them on hover. Both are wrong here: the colour is the tag's own, and an
 * underline inside a filled pill reads as damage. */
a.tag { color: var(--tag-text, var(--topic-neutral-text)); }
a.tag:hover { text-decoration: none; opacity: 0.82; }

/* Active filter deepens the tag's own hue rather than turning crimson, so that
 * filtering does not destroy the colour system the moment it is used. */
.tag[aria-current="page"] {
  background: color-mix(in srgb,
    var(--tag-bg, var(--topic-neutral-bg)),
    var(--tag-text, var(--topic-neutral-text)) 18%);
  font-weight: 500;
}

/* The tag menu (2026-08-26 evening; overlay since 2026-08-29 night). Replaces
 * the always-visible tagnav strip: a line of twenty chips was chrome the
 * reader paid for before the first story on every page. Native <details>, so
 * opening and closing needs no JavaScript. The summary is pinned to the
 * masthead's top-right corner (the masthead is sticky, hence a containing
 * block, for exactly this); closed, the element contributes no height. */
.navmenu__toggle {
  position: absolute;
  top: 0;
  right: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  /* 26px tall — the title's own line box (1.25rem x 1.3) — so the burger and
   * the title share one axis and the pinned bar is exactly one line tall.
   * Width stays 40px for the tap target. */
  width: 40px;
  height: 26px;
  color: var(--ink-muted);
  cursor: pointer;
  list-style: none;
}
.navmenu__toggle::-webkit-details-marker { display: none; }
.navmenu__toggle:hover { color: var(--ink); }

/* Theme toggle, left of the burger and matching its 26px bar height. The icon
 * is the theme a press takes you to: a moon while light, a sun while dark.
 * The swap rules mirror the token logic above exactly — same three states,
 * same :not() guard — because the icon claiming one mode while the tokens
 * render the other would be worse than no icon. */
.themetoggle {
  position: absolute;
  top: 0;
  right: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 26px;
  padding: 0;
  border: none;
  background: none;
  color: var(--ink-muted);
  cursor: pointer;
}
.themetoggle:hover { color: var(--ink); }
.themetoggle__sun { display: none; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .themetoggle__sun { display: block; }
  :root:not([data-theme="light"]) .themetoggle__moon { display: none; }
}
:root[data-theme="dark"] .themetoggle__sun { display: block; }
:root[data-theme="dark"] .themetoggle__moon { display: none; }
:root[data-theme="light"] .themetoggle__sun { display: none; }
:root[data-theme="light"] .themetoggle__moon { display: block; }

/* One control, two glyphs: the burger closed, a cross open. CSS-swapped so the
 * markup stays a single summary and no script owns the state. */
.navmenu__close { display: none; }
.navmenu[open] .navmenu__burger { display: none; }
.navmenu[open] .navmenu__close { display: block; }

/* Open, the panel OVERLAYS the page: absolutely positioned against the sticky
 * masthead (a sticky element is a containing block), dropping from under the
 * pinned bar the way the comment sheet rises from below. It used to open in
 * flow and push the page down; as an overlay it never changes the masthead's
 * layout height, which under `y mandatory` snap removes the last thing that
 * resized the pinned bar mid-scroll. Spans the masthead's column, same as the
 * text it covers; --paper background so cards pass invisibly underneath, the
 * same trick the masthead itself uses. HEADER_SCRIPT closes it on outside tap
 * and Escape — <details> never closes on its own. */
.navmenu__panel {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  padding: var(--sp-3) 0 var(--sp-4);
  background: var(--paper);
  border-bottom: 0.5px solid var(--rule);
  box-shadow: 0 14px 24px -14px rgba(0, 0, 0, 0.28);
}

/* Site links inside the tag menu (2026-09-01). About/Privacy/Sources were
 * reachable only through the footer, which sits below an infinite snap feed —
 * effectively nowhere on mobile. A full-width rule separates them from the tag
 * vocabulary so the menu reads as two kinds of destination, not one list. */
.navmenu__sitelinks {
  flex-basis: 100%;
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-4);
  margin-top: var(--sp-2);
  padding-top: var(--sp-3);
  border-top: 0.5px solid var(--rule);
}
.navmenu__sitelink {
  font-family: var(--sans, ui-sans-serif, system-ui, sans-serif);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-2, var(--ink));
  text-decoration: none;
}
.navmenu__sitelink:hover { text-decoration: underline; }
.navmenu__sitelink[aria-current="page"] { color: var(--accent); }

/* Coach marks (2026-08-29 night). Two once-per-device lessons on the hint
 * ladder. .coach is the third-swipe pointer at the tag menu: pinned under
 * the masthead's top-right corner, arrow aimed at the burger, faded in and
 * out by .show so a mid-life removal never pops. The fourth-swipe install
 * lesson is .short--install below — a full feed card, not an overlay. */
.coach {
  position: fixed;
  top: calc(var(--mast-h, 31px) + 8px);
  right: 10px;
  z-index: 45;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 4px;
  max-width: 15rem;
  color: var(--ink);
  opacity: 0;
  transform: translateY(4px);
  transition: opacity 0.35s ease, transform 0.35s ease;
  pointer-events: none;
}
.coach.show { opacity: 1; transform: none; }
/* Contribute/report coach (2026-08-30): unlike the menu mark it points DOWN
 * — the red bubble and the triangle both live in the card's bottom cluster
 * — so it hangs above the engagement bar with the arrow last. */
.coach--contrib {
  top: auto;
  bottom: 92px;
  right: 14px;
}
.coach--contrib .coach__arrow { margin: 2px 10px 0 0; }
/* The report chip's UNANCHORED fallback sits lower than the contribute
 * chip's, so the two never stack while their targets are still off-screen
 * (each rides onto its own control the moment it appears). */
.coach--report { bottom: 16px; }
.coach__ico { display: inline-block; vertical-align: -2px; margin-right: 6px; color: var(--accent); }
.coach__ico--warn { color: var(--paper); }
.coach__ico svg { display: inline; }
.coach__arrow { margin-right: 8px; color: var(--ink-muted); }
.coach__text {
  background: var(--ink);
  color: var(--paper);
  font-size: 0.8rem;
  line-height: 1.35;
  padding: 0.5rem 0.75rem;
  border-radius: 10px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);
}

/* The install card (2026-08-29 night, reworked from a bottom bar the same
 * night: a transient overlay naming "Add to Home Screen" was not enough to
 * FIND it — iOS buries the row below the share sheet's fold, and a dismissed
 * one-liner can't be consulted mid-hunt). A full .short card instead: the
 * fourth swipe lands on it, it holds the reader's whole screen for exactly
 * as long as they need the steps, and swiping on is the dismissal. Inherits
 * the card's min-height, snap-align and border from .short; centers its own
 * column. */
.short--install .install {
  margin: auto 0;
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  max-width: var(--measure);
}
.install__title {
  font-family: var(--font-serif);
  font-size: var(--fs-headline);
  line-height: var(--lh-headline);
  font-weight: 400;
  letter-spacing: -0.005em;
  margin: 0;
}
.install__lede { margin: 0; color: var(--ink-body); }
.install__steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}
.install__steps li {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  color: var(--ink-body);
}
.install__icon {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border: 0.5px solid var(--rule);
  border-radius: 12px;
  color: var(--ink);
  background: var(--paper);
}
.install__go {
  align-self: flex-start;
  border: none;
  background: var(--ink);
  color: var(--paper);
  font-size: 0.9rem;
  font-weight: 500;
  padding: 0.6rem 1.2rem;
  border-radius: 999px;
  cursor: pointer;
}
.install__note { margin: 0; color: var(--ink-muted); font-size: 0.8rem; }

/* Seen shorts (2026-08-29 night). Applied at render time from the seen
 * store, never live — a story must not fade under the reader mid-read. Order
 * and layout untouched: a muted headline and a quiet suffix on the date are
 * the whole treatment, so an unread story is findable at a glance without
 * read ones looking deleted. */
.short.is-seen .short__headline { color: var(--ink-muted); }
.short.is-seen .short__headline a { color: inherit; }
.short.is-seen .short__date::after { content: " \00b7\0020read"; }

/* The freshness pill (2026-08-29 night). FRESH_SCRIPT shows it when new
 * shorts exist and the reader is mid-feed — never moving the card under them.
 * Fixed under the pinned bar (--mast-h is the bar's published height), above
 * the cards (masthead is 30) and below the sheet (60). High-contrast inverse
 * of the page so it reads as an action, not a card. */
.freshpill {
  position: fixed;
  top: calc(var(--mast-h, 31px) + 10px);
  left: 50%;
  transform: translateX(-50%);
  z-index: 40;
  padding: 0.45rem 0.95rem;
  border: none;
  border-radius: 999px;
  background: var(--ink);
  color: var(--paper);
  font-size: 0.8rem;
  font-weight: 500;
  cursor: pointer;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22);
}
.freshpill:hover { opacity: 0.9; }

/* Like and share. Bottom-right, inside the card, opposite the editor byline —
 * deliberately not the overlaid vertical stack of an entertainment feed, which
 * covers the story and reads as engagement-farming.
 *
 * "Bottom-right" was written here on 2026-08-25 and was not true until
 * 2026-08-26: the row rendered left-aligned because nothing ever set
 * justify-content. Noting it rather than quietly correcting, because a comment
 * that describes an intention rather than the render is the failure mode this
 * file has hit twice now.
 *
 * "Opposite the timestamp" above was a third instance: it pointed at a planned
 * bottom meta row (.short__meta / .tier / .short__meta-time) that was never
 * emitted — the date renders under the headline instead — so its CSS was
 * removed 2026-08-27. .tier returns with Phase 2 provenance, rebuilt against
 * the card as it exists then. */
.actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--sp-2);

  /* A full step rather than the --sp-3 the source line gets. The gaps inside
   * the card's bottom cluster are graded by kind, not by evenness: whym → the
   * source line is --sp-3 and the source line → the Updated stamp is --sp-2,
   * because those are three pieces of the same reading. Controls are a
   * different kind of thing, and at --sp-3 they read as another line of meta
   * stuck to the sources rather than something to press. */
  margin-top: var(--sp-4);
  /* One right edge (2026-08-30, editor's call): the controls used to
   * right-align at the CARD edge — ~85px past the measure at 1100px and
   * drifting further right the wider the window. Capped, they tuck in
   * directly under the whym band's edge and the card reads as one column.
   * The headline's 24ch display measure stays its own, deliberately. */
  max-width: var(--measure);
}

/* The editor's name on every card, opposite the engagement icons. This is the
 * editorial promise made visible at the story level — the masthead claim,
 * signed. Muted like the sources: it is provenance, not a call to action. */
.actions__byline {
  margin-right: auto;
  font-size: var(--fs-meta);
  color: var(--ink-faint);
  /* Two lines since 2026-08-30 ("Drafted by AI / Reviewed / sometimes
   * edited") — tight leading keeps the pair no taller than the 32px
   * control pills beside it, so the bar's height doesn't move. */
  line-height: 1.45;
}
.actions__byline:hover { color: var(--ink-muted); text-decoration: underline; }

.action {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 32px;
  padding: 5px 10px;
  border: 0.5px solid var(--rule-strong);
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--ink-body);
  font: inherit;
  font-size: var(--fs-meta);
  cursor: pointer;
}

/* The view count: a stat wearing the action row's pill so the cluster reads
 * as one family, but it is provenance, not a control — no cursor, no hover,
 * no pointer events (which also inertizes the shared wire() click listener).
 * Hidden until it has a number: a bare eye is not information. */
.action--views {
  pointer-events: none;
  cursor: default;
  color: var(--ink-muted);
}

.action--liked {
  background: var(--accent-wash);
  border-color: var(--accent-border);
  color: var(--accent-deep);
  font-weight: 500;
}

/* Icon controls (2026-08-26 sixth slice). The icons are stroked outlines in
 * currentColor, so every state the pill already had — resting, liked — colours
 * the glyph for free. The liked heart additionally FILLS: colour alone is the
 * one channel dark mode and a glance both blur, a solid heart is neither. */
.action svg { display: block; flex: none; }
.action--liked svg { fill: currentColor; }

/* A count that has not loaded does not render, so a placeholder never flickers
 * into a real number.
 *
 * ZERO IS NOW ALSO HIDDEN, decided 2026-08-26, and that REVERSES what this
 * comment said. "A count of zero is a real value" is correct, and it is the
 * right rule for a publication that has readers — nought likes is information.
 * With no audience it is twenty cards reading "Like 0", which only advertises
 * that nobody is here. The server still returns zeros for every short, so this
 * is one comparison in the client and not a data decision: revisit it the day
 * the first count comes back non-zero. */
.action__count[hidden] { display: none; }

/* The bottom sheet (2026-08-26 sixth slice) — one overlay for everything that
 * swoops up over the feed: comments today, the source overlay next. Fixed and
 * viewport-filling as a hit target, with the panel pinned to the bottom edge
 * and translated fully offscreen until .is-open lands; the transition runs on
 * transform only, which composites without layout work. Width-capped to the
 * page's own 44rem measure so the desktop split view is not spanned edge to
 * edge — one mechanism, both breakpoints (decided with Sumit 2026-08-26). */
.sheet { position: fixed; inset: 0; z-index: 60; }
.sheet[hidden] { display: none; }

.sheet__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(12, 11, 10, 0.45);
  opacity: 0;
  transition: opacity 0.25s ease;
}

.sheet__panel {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translate(-50%, 105%);
  width: 100%;
  max-width: 44rem;
  height: 86vh;
  height: 86svh; /* svh, not dvh — the .feed comment; iOS Chrome pins dvh to the large viewport */
  background: var(--paper);
  border: 0.5px solid var(--rule);
  border-bottom: none;
  border-radius: 14px 14px 0 0;
  box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.25);
  transition: transform 0.3s ease;
  display: flex;
  flex-direction: column;
}

.sheet.is-open .sheet__backdrop { opacity: 1; }
.sheet.is-open .sheet__panel { transform: translate(-50%, 0); }

@media (prefers-reduced-motion: reduce) {
  .sheet__backdrop, .sheet__panel { transition: none; }
}

.sheet__bar {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 0.5px solid var(--rule);
}
.sheet__visit { font-size: var(--fs-meta); color: var(--accent); }
.sheet__visit[hidden] { display: none; }
.sheet__title {
  flex: 1;
  font-family: var(--font-serif);
  font-size: 1.05rem;
  font-weight: 500;
  color: var(--ink);
}
.sheet__close {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 36px;
  height: 36px;
  margin-left: auto;
  padding: 0;
  border: none;
  background: none;
  color: var(--ink-muted);
  cursor: pointer;
}
.sheet__close:hover { color: var(--ink); }

.sheet__body { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch; }

/* The embedded source. The iframe fills the body; its own page scrolls
 * itself, so the body's scrolling is moot while a frame is up. White ground
 * behind it deliberately — most sites assume a light canvas, and a dark flash
 * behind a loading page reads as a failure. */
/* The compact sheet: sized by its content instead of 86svh. A source card in
 * a full-height panel is a business card in an empty ballroom. */
.sheet__panel--compact { height: auto; max-height: 70vh; max-height: 70svh; }

/* The source card (2026-08-26, seventh slice reworked). One shape for every
 * outlet — no frames, no probes, no per-site behaviour. The label wears the
 * WHY IT MATTERS treatment so the card speaks the site's existing language,
 * and the one prominent action points OUTWARD at the source. */
.srccard {
  max-width: 34rem;
  margin: 0 auto;
  padding: var(--sp-5) var(--sp-4) var(--sp-5);
  display: flex;
  flex-direction: column;
}
.srccard__label {
  font-size: var(--fs-label);
  letter-spacing: 0.11em;
  text-transform: uppercase;
  font-weight: 500;
  color: var(--accent);
}
.srccard__outlet {
  font-family: var(--font-serif);
  font-size: 1.6rem;
  font-weight: 500;
  margin: var(--sp-2) 0 var(--sp-2);
  color: var(--ink);
}
.srccard__title {
  margin: 0 0 var(--sp-2);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  color: var(--ink-body);
}
.srccard__url {
  font-size: var(--fs-meta);
  color: var(--ink-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.srccard__vi {
  margin-top: var(--sp-2);
  font-size: var(--fs-meta);
  color: var(--ink-muted);
}
.srccard__go {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  align-self: flex-start;
  margin-top: var(--sp-5);
  padding: 12px 24px;
  border-radius: var(--radius-pill);
  background: var(--accent);
  color: #fff;
  font-size: var(--fs-meta);
  font-weight: 500;
}
.srccard__go:hover { background: var(--accent-deep); color: #fff; }
.srccard__note {
  margin-top: var(--sp-3);
  font-size: var(--fs-meta);
  color: var(--ink-faint);
}

/* The coming-soon panel, and the empty-state shape comments will reuse. */
/* Report-an-issue form (2026-08-30), rendered into the shared sheet by the
 * engagement script. Tokens only — inherits both themes for free. */
.rform {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  padding: var(--sp-4);
  max-width: var(--measure);
}
.rform__lede { margin: 0; color: var(--ink-body); font-size: var(--fs-meta); }
.rform__label {
  font-size: var(--fs-label);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--ink-muted);
}
.rform textarea, .rform input {
  font: inherit;
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--rule-strong);
  border-radius: var(--radius-ctl);
  padding: var(--sp-2) var(--sp-3);
  width: 100%;
}
.rform__err { color: var(--accent-deep); font-size: var(--fs-meta); }
.rform__go {
  font: inherit;
  align-self: flex-start;
  background: var(--accent);
  color: var(--on-accent);
  border: 0;
  border-radius: var(--radius-pill);
  padding: var(--sp-2) var(--sp-5);
  cursor: pointer;
}
.rform__go:disabled { opacity: 0.6; cursor: default; }

.sheet__empty {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  padding: var(--sp-5);
  text-align: center;
  color: var(--ink-muted);
}
.sheet__empty h2 { font-family: var(--font-serif); font-weight: 500; color: var(--ink); margin: 0; }
.sheet__empty p { margin: 0; font-size: var(--fs-meta); max-width: 24rem; }

/* Both scrollers freeze while a sheet is up — which one is live depends on
 * the breakpoint, so both are locked unconditionally. */
html.sheet-lock, html.sheet-lock body { overflow: hidden; }
html.sheet-lock .feed { overflow-y: hidden; }

/* The scroll hint (2026-08-26 night). A ~2.3s overlay on feed pages telling
 * a new reader how to advance: a hand swiping up below 900px, a PageDown
 * keycap above it. Shows on EVERY visit (decided with Sumit — it is brief and
 * non-blocking, and new readers always get the cue). Never interactive:
 * pointer-events none, no scrim, aria-hidden, removed from the DOM by
 * HEADER_SCRIPT when its life animation ends. z-index between the masthead
 * (30) and the sheet (60). */
.hint {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 12%;
  z-index: 50;
  pointer-events: none;
  opacity: 0;
}
.hint.play { animation: hint-life 2.3s ease both; }
@keyframes hint-life {
  0% { opacity: 0; } 8% { opacity: 1; } 82% { opacity: 1; } 100% { opacity: 0; }
}
.hint__variant {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-2);
}
/* One markup, two variants; the breakpoint picks. Same 900px line as the rest
 * of the design — the cue must match the mechanism the page actually has. */
.hint__variant--d { display: none; }
@media (min-width: 900px) {
  .hint__variant--m { display: none; }
  .hint__variant--d { display: flex; }
  /* The rail shifts the reading column right of the viewport's centre; the
   * hint follows it. Half the rail width is the correct offset at every
   * viewport ≥900px — the .layout grid is centred, so the content column's
   * centre is always 50vw + rail/2. */
  .hint { transform: translateX(calc(var(--rail-width) / 2)); }
}
.hint__caption {
  font-size: var(--fs-label);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--ink);
  background: color-mix(in srgb, var(--paper) 82%, transparent);
  padding: 4px 10px;
  border-radius: 999px;
  backdrop-filter: blur(2px);
}

/* Mobile: a hand swipes up leaving a fading accent trail, twice. */
.swipe { position: relative; width: 64px; height: 110px; }
.swipe__trail {
  position: absolute;
  left: 50%;
  bottom: 14px;
  width: 4px;
  height: 0;
  border-radius: 2px;
  background: linear-gradient(to top, transparent, var(--accent));
  transform: translateX(-50%);
  opacity: 0;
}
.swipe__hand {
  position: absolute;
  left: 50%;
  bottom: 0;
  transform: translate(-50%, 0);
  color: var(--ink);
}
.swipe__hand svg { display: block; filter: drop-shadow(0 1px 2px color-mix(in srgb, var(--ink) 25%, transparent)); }
.play .swipe__hand { animation: swipe-up 1.1s cubic-bezier(.5, 0, .3, 1) .15s 2 both; }
.play .swipe__trail { animation: swipe-trail 1.1s ease-out .15s 2 both; }
@keyframes swipe-up {
  0% { transform: translate(-50%, 0) rotate(4deg); }
  12% { transform: translate(-50%, 0) rotate(0deg); }
  55% { transform: translate(-50%, -62px) rotate(-6deg); }
  64% { opacity: 1; }
  75%, 100% { transform: translate(-50%, -62px) rotate(-6deg); opacity: 0; }
}
@keyframes swipe-trail {
  0%, 12% { height: 0; opacity: 0; }
  55% { height: 66px; opacity: .9; }
  85%, 100% { height: 66px; opacity: 0; }
}

/* Desktop: a PageDown keycap pressed twice, a chevron falling beneath it. */
.keycap { position: relative; width: 72px; height: 54px; }
.keycap__cap {
  position: absolute;
  inset: 0 0 6px 0;
  border-radius: 9px;
  background: var(--paper);
  border: 1.5px solid var(--ink-muted);
  box-shadow: 0 6px 0 -1px color-mix(in srgb, var(--ink) 38%, transparent);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1px;
  color: var(--ink);
}
.keycap__cap span { font-size: 11px; font-weight: 500; letter-spacing: .04em; }
.keycap__cap svg { display: block; }
.play .keycap__cap { animation: keycap-press 1.1s ease .15s 2 both; }
@keyframes keycap-press {
  0%, 38% { transform: translateY(0); box-shadow: 0 6px 0 -1px color-mix(in srgb, var(--ink) 38%, transparent); }
  50%, 62% { transform: translateY(5px); box-shadow: 0 1px 0 -1px color-mix(in srgb, var(--ink) 38%, transparent); }
  74%, 100% { transform: translateY(0); box-shadow: 0 6px 0 -1px color-mix(in srgb, var(--ink) 38%, transparent); }
}
.keycap__chev {
  position: absolute;
  left: 50%;
  top: 110%;
  transform: translateX(-50%);
  color: var(--accent);
  opacity: 0;
}
.play .keycap__chev { animation: keycap-chev 1.1s ease .3s 2 both; }
@keyframes keycap-chev {
  0%, 40% { opacity: 0; transform: translate(-50%, -6px); }
  55% { opacity: 1; }
  90%, 100% { opacity: 0; transform: translate(-50%, 10px); }
}

/* Double-tap acknowledgement: a heart pops from the card's centre for
 * ~0.9s. Plays on every double-tap, liked already or not — the gesture is
 * always answered, it just never unlikes. */
.dbl-heart {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 5;
  color: var(--accent);
  pointer-events: none;
  transform: translate(-50%, -50%);
  animation: dbl-heart .9s cubic-bezier(.2, .7, .3, 1) both;
}
.dbl-heart svg { display: block; filter: drop-shadow(0 2px 6px color-mix(in srgb, var(--ink) 30%, transparent)); }
@keyframes dbl-heart {
  0% { transform: translate(-50%, -50%) scale(.3); opacity: 0; }
  22% { transform: translate(-50%, -50%) scale(1.12); opacity: 1; }
  40% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
  70% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
  100% { transform: translate(-50%, -62%) scale(1); opacity: 0; }
}

/* The like hint: a heart with two tap ripples, shown once when the reader
 * reaches their second card (they have just proven they can swipe; teach the
 * next gesture). Mobile-only — the script arms it on the .feed scroller and
 * only when the rail is hidden. Reuses .hint's life/caption/positioning. */
.dbltap {
  position: relative;
  width: 84px;
  height: 84px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--accent);
}
.dbltap svg { position: relative; z-index: 1; filter: drop-shadow(0 1px 2px color-mix(in srgb, var(--ink) 25%, transparent)); }
.dbltap__ring {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 56px;
  height: 56px;
  border: 2px solid var(--accent);
  border-radius: 50%;
  transform: translate(-50%, -50%) scale(.5);
  opacity: 0;
}
/* Two rings, offset by a quarter second — the rhythm IS the instruction. */
.play .dbltap__ring { animation: dbltap-ring 1.1s ease-out .15s 2 both; }
.play .dbltap__ring--late { animation-delay: .4s; }
.play .dbltap svg { animation: dbltap-pop 1.1s ease .15s 2 both; }
@keyframes dbltap-ring {
  0% { transform: translate(-50%, -50%) scale(.5); opacity: .9; }
  50%, 100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
}
@keyframes dbltap-pop {
  0%, 8% { transform: scale(1); }
  16% { transform: scale(.82); }
  26% { transform: scale(1.06); }
  34% { transform: scale(.82); }
  46% { transform: scale(1); }
  100% { transform: scale(1); }
}

/* Reduced motion: the glyphs hold still; the caption (opacity-only) still
 * fades through, so the instruction survives without the movement. */
@media (prefers-reduced-motion: reduce) {
  .play .swipe__hand, .play .swipe__trail,
  .play .keycap__cap, .play .keycap__chev,
  .play .dbltap svg, .play .dbltap__ring, .dbl-heart { animation: none !important; }
  .play .swipe__trail, .play .keycap__chev, .play .dbltap__ring { opacity: 0; }
  .dbl-heart { opacity: 0; }
}

/* ---------- 5. Desktop ---------- */

@media (min-width: 900px) {
  /* MANDATORY snap scrolling is abandoned above this width. On a mouse wheel
   * it hijacks scrolling, which readers reliably dislike, and it wastes the
   * space that makes an index possible. That decision stands — what was added
   * on 2026-08-26 (evening) is `proximity` on the ROOT scroller below, which
   * is a different animal: the document scrolls normally (wheel, keys,
   * scrollbar all untouched) and only settles onto a card top when the reader
   * STOPS near one. The snap targets come from the base rules — .short,
   * .masthead and .pager all carry scroll-snap-align there. */
  .feed {
    height: auto;
    overflow-y: visible;
    scroll-snap-type: none;
  }

  html {
    scroll-snap-type: y proximity;
    /* Same reason as .feed's scroll-padding below 900px: the root is the
     * scroller here, and proximity-snapped cards must settle below the
     * pinned masthead, not under it. */
    scroll-padding-top: var(--mast-h, 0px);
  }

  .layout {
    display: grid;
    grid-template-columns: var(--rail-width) minmax(0, 1fr);
    max-width: 1100px;
    margin: 0 auto;
  }

  .rail {
    display: block;
    border-right: 0.5px solid var(--rule);
    padding: var(--sp-4) 0 var(--sp-5);
    position: sticky;
    top: 0;
    align-self: start;
    max-height: 100dvh;
    overflow-y: auto;
    /* No scrollbar. The default one renders as a thick light column between
     * the rail and the stories — chrome heavier than the index it scrolls.
     * The rail still scrolls (wheel, trackpad), and the scroll-spy keeps the
     * active item in view on its own, so the reader rarely scrolls it by
     * hand at all. */
    scrollbar-width: none;
  }
  .rail::-webkit-scrollbar { display: none; }

  .rail__group {
    font-size: var(--fs-label);
    letter-spacing: 0.11em;
    text-transform: uppercase;
    color: var(--ink-faint);
    padding: var(--sp-3) var(--sp-4) var(--sp-2);
  }

  .rail__item {
    display: block;
    padding: var(--sp-2) var(--sp-4);
    font-size: 0.78125rem;
    line-height: var(--lh-tight);
    color: var(--ink-body);
  }

  .rail__item[aria-current="page"] {
    background: var(--accent-wash);
    border-left: 3px solid var(--accent);
    color: var(--ink);
    font-weight: 500;
  }

  .rail__meta {
    font-size: var(--fs-label);
    color: var(--ink-faint);
    margin-top: var(--sp-1);
  }

  .short {
    /* scroll-snap-align deliberately NOT reset to none any more: the base
     * rule's `start` is what gives the html-level proximity snap above its
     * targets. */
    padding: var(--sp-5) 0;
  }

  /* 24ch display cap removed 2026-08-30, editor's call: at ~417px it forced
   * headlines onto three lines that fit comfortably in two at the shared
   * measure (~34ch of the 27px face — still well inside display-type
   * comfort). The base rule's var(--measure) now governs here too, so the
   * headline shares the card's one right edge. */
  .short__headline { font-size: var(--fs-display); }

  /* The summary reads at --fs-lede (15px) up here while body stays at
   * --fs-body (14px), so the token must be recomputed: 62ch of 15px type
   * is 62 * 15/14 ch of the 14px font the token computes against. Same
   * family either way, so the scaling is exact. */
  body { --measure: calc(62ch * 15 / 14); }

  /* Type sizes and measures are a function of WIDTH and stay here. Everything
   * height-dependent — the un-clamp, min-height 0, the hidden control, the
   * whym pin — moved to the height-gated block below (2026-08-27). */
  .short__summary  { font-size: var(--fs-lede); }
  .whym__text      { font-size: var(--fs-lede); }
}

/* Desktop, but only when the window is also TALL enough for a whole card.
 *
 * The un-clamp above 900px wide silently assumed the height that comes with a
 * desktop monitor. A short window — laptop with a dock, half-height window,
 * DevTools open — got the worst of both: no clamp, no Read more, and the
 * card's tail (the why band, the actions) below the fold with proximity snap
 * pulling toward the next card's start. Seen on a ~905x746 window, 2026-08-27.
 *
 * Gating the un-clamp on height means a short desktop window keeps the whole
 * mobile mechanism instead: full-screen card, base clamp, the fitter grows it
 * to the room this window actually has, and the button appears only where
 * text is genuinely cut. The JS needs no change — the resize refit re-runs
 * setup() across the boundary.
 *
 * .short__more deliberately gets NO display rule in the short band: any
 * author-set display would override the UA's [hidden] rule that the fitter
 * uses to show the button only on clipped cards.
 *
 * 900px is PROVISIONAL — bracketed from card anatomy (an unclamped desktop
 * card plus masthead runs roughly 750–900px), not yet measured against the
 * live feed the way the 5- and 7-line clamps were. Verify with real card
 * heights and tighten. */
@media (min-width: 900px) and (min-height: 900px) {
  .short { min-height: 0; }
  /* min-height 0 means the .whym auto pin never has slack up here — same
   * flush-band mechanism as an expanded mobile card, fixed the same way. */
  .whym { margin-top: var(--sp-5); }
  .short__summary { -webkit-line-clamp: none; overflow: visible; }
  .short__more { display: none; }
}

/* Reduced motion is cheap to respect and expensive to retrofit. There is
 * deliberately no scroll-behavior guard here: nothing sets smooth anywhere —
 * the rail follows by instant scrollTop and snap gestures ignore
 * scroll-behavior — so a guard would describe behaviour nothing implements. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: 0.01ms !important; }
}

/* The healgate (2026-09-02, editor's design). iOS Chrome composites the
 * webview under its own bars on tabs opened from an external app, and only
 * a gesture that scrolls the DOCUMENT makes it re-layout — feed swipes are
 * sealed inside .feed by overscroll contain. This invisible full-viewport
 * layer catches a shared-link reader's FIRST swipe and, being fixed and
 * non-scrollable, hands it to the document; Chrome heals, the gate
 * dismisses on the same gesture, the toast points the reader onward.
 * Emitted only by HEADER_SCRIPT, only on CriOS permalink landings arriving
 * from outside, only on the mobile band. Below the sheet (60) and the
 * debug panel (100); above cards and masthead. */
.healgate { position: fixed; inset: 0; z-index: 55; background: transparent; }

.healgate__toast {
  position: fixed;
  left: 50%;
  bottom: 96px;
  transform: translateX(-50%) translateY(6px);
  z-index: 56;
  background: var(--ink);
  color: var(--paper);
  font-family: var(--font-sans);
  font-size: 0.8rem;
  line-height: 1;
  padding: 10px 16px;
  border-radius: 999px;
  opacity: 0;
  transition: opacity 0.25s ease, transform 0.25s ease;
  pointer-events: none;
}
.healgate__toast.show { opacity: 1; transform: translateX(-50%); }
