/* Article typography, the shared theme tokens, and the BLOCK VOCABULARY every
 * published surface renders — a rule scoped `:is(.sp-article, .sp-site-prose)`
 * is one of the latter. There is one published markup per kind, so its rules
 * live once; /site.css loads after this sheet and states what a page wants
 * differently. Every document that renders blocks links this sheet: the
 * standalone article page, the baked site page (page-shell.html) and the SPA.
 *
 * The visuals are a 1:1 copy of the EDITOR's reader view
 * (apps/editor/src/ArticleReader.tsx + ArticleBlock.tsx + index.css):
 * Lora serif body, Figtree sans for headings/byline/captions/quotes/factboxes,
 * 720px measure (wider than the editor's 592 — a deliberate reader choice),
 * per-article accent (hsl hue cycle, theme-tuned lightness),
 * the quote ornament, the bordered byline strip.
 *
 * Theme: `.light`/`.dark` on <html> wins (the SPA's toggle); with neither
 * class the OS preference applies. Standalone pages have no JS → OS pref.
 * Each block below also declares `color-scheme` (form controls / scrollbars,
 * plus the Android system nav bar via the browser's own UI negotiation); the
 * SPA additionally mirrors the resolved value as a `<meta name="color-scheme">`
 * tag (theme.ts::syncThemeMeta) since that's the same early, explicit channel
 * `theme-color` already uses for the status bar. */

@font-face { font-family: "Lora"; font-style: normal; font-weight: 400; font-display: swap; src: url("/fonts/lora/lora-v37-latin-regular.woff2") format("woff2"); }
@font-face { font-family: "Lora"; font-style: italic; font-weight: 400; font-display: swap; src: url("/fonts/lora/lora-v37-latin-italic.woff2") format("woff2"); }
@font-face { font-family: "Lora"; font-style: normal; font-weight: 500; font-display: swap; src: url("/fonts/lora/lora-v37-latin-500.woff2") format("woff2"); }
@font-face { font-family: "Lora"; font-style: normal; font-weight: 600; font-display: swap; src: url("/fonts/lora/lora-v37-latin-600.woff2") format("woff2"); }
@font-face { font-family: "Lora"; font-style: normal; font-weight: 700; font-display: swap; src: url("/fonts/lora/lora-v37-latin-700.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: normal; font-weight: 300; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-300.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: italic; font-weight: 300; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-300italic.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: normal; font-weight: 400; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-regular.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: italic; font-weight: 400; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-italic.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: normal; font-weight: 500; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-500.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: italic; font-weight: 500; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-500italic.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: normal; font-weight: 600; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-600.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: italic; font-weight: 600; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-600italic.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: normal; font-weight: 700; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-700.woff2") format("woff2"); }
@font-face { font-family: "Figtree"; font-style: italic; font-weight: 700; font-display: swap; src: url("/fonts/figtree/figtree-v9-latin-700italic.woff2") format("woff2"); }

/* ── Theme tokens (editor palette, index.css) ─────────────────────────── */
:root,
:root.light {
  --sp-bg: #f5f5f4;
  --sp-fg: #1c1c1c;
  --sp-surface: #ffffff;
  --sp-muted-bg: #e8e8e6;
  --sp-muted: #6b6b6b;
  --sp-border: #d4d4d0;
  --sp-primary: #1a6ec9;
  --sp-accent-l: 38%;
  --sp-serif: "Lora", Georgia, "Times New Roman", serif;
  --sp-sans: "Figtree", ui-sans-serif, system-ui, sans-serif;
  color-scheme: light;
}
:root.dark {
  --sp-bg: #1a1a1a;
  --sp-fg: #e8e8e8;
  --sp-surface: #232323;
  --sp-muted-bg: #2a2a2a;
  --sp-muted: #888888;
  --sp-border: #333333;
  --sp-primary: #4a9eff;
  --sp-accent-l: 55%;
  color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
  :root:not(.light):not(.dark) {
    --sp-bg: #1a1a1a;
    --sp-fg: #e8e8e8;
    --sp-surface: #232323;
    --sp-muted-bg: #2a2a2a;
    --sp-muted: #888888;
    --sp-border: #333333;
    --sp-primary: #4a9eff;
    --sp-accent-l: 55%;
    color-scheme: dark;
  }
}

/* Standalone article pages only (the SPA styles its own shell). */
body.sp-standalone {
  margin: 0;
  background: var(--sp-bg);
  color: var(--sp-fg);
}
body.sp-standalone .sp-article {
  padding: 2rem 1.25rem 6rem;
}

/* ── The block vocabulary's own tokens ────────────────────────────────────
 * One markup bakes into an article page and into a site page, so the two roots
 * both answer what its rules read. `.sp-article` retunes the accent per article
 * and comes after this rule at equal specificity, so it keeps its own. */
:is(.sp-article, .sp-site-prose) {
  --article-accent: var(--sp-primary);
  --sp-gallery-gap: 6px;
}

/* ── Article scope ────────────────────────────────────────────────────── */
.sp-article {
  /* Per-article accent: hue set inline (--accent-h) by the publish script,
   * lightness by theme — mirrors the editor's articleColor(). */
  --article-accent: hsl(var(--accent-h, 210) 70% var(--sp-accent-l));
  /* THE image gutter — the gallery's in-row flex gap AND the gap between
   * adjacent image blocks, one value so the two can never drift apart. MUST
   * equal the editor's `--stpub-gallery-gap`. */
  --sp-gallery-gap: 6px;
  max-width: 720px;
  margin: 0 auto;
  font-family: var(--sp-serif);
  /* Text-size control (views/ArticleView.tsx sets --article-font-scale on this
   * element from prefs.ts's persisted pref; every scaled rule below reads it
   * off inheritance). The standalone prerendered pages never set the var, so
   * every use falls back to 1 — identical to today's fixed sizes. */
  font-size: calc(1rem * var(--article-font-scale, 1));
  line-height: 1.625;
  color: var(--sp-fg);
  /* Inherited by every descendant (title, subtitle, body, captions): break an
   * over-long token — a URL, a long Norwegian compound — only when it would
   * otherwise overflow the 720px measure. Normal wrapping is untouched.
   * min-width:0 lets the article flex-shrink inside the split-view column so
   * this measure is the real width, not the content's intrinsic max. */
  overflow-wrap: break-word;
  min-width: 0;
}
.sp-article ::selection {
  background-color: color-mix(in srgb, var(--article-accent) 30%, transparent);
}

/* Deep-link arrival flash: a page-region click in the spread view scrolls the
 * reader to the target block (ArticleView.tsx) and adds .block-flash — a brief
 * accent wash so it's obvious where the jump landed. Ends transparent. */
@keyframes sp-block-flash {
  from {
    background-color: color-mix(in srgb, var(--article-accent) 32%, transparent);
  }
  to {
    background-color: transparent;
  }
}
.sp-article .block-flash {
  animation: sp-block-flash 1.3s ease-out;
  border-radius: 4px;
}
@media (prefers-reduced-motion: reduce) {
  .sp-article .block-flash {
    animation: none;
  }
}

/* Kicker: editor's label-caps in the article accent. */
.sp-article .kicker {
  font-family: var(--sp-sans);
  font-size: calc(11px * var(--article-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--article-accent);
  margin: 0 0 0.75rem;
}

/* Supertitle: a small sans all-caps label ABOVE the title (kicker-like). */
.sp-article .supertitle {
  font-family: var(--sp-sans);
  font-size: calc(0.875rem * var(--article-font-scale, 1));
  line-height: 1.3;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--sp-fg) 70%, transparent);
  margin: 0 0 0.5rem;
}

/* Title: serif 36px bold (editor: mt-0 mb-3 text-4xl leading-tight font-bold). */
.sp-article h1 {
  font-size: calc(2.25rem * var(--article-font-scale, 1));
  line-height: 1.25;
  font-weight: 700;
  margin: 0 0 0.75rem;
}

/* Subtitle: serif 20px, fg/80 (editor: mb-4 text-xl font-normal text-foreground/80). */
.sp-article .subtitle {
  font-size: calc(1.25rem * var(--article-font-scale, 1));
  line-height: 1.4;
  font-weight: 400;
  color: color-mix(in srgb, var(--sp-fg) 80%, transparent);
  margin: 0 0 1rem;
}

/* Byline strip: bordered, sans 13px muted (editor: mb-5 flex flex-col gap-3
 * border-y py-3 font-sans text-[13px] text-muted-foreground). */
.sp-article .byline {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  border-top: 1px solid var(--sp-border);
  border-bottom: 1px solid var(--sp-border);
  padding: 0.75rem 0;
  margin-bottom: 1.25rem;
  font-family: var(--sp-sans);
  font-size: calc(13px * var(--article-font-scale, 1));
  line-height: 1.45;
  color: var(--sp-muted);
}
.sp-article .byline .author {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
}
.sp-article .byline .portrait {
  width: 3.5rem;
  height: 3.5rem;
  flex-shrink: 0;
  border-radius: 2px;
  object-fit: cover;
}
.sp-article .byline .author-text {
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}
.sp-article .byline .author-name {
  font-weight: 700;
  color: var(--sp-fg);
}
.sp-article .byline .author-affiliation {
  margin-top: 0.125rem;
  font-style: italic;
  white-space: pre-wrap;
}
/* The PORTRAIT's caption (e.g. a photo credit) — under the affiliation lines, one
 * step quieter than them (editor: text-[11px] text-muted-foreground). */
.sp-article .byline .author-caption {
  margin-top: 0.125rem;
  font-size: 0.85em;
  opacity: 0.8;
  white-space: pre-wrap;
}

.sp-article .narration {
  width: 100%;
  margin-bottom: 1.25rem;
}

/* Blurb: sans 18px light, fg/90 (editor: mb-6 font-sans text-lg leading-normal
 * font-light whitespace-pre-wrap text-foreground/90). Sits AFTER the header. */
.sp-article .blurb {
  font-family: var(--sp-sans);
  font-size: calc(1.125rem * var(--article-font-scale, 1));
  line-height: 1.5;
  font-weight: 300;
  color: color-mix(in srgb, var(--sp-fg) 90%, transparent);
  margin: 0 0 1.5rem;
  white-space: pre-wrap;
}

/* Body paragraphs (editor: mt-0 mb-3.5 whitespace-pre-wrap, serif base). */
:is(.sp-article, .sp-site-prose) p {
  margin: 0 0 0.875rem;
  white-space: pre-wrap;
}

/* EMPHASIS BOX (v33 — promoted from a paragraph role to a container kind, so it
 * is an `<aside class="emphasis">` holding its own paragraphs, like the factbox).
 * Sans-serif, slightly larger, in the article accent — the same treatment the
 * emphasis paragraph carried, so a single-paragraph box is visually identical to
 * what it replaced. The inner paragraphs inherit and only manage their spacing. */
:is(.sp-article, .sp-site-prose) .emphasis {
  font-family: var(--sp-sans);
  font-size: calc(1.1em * var(--article-font-scale, 1));
  line-height: 1.45;
  color: var(--article-accent);
  margin: 1rem 0;
}
:is(.sp-article, .sp-site-prose) .emphasis > p {
  margin: 0 0 0.6em;
}
:is(.sp-article, .sp-site-prose) .emphasis > p:last-child {
  margin-bottom: 0;
}

/* Small text (editor: mt-0 mb-3.5 text-[0.75em] whitespace-pre-wrap) — for fine
 * print, credits, or asides. A BULLET carries the class itself when every line
 * it holds is small: its lines have no element of their own to take it. */
:is(.sp-article, .sp-site-prose) p.small-text,
:is(.sp-article, .sp-site-prose) li.small-text {
  font-size: calc(0.75em * var(--article-font-scale, 1));
  line-height: 1.45;
}

/* Prose set as a HEADING that a container draws inside its own element, so it
 * gets no `h2`/`h3` of its own — a bullet holding one is the case. The
 * element-borne rank below carries every other heading. */
:is(.sp-article, .sp-site-prose) li.heading-text,
:is(.sp-article, .sp-site-prose) p.heading-text {
  font-family: var(--sp-sans);
  font-size: calc(22px * var(--article-font-scale, 1));
  font-weight: 700;
  line-height: 1.3;
}

/* Separator: a short decorative rule spanning the left THIRD of the column
 * (never full-width — that would read as a section/article boundary, a
 * different concept). Mirrors the editor's `<Separator>` (py-3, w-1/3,
 * border-foreground, 2px — vyqowpuo: black + a bit thicker than the previous
 * neutral 1px `--sp-border` rule). `--sp-fg` reads as black in light mode
 * while staying visible in dark mode (a literal `#000` would vanish on a dark
 * background). */
:is(.sp-article, .sp-site-prose) hr.separator {
  width: 33%;
  margin: 1.5rem 0;
  border: none;
  border-top: 2px solid var(--sp-fg);
}

/* Body heading: SANS 22px bold (editor h2: mt-7 mb-2 font-sans text-[22px] font-bold). */
:is(.sp-article, .sp-site-prose) h2 {
  font-family: var(--sp-sans);
  font-size: calc(22px * var(--article-font-scale, 1));
  font-weight: 700;
  line-height: 1.3;
  margin: 1.75rem 0 0.5rem;
  white-space: pre-wrap;
}

/* Container sub-heading: caps 15px bold (editor child heading h3:
 * label-caps mt-0 mb-2 text-[15px] font-bold tracking-wide). */
:is(.sp-article, .sp-site-prose) h3 {
  font-family: var(--sp-sans);
  font-size: calc(15px * var(--article-font-scale, 1));
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin: 0 0 0.5rem;
}

/* Pullquote: sans 22px in the article accent with the ornament above
 * (editor: relative mx-0 my-4 pt-12 text-[22px] leading-snug accent). */
:is(.sp-article, .sp-site-prose) blockquote {
  position: relative;
  font-family: var(--sp-sans);
  font-size: calc(22px * var(--article-font-scale, 1));
  line-height: 1.375;
  color: var(--article-accent);
  margin: 1rem 0;
  padding: 3rem 0 0;
}
/* The brand quote ornament — two open-quote glyphs lifted from the magazine —
 * drawn by this sheet rather than baked into the markup, so it costs one rule
 * instead of 1.4kB of inline SVG per quote. Masked, not a background image, so
 * `currentColor` paints it exactly as the inline `fill: currentColor` did.
 * `left: 0` aligns it with the quote text, which starts at the blockquote's x=0
 * (`padding: 3rem 0 0` — no left padding); the box is the viewBox's own ratio
 * (30.1:18.9) at the rendered 42px height. */
:is(.sp-article, .sp-site-prose) blockquote::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 66.88px;
  height: 42px;
  background-color: currentColor;
  -webkit-mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='2.4%202.2%2030.1%2018.9'%3E%3Cpath%20transform='matrix%28.1,0,0,-.1,0,25%29'%20d='M153.621%20175.605C153.609%20175.617%20153.602%20175.637%20153.602%20175.648%20147.012%20204.598%20121.172%20226.219%2090.2305%20226.219%2054.3203%20226.219%2025.1992%20197.105%2025.1992%20161.188%2025.1992%20125.266%2054.3203%2096.1484%2090.2305%2096.1484%20113.281%2096.1484%20133.473%20108.168%20145.02%20126.246%20142.32%20108.438%20136.281%2089.0078%20123.383%2074.0781%20108.68%2057.0469%2087.2305%2048.418%2059.6406%2048.418%2057.3828%2048.418%2055.5508%2046.5859%2055.5508%2044.3164%2055.5508%2042.0586%2057.3828%2040.2266%2059.6406%2040.2266%2089.7422%2040.2266%20113.281%2049.8164%20129.609%2068.7461%20163.461%20107.977%20155.57%20171.188%20153.621%20175.605'/%3E%3Cpath%20transform='matrix%28.1,0,0,-.1,0,25%29'%20d='M314.039%20175.605C314.031%20175.617%20314.02%20175.637%20314.02%20175.648%20307.43%20204.598%20281.602%20226.219%20250.652%20226.219%20214.742%20226.219%20185.621%20197.105%20185.621%20161.188%20185.621%20125.266%20214.742%2096.1484%20250.652%2096.1484%20273.699%2096.1484%20293.891%20108.168%20305.441%20126.246%20302.742%20108.438%20296.699%2089.0078%20283.801%2074.0781%20269.102%2057.0469%20247.652%2048.418%20220.063%2048.418%20217.801%2048.418%20215.973%2046.5859%20215.973%2044.3164%20215.973%2042.0586%20217.801%2040.2266%20220.063%2040.2266%20250.16%2040.2266%20273.699%2049.8164%20290.031%2068.7461%20323.883%20107.977%20315.992%20171.188%20314.039%20175.605'/%3E%3C/svg%3E") no-repeat left top / contain;
  mask: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='2.4%202.2%2030.1%2018.9'%3E%3Cpath%20transform='matrix%28.1,0,0,-.1,0,25%29'%20d='M153.621%20175.605C153.609%20175.617%20153.602%20175.637%20153.602%20175.648%20147.012%20204.598%20121.172%20226.219%2090.2305%20226.219%2054.3203%20226.219%2025.1992%20197.105%2025.1992%20161.188%2025.1992%20125.266%2054.3203%2096.1484%2090.2305%2096.1484%20113.281%2096.1484%20133.473%20108.168%20145.02%20126.246%20142.32%20108.438%20136.281%2089.0078%20123.383%2074.0781%20108.68%2057.0469%2087.2305%2048.418%2059.6406%2048.418%2057.3828%2048.418%2055.5508%2046.5859%2055.5508%2044.3164%2055.5508%2042.0586%2057.3828%2040.2266%2059.6406%2040.2266%2089.7422%2040.2266%20113.281%2049.8164%20129.609%2068.7461%20163.461%20107.977%20155.57%20171.188%20153.621%20175.605'/%3E%3Cpath%20transform='matrix%28.1,0,0,-.1,0,25%29'%20d='M314.039%20175.605C314.031%20175.617%20314.02%20175.637%20314.02%20175.648%20307.43%20204.598%20281.602%20226.219%20250.652%20226.219%20214.742%20226.219%20185.621%20197.105%20185.621%20161.188%20185.621%20125.266%20214.742%2096.1484%20250.652%2096.1484%20273.699%2096.1484%20293.891%20108.168%20305.441%20126.246%20302.742%20108.438%20296.699%2089.0078%20283.801%2074.0781%20269.102%2057.0469%20247.652%2048.418%20220.063%2048.418%20217.801%2048.418%20215.973%2046.5859%20215.973%2044.3164%20215.973%2042.0586%20217.801%2040.2266%20220.063%2040.2266%20250.16%2040.2266%20273.699%2049.8164%20290.031%2068.7461%20323.883%20107.977%20315.992%20171.188%20314.039%20175.605'/%3E%3C/svg%3E") no-repeat left top / contain;
  pointer-events: none;
}
/* A page baked before the ornament left the markup carries its own `<svg>` and
 * links this same served sheet, so it would otherwise draw both. */
:is(.sp-article, .sp-site-prose) .quote-ornament {
  display: none;
}
:is(.sp-article, .sp-site-prose) blockquote p {
  margin: 0 0 0.5rem;
}
/* Factbox: sans on a faint panel (editor aside: my-5 rounded-md border
 * bg-white/3 px-5 py-4 text-base leading-relaxed). */
:is(.sp-article, .sp-site-prose) aside.factbox {
  font-family: var(--sp-sans);
  font-size: calc(1rem * var(--article-font-scale, 1));
  line-height: 1.625;
  border: 1px solid var(--sp-border);
  border-radius: 6px;
  background: color-mix(in srgb, #ffffff 3%, transparent);
  padding: 1rem 1.25rem;
  margin: 1.25rem 0;
}
:is(.sp-article, .sp-site-prose) aside.factbox p {
  margin: 0 0 0.5rem;
}
:is(.sp-article, .sp-site-prose) aside.factbox p:last-child {
  margin-bottom: 0;
}
/* NB-1: a factbox capped by a photo. The leading image bleeds past the aside's
 * 1rem 1.25rem padding to meet the border, its top corners share the box's 6px
 * rounding, then the padded text follows (editor: -mx-5 -mt-4 mb-4, rounded-t-md,
 * object-cover). */
:is(.sp-article, .sp-site-prose) aside.factbox > figure.factbox-photo {
  margin: -1rem -1.25rem 1rem;
  border-radius: 6px 6px 0 0;
}
:is(.sp-article, .sp-site-prose) aside.factbox > figure.factbox-photo img {
  max-height: none;
  object-fit: cover;
  border-radius: 6px 6px 0 0;
}

/* Images (editor figure: mx-0 my-4; img h-auto max-h-[70vh] w-full rounded-xs
 * object-contain; figcaption my-3 font-sans text-xs centered). */
:is(.sp-article, .sp-site-prose) figure {
  position: relative;
  margin: var(--sp-figure-margin, 1rem) 0;
  /* Clips the hover-scale below to a clean rounded edge (mirrors the
   * overflow-hidden rounded-md thumbnail wrapper in Library.tsx). */
  overflow: hidden;
  border-radius: 4px;
}
/* Adjacent image blocks (figures / galleries, any mix) read as rows of ONE
 * grid, so the gap between them is the same gutter as the in-row flex gap. */
:is(.sp-article, .sp-site-prose) figure:has(+ figure) {
  margin-bottom: var(--sp-gallery-gap);
}
:is(.sp-article, .sp-site-prose) figure + figure {
  margin-top: var(--sp-gallery-gap);
}
:is(.sp-article, .sp-site-prose) figure img {
  display: block;
  width: 100%;
  height: auto;
  max-height: 70vh;
  object-fit: contain;
  border-radius: 4px;
  /* Affordance: figure images open a lightbox. Signal it with a zoom cursor
   * and, on real hover devices, the same subtle scale-up the library's issue
   * thumbnails use on hover (Library.tsx: transition-transform duration-300
   * group-hover:scale-[1.02]) — touch has no hover, so the tap itself is the
   * affordance there. */
  cursor: zoom-in;
  transition:
    opacity 0.25s ease,
    transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@media (hover: hover) {
  :is(.sp-article, .sp-site-prose) figure img:hover {
    transform: scale(1.02);
  }
}
/* SPA-managed loading state: the reserved box (width/height attrs) shows as a
 * muted placeholder; the image fades in when complete (ArticleView.tsx).
 * Applies to every article image incl. byline portraits. */
:is(.sp-article, .sp-site-prose) figure:has(> img.img-loading) {
  background: var(--sp-muted-bg);
  border-radius: 4px;
}
:is(.sp-article, .sp-site-prose) img {
  transition: opacity 0.25s ease;
}
:is(.sp-article, .sp-site-prose) img.img-loading {
  opacity: 0;
}
/* Broken-image fallback (ArticleView.tsx's fadeInWhenLoaded): a failed figure
 * image is fully hidden (opacity:0 — a plain `display:none`/visibility hack
 * still leaves the browser's native broken-image glyph+alt-text painted) and
 * the figure — already sized by the image's reserved width/height attrs —
 * gets a muted placeholder background with a centered message instead. */
:is(.sp-article, .sp-site-prose) img.img-broken {
  opacity: 0;
}
:is(.sp-article, .sp-site-prose) figure:has(> img.img-broken) {
  background: var(--sp-muted-bg);
}
:is(.sp-article, .sp-site-prose) figure:has(> img.img-broken)::after {
  content: "Image unavailable";
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0.75rem;
  text-align: center;
  font-family: var(--sp-sans);
  font-size: 0.8125rem;
  color: var(--sp-muted);
}
/* Byline portraits aren't wrapped in a <figure> — same idea, scoped to the
 * fixed 3.5rem portrait box via the `.author` row (portrait is always its
 * first child; see articleHtml.ts). No text: too small to be legible. */
.sp-article .byline .author {
  position: relative;
}
.sp-article .byline .author:has(> img.portrait.img-broken)::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 3.5rem;
  height: 3.5rem;
  border-radius: 2px;
  background: var(--sp-muted-bg);
}
.sp-article .byline .portrait.img-broken {
  opacity: 0;
}
:is(.sp-article, .sp-site-prose) figcaption {
  font-family: var(--sp-sans);
  font-size: calc(12px * var(--article-font-scale, 1));
  line-height: 1.5;
  color: var(--sp-fg);
  text-align: center;
  margin: 0.75rem 0;
}

/* Image galleries: a `gallery` CONTAINER block (task #270e/#270f/#287d). Core
 * auto-groups runs of 2+ images into a gallery at import; the editor AND the
 * reader lay it out with the SAME shared pure packer (`packGallery` in
 * @steinerpublish/core), aspect-preserved, never cropped.
 *
 * The pack is BAKED once and rendered VERBATIM (task #303): the prerender projects the
 * gallery's STORED arrangement (`layoutFromArrangement` → `galleryFlexTree`) into a
 * NESTED-FLEX tree (task #313) — `.sp-gallery-split` flex rows/columns with a leaf
 * `.sp-gallery-tile` figure per image; `flex-grow`/`aspect-ratio` come inline. With
 * `flex-basis: 0` native flex subtracts the gutter BEFORE distributing, so every child
 * of a row ends at the same height → rows/cols are gap-free by construction (this fixes
 * the visible gaps the earlier absolute-rect render, #311, produced). The root
 * `.sp-gallery` reserves height via `aspect-ratio`, so the whole gallery is laid out
 * with ZERO client JS and correct with JS disabled — the crawler / shared-link
 * standalone page matches the SPA, AND matches the editor (same stored tree, same
 * flex projection, same gap). The gallery-level caption renders as a normal figcaption
 * BELOW the gallery (styled by the generic figcaption rule above). */
:is(.sp-article, .sp-site-prose) .sp-gallery-figure {
  /* Via the base figure rule's margin var, NOT a margin declaration here — a
   * shorthand at this specificity would override the adjacent-figure gutter. */
  --sp-figure-margin: 1.25rem;
  /* The generic figure rule clips + rounds; a gallery manages its own tile
   * radii, so opt out here. */
  overflow: visible;
  border-radius: 0;
}
:is(.sp-article, .sp-site-prose) .sp-gallery {
  /* Flex wrapper for the nested-flex tree (task #313); its inline `aspect-ratio`
   * reserves the whole gallery's height once. `--sp-gallery-gap` (inherited from
   * `.sp-article`) is a PURELY VISUAL gutter — native flex subtracts it before
   * distributing, so rows/cols stay gap-free for any value (it need not match the
   * packer's baked gap fraction). */
  display: flex;
  width: 100%;
  margin: 0;
}
/* An internal split node: a flex row (children side by side) or column (stacked);
 * `flex-direction` + `flex` come inline. Children size from flex-grow (∝ aspect for a
 * row, ∝ 1/aspect for a column). min-*:0 lets a child shrink below its content. */
:is(.sp-article, .sp-site-prose) .sp-gallery-split {
  display: flex;
  gap: var(--sp-gallery-gap);
  min-width: 0;
  min-height: 0;
}
:is(.sp-article, .sp-site-prose) .sp-gallery-tile {
  /* Each tile figure IS a flex leaf: `flex` + `aspect-ratio` come inline. It stays
   * `position: relative` as the containing block for its caption scrim. */
  position: relative;
  margin: 0;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
  border-radius: 2px;
  /* No background: each box matches its image's aspect very closely, and the tile
   * image COVER-fills it (task #317), so there is nothing to letterbox — keep any
   * residual margin INVISIBLE (the article bg shows through) not a gray frame (#282). */
  background: transparent;
}
:is(.sp-article, .sp-site-prose) .sp-gallery-tile img {
  display: block;
  width: 100%;
  height: 100%;
  /* Override the generic figure-img cap: a gallery tile fills its packed box. */
  max-height: none;
  /* COVER, not contain (task #317): the leaf box aspect matches the (already
   * build-time-cropped) image aspect to sub-pixel, so cover absorbs the residual
   * rounding as an imperceptible crop rather than a letterbox gap or a stretch. */
  object-fit: cover;
}
/* Per-tile caption overlaid at the bottom with a gradient scrim (reader
 * decision, task #270e): a packed tile can't stack a figcaption BELOW its fixed
 * box, and dropping captions would lose content the old gallery showed — so keep
 * them as a subtle overlay. The full caption also appears in the lightbox on tap
 * (Lightbox reads the figcaption). The GALLERY-LEVEL caption is a separate
 * figcaption below the whole gallery, NOT scoped here. */
:is(.sp-article, .sp-site-prose) .sp-gallery-tile figcaption {
  position: absolute;
  inset: auto 0 0 0;
  margin: 0;
  padding: 1.75rem 0.6rem 0.5rem;
  font-size: 0.7rem;
  line-height: 1.3;
  text-align: left;
  color: #fff;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.72), rgba(0, 0, 0, 0));
  pointer-events: none;
}

/* Lists (editor: pl-7 items, absolute muted marker, mb-1 per item). */
:is(.sp-article, .sp-site-prose) ol,
:is(.sp-article, .sp-site-prose) ul {
  margin: 0 0 0.875rem;
  padding-left: 1.75rem;
}
/* Restore markers: the SPA injects this fragment under Tailwind's preflight,
 * which resets `list-style: none` on ul/ol. */
:is(.sp-article, .sp-site-prose) ul {
  list-style: disc;
}
:is(.sp-article, .sp-site-prose) ol {
  list-style: decimal;
}
:is(.sp-article, .sp-site-prose) li {
  margin-bottom: 0.25rem;
  white-space: pre-wrap;
}
:is(.sp-article, .sp-site-prose) li::marker {
  color: var(--sp-muted);
  font-variant-numeric: tabular-nums;
}

/* Footnote markers render sans (editor: [data-footnote-start] → font-sans). */
:is(.sp-article, .sp-site-prose) sup.footnote {
  font-family: var(--sp-sans);
  color: var(--article-accent);
}

:is(.sp-article, .sp-site-prose) mark {
  background: color-mix(in srgb, var(--article-accent) 25%, transparent);
  color: inherit;
  border-radius: 2px;
}

/* ── Print ─────────────────────────────────────────────────────────────
 * Shared by the SPA's injected fragment AND the standalone prerendered page
 * (see the file banner) — the standalone page is the more likely target
 * (no chrome to begin with), but the SPA route must print sanely too.
 *
 * 1. The SPA is a fixed-height scrolling shell (index.css: html/body/#root
 *    pinned to 100svh; the article pane is `sm:h-full sm:overflow-y-auto`),
 *    which would clip a printed article to one screen's worth of content.
 *    Forcing the shell to `height: auto` mirrors the existing
 *    `sp-mobile-article` trick (index.css) — a percentage height against an
 *    auto-height ancestor resolves to `auto` per spec, so every `h-full`/
 *    `flex-1` box down the chain relaxes to plain flow and `overflow-y-auto`
 *    has nothing left to scroll.
 * 2. Hide app chrome around the article: the ViewHeader / split-view pane
 *    toolbar, the ToC sidebar, the prev/next gutter arrows + nav cards, the
 *    swipe-hint chips, and any open dialog (lightbox / search / mobile ToC).
 *    None of this exists on the standalone page, so these are no-ops there.
 * 3. Force the light palette regardless of `.dark`/OS preference — print is
 *    always white paper.
 * 4. Print-scale serif sizing (pt, not the screen rem/px scale) + page-break
 *    rules: a heading never ends a page (stays with what follows), figures/
 *    factboxes/the byline strip never split mid-block.
 * 5. Drop the 70vh screen cap on images — meaningless on a printed page;
 *    `break-inside: avoid` on the figure keeps it whole instead. */
/* ── Floated blocks (feature NB-2 Phase 1) ─────────────────────────────────
 * A GENERIC float wrapper emitted by readerArticleHtml around ANY block-level
 * container (figure, factbox, gallery, …) whose `placement` is float-left/right.
 * Body text FOLLOWING it wraps around it; `width` (inline, a % of the measure)
 * sizes it. Not image-specific: one mechanism floats a figure, a factbox, etc.
 * The inner block keeps its own emitter output untouched — we only reset its
 * doubled top margin so the float lines up with the paragraph beside it. */
:is(.sp-article, .sp-site-prose) .sp-float {
  box-sizing: border-box;
}
:is(.sp-article, .sp-site-prose) .sp-float-left {
  float: left;
  margin: 0.35rem 1.5rem 0.75rem 0;
  clear: left;
}
:is(.sp-article, .sp-site-prose) .sp-float-right {
  float: right;
  margin: 0.35rem 0 0.75rem 1.5rem;
  clear: right;
}
/* The wrapper carries the float's own spacing, so whatever it holds — a figure,
 * a box, a bare line of prose — starts flush with the paragraph beside it. */
:is(.sp-article, .sp-site-prose) .sp-float > * {
  margin-top: 0;
}
/* Narrow-collapse: a float is unreadable in a narrow column, so below 640px the
 * wrapper COLLAPSES back to a normal full-measure stacked block (float:none). */
@media (max-width: 640px) {
  /* `!important` on every property here, not just `width`: `.sp-float-left`/
   * `.sp-float-right` above are MORE specific (extra class) than this plain
   * `.sp-float` media rule, so on a narrow viewport their `float`/`clear`/
   * `margin` were WINNING over this collapse despite coming first in the
   * cascade — specificity beats source order, media queries don't change
   * that. The float never actually collapsed: it stayed `float: left/right`
   * with `width: auto` (the one property that WAS `!important` already), and
   * a floated block with no explicit width shrinks-to-fit unpredictably —
   * some floated figures ended up invisible/near-0-width on mobile. */
  :is(.sp-article, .sp-site-prose) .sp-float {
    float: none !important;
    clear: none !important;
    width: auto !important;
    margin: 0.35rem 0 0.75rem 0 !important;
  }
}

/* ── Side-by-side columns ──────────────────────────────────────────────────
 * A `columns` container: a grid whose tracks come from the slots' own weights
 * (inline `grid-template-columns`, written by readerArticleHtml). Below 640px
 * it collapses to ONE track, so the slots stack in reading order — the whole
 * reason the slot order is the document order. */
:is(.sp-article, .sp-site-prose) .sp-columns {
  display: grid;
  gap: 0 1.75rem;
  margin: 1.25rem 0;
  align-items: start;
}
:is(.sp-article, .sp-site-prose) .sp-column {
  min-width: 0;
}
:is(.sp-article, .sp-site-prose) .sp-column > :first-child {
  margin-top: 0;
}
@media (max-width: 640px) {
  :is(.sp-article, .sp-site-prose) .sp-columns {
    grid-template-columns: 1fr !important;
    gap: 0;
  }
}

@media print {
  html,
  body,
  #root {
    height: auto !important;
  }

  /* App chrome (SPA route only). The header selector excludes the ARTICLE's
   * own <header> (kicker/supertitle/title/subtitle/byline/narration, emitted
   * by articleHtml.ts) — only the outer ViewHeader chrome above it is hidden.
   * `[data-print-hide]` is a STABLE hook the chrome elements carry directly
   * (the ToC aside, the article nav, the prev/next gutter arrows, the split
   * view's spread column) — it must stay locale-independent, so no matching
   * on the translated aria-labels. */
  header:not(.sp-article header),
  [data-print-hide],
  .swipe-hint,
  .article-actions-row,
  [role="dialog"] {
    display: none !important;
  }

  /* The split view prints as the plain single-article view: its spread column
   * carries `data-print-hide`, and the article pane drops the column sizing it
   * only has because a spread sits beside it. */
  [data-print-full] {
    max-width: none !important;
    flex: 1 1 auto !important;
    border: 0 !important;
  }

  :root {
    --sp-bg: #ffffff !important;
    --sp-fg: #000000 !important;
    --sp-muted-bg: #eeeeee !important;
    --sp-muted: #555555 !important;
    --sp-border: #cccccc !important;
    --sp-accent-l: 32% !important;
    color-scheme: light !important;
  }

  .sp-article {
    max-width: 100%;
    padding: 0 !important;
    font-size: 11pt;
    line-height: 1.5;
  }
  .sp-article .kicker {
    font-size: 8.5pt;
  }
  .sp-article .supertitle {
    font-size: 10pt;
  }
  .sp-article h1 {
    font-size: 22pt;
  }
  .sp-article .subtitle {
    font-size: 13pt;
  }
  .sp-article .byline {
    font-size: 9pt;
  }
  .sp-article .blurb {
    font-size: 12pt;
  }
  .sp-article h2 {
    font-size: 15pt;
  }
  .sp-article h3 {
    font-size: 10.5pt;
  }
  .sp-article blockquote {
    font-size: 14pt;
  }
  .sp-article figcaption {
    font-size: 9pt;
  }

  /* Never split these; never end a page on a heading/kicker. */
  .sp-article header,
  .sp-article h1,
  .sp-article h2,
  .sp-article h3,
  .sp-article .byline,
  .sp-article figure,
  .sp-article aside.factbox,
  .sp-article blockquote {
    page-break-inside: avoid;
    break-inside: avoid;
  }
  .sp-article header,
  .sp-article h1,
  .sp-article h2,
  .sp-article h3,
  .sp-article .kicker {
    page-break-after: avoid;
    break-after: avoid;
  }
  .sp-article p,
  .sp-article li {
    orphans: 3;
    widows: 3;
  }

  .sp-article figure img {
    max-height: none;
  }

  /* Inert on paper. */
  .sp-article .narration {
    display: none;
  }
}
