/* ═══ OPINIONS AND GUIDES — /opinions/ layout ═══
   The hub page and the post page. Nothing else uses this file, and it is
   loaded AFTER assets/site.css, which owns everything shared:
     the reset, <body>, .container, .bleed-l/.bleed-r, the h1-h4 ramp,
     .btn / .btn--ondark, .eyebrow, the whole nav, the footer, and the
     global :focus-visible ring.
   None of those are redefined here. Where a rule below touches a shared
   element (prose h2/h3, .op-cta h2) it is a scoped OVERRIDE of size or
   colour inside a component, never a second definition of the base.

   TOKENS: block 2 (SEMANTIC) of assets/tokens.css only. One documented
   escape, flagged in place: the monospace stack. The --font-mono role now
   exists (added 2026-09-02 for tool UI), but opinions.css does not use it
   on purpose.

   DESIGN REFERENCE: A&O Shearman's insights pages, read for structure and
   translated into CCD's slate-blue editorial language. Structure only.
   Three deliberate corrections against the blog CSS this replaces
   (templates/post.html, being deleted):
     1. code carried a 6px radius on a site with no rounding anywhere —
        now --radius-chip, which is 0.
     2. blockquote was a tinted panel with border-left: 4px solid. That is
        the design linter's `side-tab` rule and the most-cited AI tell in
        the project's notes. It is now a TOP rule with no fill, set in the
        display serif so it reads as an editorial pull-quote.
     3. the pill-shaped date badge is gone. Meta is plain text.

   Canonical breakpoints (media queries cannot read vars):
   560 / 640 / 900 / 960 / 1080 / 1200. Only the ones that earn a change
   appear below. */


/* ── 1. shared editorial furniture ──────────────────────────────────── */

/* The rule that sits under a heading on both pages. An <hr>, so it is a
   separator in the accessibility tree as well as on screen. */
.op-rule {
  border: 0;
  border-top: var(--rule-hairline) solid var(--border-subtle);
  margin: var(--space-stack) 0;
}

/* The standfirst under an h1 on both pages. Same role as the landing
   page's section intros, so it takes the same three tokens. */
.op-lede {
  max-width: 58ch;
  color: var(--text-muted);
  font-size: var(--text-lede);
  line-height: var(--leading-normal);
}

/* Both pages bleed an image past the container, so both must clip X — the
   same pairing site.css documents on <body>: `clip`, never `hidden`, or
   the section becomes a scroll container. */
.op-hero, .op-post { overflow-x: clip; }

/* Its own element, ABOVE .op-post-head / .op-hero-grid — never inside
   .op-post-head, whose four children (.eyebrow kicker, h1, .op-rule,
   .op-lede) are a pinned, documented order and a fifth item does not slot
   into it without breaking that contract. Shared by both pages; both the
   markup here and the matching BreadcrumbList JSON-LD are rendered from
   ONE array in tools/build.mjs's breadcrumbTrail(), so the visible trail
   and the structured data can never name a different crumb or point at a
   different URL — same discipline as heroUrl feeding both og:image and
   the JSON-LD `image`. The current crumb is a <span aria-current="page">,
   never a link — a page does not link to itself, matching how the nav
   marks its own current item with the same attribute. The separator is a
   real "/" character, not an image, in its own aria-hidden span so a
   screen reader announces the crumb list, not a slash between each pair. */
.op-crumbs { margin: 0 0 var(--space-stack); }
.op-crumbs ol {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  list-style: none;
  margin: 0;
  padding: 0;
}
.op-crumbs li { display: flex; align-items: baseline; }
.op-crumbs a,
.op-crumbs [aria-current='page'] {
  font-size: var(--text-caption);
  color: var(--text-muted);
}
.op-crumbs a {
  text-decoration: none;
  transition: color var(--motion-fast) var(--motion-ease);
}
.op-crumbs a:hover, .op-crumbs a:focus-visible { color: var(--accent-text); }
.op-crumbs-sep {
  margin: 0 8px;
  color: var(--text-muted);
  font-size: var(--text-caption);
}
/* Inside .op-post the trail shares the post's own measure and centring so
   its left edge lines up with the h1 below it (both resolve to the same
   1160px column, centred the same way inside the wider .container). The
   hub has no such measure — .op-hero-grid runs the full container — so
   the trail is left plain there and sits at the container's edge, level
   with .op-hero-copy's h1. Higher specificity than the base rule above,
   so this wins where it applies. */
.op-post .op-crumbs {
  max-width: var(--op-column, 1160px);
  margin: 0 auto var(--space-stack);
}


/* ── 2. hub hero ────────────────────────────────────────────────────── */

/* Copy left at a hard measure, one large plate running off the right edge.
   The asymmetry is the point: a centred hero with a centred image under it
   is the shape every generated index page arrives in. */
.op-hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 44fr) minmax(0, 56fr);
  gap: 0 clamp(40px, 5vw, 88px);
  align-items: center;
}
.op-hero-copy { grid-column: 1; }
.op-hero-copy h1 { max-width: 14ch; }

/* order: 2 rather than a second grid-column so the copy still reads first
   in the DOM, which is the order it should be announced and the order it
   stacks in below 900. */
.op-hero-plate {
  grid-column: 2;
  order: 2;
  position: relative;
  /* The plate is a window, not a photo at its natural size: it takes
     whatever height the column can carry and the image crops into it. */
  height: clamp(320px, 34vw, 560px);
  background: var(--surface-sunken);
  border-radius: var(--radius-card);
}
.op-hero-plate picture { display: block; width: 100%; height: 100%; }
.op-hero-plate img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  border-radius: var(--radius-card);
}


/* ── 3. hub post list ───────────────────────────────────────────────── */

/* Underlined rows in two columns, NOT a grid of cards. A card grid would
   put a border on four sides of every post and land straight on the
   cluster-of-three tell; a rule under each row is what an index in print
   does, and it is already the site's own language (.niche-row on the
   landing page). */
.op-list-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gap-grid);
  flex-wrap: wrap;
  margin-bottom: var(--gap-grid);
}
.op-list {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  /* The column gap is wide enough that the two runs of rules read as two
     columns of an index rather than one table with a hairline down it. */
  column-gap: clamp(40px, 5vw, 88px);
}
.op-item { border-top: var(--rule-hairline) solid var(--border-subtle); }
/* An odd number of posts leaves the last row half-ruled, which reads as a
   mistake. Let the orphan span instead — the row structure is unchanged,
   it just gets the full measure. */
.op-item:last-child { border-bottom: var(--rule-hairline) solid var(--border-subtle); }
.op-item:nth-last-child(2):nth-child(even) { border-bottom: var(--rule-hairline) solid var(--border-subtle); }

.op-row {
  --op-thumb: clamp(88px, 9vw, 132px);
  display: grid;
  grid-template-columns: var(--op-thumb) minmax(0, 1fr) 30px;
  gap: 0 clamp(20px, 2vw, 28px);
  align-items: start;
  /* Flush left and right with its own rule — no inset, no hover panel.
     The old blog reached for a filled row on hover; the affordance here is
     the arrow, the title colour and the thumbnail coming up in colour. */
  padding: 28px 0 30px;
  text-decoration: none;
  color: inherit;
}
.op-thumb {
  display: block;
  aspect-ratio: 4 / 3;
  background: var(--surface-sunken);
  overflow: hidden;
  border-radius: var(--radius-card);
}
.op-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* Desaturated at rest, in colour on hover. The landing page already sets
     its portrait in black and white, so grey is the resting state of
     photography on this site; restoring the colour is the reward for
     pointing at the row. No scale, no rotate — an image that jumps under
     the cursor is its own tell. */
  filter: grayscale(1) contrast(1.04);
  transition: filter var(--motion-slow) var(--motion-ease);
}
.op-rowtext { display: block; min-width: 0; }
/* Plain text, sentence case. Deliberately NOT an uppercase tracked label:
   the project sanctions exactly four of those (.btn, .eyebrow,
   .footer-col h4, .nav-links a) and a date is not a fifth. */
.op-meta {
  display: block;
  font-size: var(--text-caption);
  color: var(--text-muted);
}
.op-title {
  display: block;
  margin-top: 10px;
  font-family: var(--font-display);
  font-weight: var(--weight-display);
  /* --text-title is the page h2 clamp and is far too large for a row in a
     two-column index; this is the row's own step. */
  font-size: clamp(1.25rem, 1.7vw, 1.6rem);
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-display);
  color: var(--text-heading-color);
  transition: color var(--motion-fast) var(--motion-ease);
}
.op-desc {
  display: block;
  margin-top: 10px;
  max-width: 46ch;
  color: var(--text-muted);
  font-size: var(--text-body);
  line-height: var(--leading-normal);
}
.op-arrow {
  grid-column: 3;
  width: 30px;
  height: 10px;
  justify-self: end;
  /* bottom-right of the row, level with the last line of the description */
  align-self: end;
  margin-bottom: 0.4em;
  overflow: visible;
  transition: transform var(--motion-base) var(--motion-ease);
}
.op-arrow path { stroke: var(--accent); stroke-width: 1; fill: none; }

.op-row:hover .op-title, .op-row:focus-visible .op-title { color: var(--accent-text); }
.op-row:hover .op-arrow, .op-row:focus-visible .op-arrow { transform: translateX(8px); }
.op-row:hover .op-thumb img, .op-row:focus-visible .op-thumb img { filter: grayscale(0) contrast(1); }
/* the row has no inner padding, so the global 3px ring would sit tight
   against the rule above it */
.op-row:focus-visible { outline-offset: 6px; }


/* ── 4. post head ───────────────────────────────────────────────────── */

/* --op-column is the post's measure: the head, the body grid and the hero
   caption all resolve to the same box so their left edges line up down the
   page. Declared here and read with a fallback, so a consumer that loses
   the .op-post class still lays out. */
.op-post { --op-column: 1160px; }

.op-post-head {
  max-width: var(--op-column, 1160px);
  margin: 0 auto;
}
/* A plain .eyebrow kicker sits above this h1 in the markup. It is
   site.css's, styled there, and it is a text kicker — not a pill, not a
   badge, not a chip. */
.op-post-head h1 { max-width: 20ch; }
.op-post-head .op-lede { margin-top: var(--space-stack); }


/* ── 5. post hero photo ─────────────────────────────────────────────── */

/* Full bleed: the figure carries .bleed-l AND .bleed-r, so it escapes the
   container on both sides and touches the viewport. */
.op-hero-photo {
  margin-top: clamp(40px, 5vw, 72px);
}
.op-hero-photo img {
  background: var(--surface-sunken);
  width: 100%;
  height: clamp(240px, 42vw, 620px);
  object-fit: cover;
  display: block;
  border-radius: var(--radius-card);
}
/* The photo is full-bleed but its caption must not be — text running to
   the viewport edge is its own failure. width: min() puts the caption back
   on the post's measure at every width without needing the gutter twice. */
.op-hero-photo figcaption {
  width: min(var(--op-column, 1160px), 100% - var(--gutter, 48px) * 2);
  margin: 14px auto 0;
  font-size: var(--text-caption);
  color: var(--text-muted);
}


/* ── 6. post body: sticky rail + prose ──────────────────────────────── */

/* Rail left, article right, the pair centred on the page. The rail is not
   navigation and not a table of contents — it is the masthead data an
   editorial page keeps beside the text: when it ran, how long it runs,
   who wrote it, what it is about. */
.op-body {
  display: grid;
  grid-template-columns: minmax(0, 16rem) minmax(0, 68ch);
  gap: 0 clamp(40px, 6vw, 96px);
  justify-content: center;
  max-width: var(--op-column, 1160px);
  margin: clamp(48px, 6vw, 88px) auto 0;
}
.op-rail {
  position: sticky;
  /* clears the stuck nav plus a breath */
  top: calc(var(--nav-h) + var(--space-stack));
  align-self: start;
}
.op-rail-block + .op-rail-block { margin-top: 26px; }
/* Sentence case for the same reason .op-meta is — the uppercase tracked
   label budget is spent. Size and colour carry the hierarchy instead. */
.op-rail-label {
  display: block;
  font-size: var(--text-caption);
  color: var(--text-muted);
}
.op-rail-value {
  display: block;
  margin-top: 4px;
  font-size: var(--text-body);
  font-weight: var(--weight-emphasis);
  color: var(--text-heading-color);
}
.op-tags {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-inline);
  margin-top: var(--space-inline);
}
.op-tag {
  padding: 5px 12px;
  border: var(--rule-hairline) solid var(--border-subtle);
  border-radius: var(--radius-chip);
  background: var(--wash);
  color: var(--accent-ink);
  font-size: var(--text-label);
  font-weight: var(--weight-emphasis);
  line-height: var(--leading-snug);
}


/* ── 7. .prose — the article body ───────────────────────────────────── */

/* Everything a Markdown post can emit. This is the surface every post
   renders through, so it is styled exhaustively rather than by whatever
   the one existing post happens to contain. */
.prose {
  max-width: 68ch;
  font-size: var(--text-body);
  line-height: var(--leading-body);
  color: var(--text-on-page);
}
.prose > :first-child { margin-top: 0; }
.prose > :last-child { margin-bottom: 0; }

/* h2/h3 get their family and weight from site.css (serif h2, sans h3) and
   only their SIZE and rhythm here: the page-level clamps are display sizes
   meant to be seen across a 1856px measure, and inside a 68ch column they
   would tower over the text. The ladder below is 34 / 22 / 17 at the
   ceiling — each step a clear jump, none of them close enough to read as a
   flat hierarchy. Adjacent sibling margins collapse in this block flow, so
   a heading after a paragraph sits at the heading's margin, not the sum. */
.prose h2 {
  margin: var(--space-block) 0 var(--space-stack);
  font-size: clamp(1.6rem, 2.6vw, 2.125rem);
  line-height: var(--leading-tight);
}
.prose h3 {
  margin: var(--gap-grid) 0 12px;
  font-size: clamp(1.2rem, 1.8vw, 1.4rem);
  color: var(--text-heading-color);
}
.prose p { margin: 0 0 var(--space-stack); }
.prose a {
  color: var(--accent-text);
  text-decoration: underline;
  text-decoration-color: var(--accent-200);
  text-decoration-thickness: 1px;
  text-underline-offset: 3px;
  transition: color var(--motion-fast) var(--motion-ease),
              text-decoration-color var(--motion-fast) var(--motion-ease);
}
.prose a:hover, .prose a:focus-visible {
  color: var(--accent-deep);
  text-decoration-color: var(--accent);
}
.prose ul, .prose ol { margin: 0 0 var(--space-stack); padding-left: 1.4em; }
.prose ul { list-style: disc; }
.prose ol { list-style: decimal; }
.prose li { margin-bottom: var(--space-inline); }
.prose li::marker { color: var(--accent); }
/* nested lists tighten rather than repeat the block spacing */
.prose li > ul, .prose li > ol { margin: var(--space-inline) 0 0; }

/* THE CORRECTION. The old rule was a tinted box with border-left: 4px —
   the linter's side-tab pattern verbatim. A quote in a magazine is not a
   callout box; it is the same voice, set larger, with a line above it. No
   fill, no left tab, no italic (an oversized italic serif is its own
   tell), set roman in the display face. */
.prose blockquote {
  margin: clamp(36px, 4vw, 52px) 0;
  padding-top: 22px;
  border-top: var(--rule-strong) solid var(--border-accent);
  font-family: var(--font-display);
  font-weight: var(--weight-display);
  font-size: clamp(1.25rem, 2vw, 1.6rem);
  line-height: var(--leading-snug);
  letter-spacing: var(--tracking-display);
  color: var(--text-heading-color);
}
.prose blockquote p { margin: 0 0 12px; }
.prose blockquote > :last-child { margin-bottom: 0; }
.prose blockquote cite {
  display: block;
  margin-top: 14px;
  font-family: var(--font-body);
  font-size: var(--text-caption);
  font-weight: var(--weight-body);
  font-style: normal;
  letter-spacing: var(--tracking-body);
  color: var(--text-muted);
}

/* The monospace stack is written out because tokens.css has no --font-mono
   role in any block: nothing on this site has ever been set in mono. If
   code becomes common in posts, that is the token to add. */
.prose code {
  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
  font-size: 0.9em;
  padding: 2px 6px;
  background: var(--surface-sunken);
  border: var(--rule-hairline) solid var(--border-subtle);
  /* was 6px. The site has no rounding — see the header note. */
  border-radius: var(--radius-chip);
  letter-spacing: normal;
}
.prose pre {
  margin: 0 0 var(--space-stack);
  padding: 20px 24px;
  background: var(--surface-sunken);
  border: var(--rule-hairline) solid var(--border-subtle);
  border-radius: var(--radius-card);
  overflow-x: auto;
  line-height: var(--leading-relaxed);
}
.prose pre code {
  padding: 0;
  background: none;
  border: 0;
  font-size: var(--text-caption);
}
.prose img {
  display: block;
  max-width: 100%;
  height: auto;
  margin: clamp(32px, 4vw, 44px) 0;
  border-radius: var(--radius-card);
}
.prose figure { margin: clamp(32px, 4vw, 44px) 0; }
.prose figure img { margin: 0; }
.prose figcaption {
  margin-top: 12px;
  font-size: var(--text-caption);
  color: var(--text-muted);
}
.prose hr {
  border: 0;
  border-top: var(--rule-hairline) solid var(--border-subtle);
  margin: clamp(40px, 5vw, 56px) 0;
}
/* Body copy runs Light (300), so emphasis has to move two steps to be
   seen; 500 against 300 is the jump, not 400. */
.prose strong { font-weight: var(--weight-emphasis); color: var(--text-heading-color); }
.prose em { font-style: italic; }

/* Optional tail under the article — a way back to the hub. */
.op-back {
  display: inline-block;
  margin-top: var(--space-block);
  padding-top: var(--space-stack);
  border-top: var(--rule-hairline) solid var(--border-subtle);
  color: var(--text-muted);
  font-size: var(--text-caption);
  text-decoration: none;
  transition: color var(--motion-fast) var(--motion-ease);
}
.op-back:hover, .op-back:focus-visible { color: var(--accent-text); }


/* ── 8. dark CTA band ───────────────────────────────────────────────── */

/* A <section>, so it is full-bleed by construction and needs no escape
   from the container. site.css's --space-section (160px) is the landing
   page's rhythm and too much for a closing band. */
.op-cta {
  padding: clamp(64px, 8vw, 104px) 0;
  background: var(--surface-inverse);
  color: var(--ink-ondark);
}
.op-cta-inner {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--gap-grid);
  flex-wrap: wrap;
  max-width: var(--op-column, 1160px);
  margin: 0 auto;
}
.op-cta-copy { max-width: 40ch; }
/* h2 inherits --text-heading-color from site.css, which is the ink itself
   — invisible on this ground. Same for the lede's --text-muted. */
.op-cta h2 { color: var(--ink-ondark); font-size: clamp(1.75rem, 3vw, 2.5rem); }
.op-cta .op-lede { margin-top: var(--space-stack); color: var(--muted-ondark); }
/* site.css restates the focus ring on .btn--ondark; any plain link in the
   band needs the same treatment or its ring measures 1.5:1 on the ink. */
.op-cta a:not(.btn):focus-visible { outline-color: var(--accent-ondark); }


/* ── 9. 404 quick links ─────────────────────────────────────────────── */

/* The 404 page's only bespoke component — everything else on it is .btn,
   .eyebrow, .op-rule and .op-lede, unmodified. A dead link should not be a
   dead end, so this is a flat list of the site's real sections plus the
   primary CTA. Sits inside .op-post, so var(--op-column) matches the
   heading above it without restating 1160px. Every value below is an
   existing semantic token; nothing here is a new primitive. */
.op-404-nav {
  max-width: var(--op-column, 1160px);
  margin: var(--space-block) auto 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-block) var(--gap-card);
}
.op-404-nav ul {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-stack) var(--gap-card);
}
/* :not(.btn) — the CTA shares this list's flex row but must keep .btn's own
   colour/decoration untouched, the same guard .op-cta uses one block up. */
.op-404-nav a:not(.btn) {
  color: var(--accent-ink);
  text-decoration: none;
  font-size: var(--text-body);
  transition: color var(--motion-fast) var(--motion-ease);
}
.op-404-nav a:not(.btn):hover, .op-404-nav a:not(.btn):focus-visible { color: var(--accent-text); }


/* ── 10. legal page (privacy) ───────────────────────────────────────── */

/* templates/privacy.html reuses .op-post / .op-post-head exactly like the
   404 page does, but its content is prose, not a quick-links list — so it
   needs .op-body's centred measure without .op-body's two-column rail
   grid, which assumes a rail is present and would otherwise squeeze the
   article into the 16rem rail track. This wrapper gives bare .prose the
   same left edge as the h1 above it and the same top rhythm as a post's
   .op-body, with nothing else new: .prose still supplies its own 68ch
   measure and every element style inside it. */
.op-legal {
  max-width: var(--op-column, 1160px);
  margin: clamp(48px, 6vw, 88px) auto 0;
}


/* ── 11. responsive ─────────────────────────────────────────────────── */

/* 1080: the rail is the first thing to give — 16rem of label/value copy
   starts wrapping badly before the prose measure is under pressure. */
@media (max-width: 1080px) {
  .op-body { grid-template-columns: minmax(0, 13rem) minmax(0, 1fr); }
}

/* 960: the two-column body folds. The rail stops being sticky — a sticky
   element in a single column would ride over the text it belongs to — and
   becomes a horizontal masthead row above the article. */
@media (max-width: 960px) {
  .op-body { grid-template-columns: minmax(0, 1fr); gap: 0; }
  .op-rail {
    position: static;
    display: flex;
    flex-wrap: wrap;
    gap: 20px var(--gap-grid);
    padding-bottom: 26px;
    margin-bottom: clamp(32px, 4vw, 44px);
    border-bottom: var(--rule-hairline) solid var(--border-subtle);
  }
  .op-rail-block + .op-rail-block { margin-top: 0; }
  .prose { max-width: none; }
}

/* 900: both two-column layouts on the hub fold. The hero plate keeps its
   right bleed from .bleed-r and gains a matching left one — --gutter is a
   custom property set on .container, so it inherits down to the plate and
   the two sides stay symmetrical at any gutter.

   It is also where <nav> turns into the fixed card bar (site.css §14,
   tokens.css drops --nav-h to 64 here) and site.css cuts section padding
   to --space-section-tight. Those two land on each other: the bar occupies
   77px from the viewport top and 56px of padding starts the first line
   underneath it. Both sections therefore take their top rhythm from BELOW
   the bar instead of from the top of the page — same padding the reader
   sees, measured from somewhere it can be seen. --nav-h + --nav-gap is the
   site's standing expression for the bar's occupied height (index.html's
   own hero clears it the same way); here it rounds 77 up to 78, which is
   clearance, never a shortfall. Above 900 --space-section already clears
   the taller resting nav on its own, so this stays scoped inside. */
@media (max-width: 900px) {
  .op-hero, .op-post { padding-top: calc(var(--nav-h) + var(--nav-gap) + var(--space-section-tight)); }
  .op-hero-grid { grid-template-columns: minmax(0, 1fr); gap: clamp(32px, 5vw, 48px) 0; }
  .op-hero-copy, .op-hero-plate { grid-column: 1; }
  .op-hero-copy h1 { max-width: none; }
  .op-hero-plate {
    margin-left: calc(-1 * var(--gutter, 48px));
    height: clamp(220px, 44vw, 380px);
  }
  .op-list { grid-template-columns: minmax(0, 1fr); }
  .op-item:nth-last-child(2):nth-child(even) { border-bottom: 0; }
  .op-post-head h1 { max-width: none; }
}

/* 640: phone. The thumbnail shrinks rather than disappearing — it is how a
   reader tells two entries apart at a glance — and the arrow drops, since
   the whole row is the tap target and nothing hovers on a phone. */
@media (max-width: 640px) {
  .op-row {
    --op-thumb: 76px;
    grid-template-columns: var(--op-thumb) minmax(0, 1fr);
    gap: 0 16px;
    padding: 22px 0 24px;
  }
  .op-arrow { display: none; }
  .op-desc { max-width: none; }
  .op-cta-inner { align-items: flex-start; gap: 32px; }
  .op-hero-photo img { height: clamp(200px, 56vw, 280px); }
}

/* 560: at the narrowest the thumbnail has stopped earning its column. */
@media (max-width: 560px) {
  .op-row { grid-template-columns: minmax(0, 1fr); }
  .op-thumb { display: none; }
  .op-rail { gap: 16px 28px; }
}


/* ── 12. reduced motion ─────────────────────────────────────────────── */

/* Every transition in this file is an affordance, not information: the
   arrow's travel, the thumbnail's colour, the link and title colour
   changes. All of them still reach their end state instantly. */
@media (prefers-reduced-motion: reduce) {
  .op-thumb img, .op-arrow, .op-title, .op-back, .prose a, .op-404-nav a { transition: none; }
}


/* ═══ CLASS INVENTORY — what the templates must produce ══════════════
   Every class this file defines, with the markup it expects. Anything not
   listed here comes from assets/site.css (.container, .bleed-l, .bleed-r,
   .eyebrow, .btn, .btn--ondark, nav, footer) and is used unchanged.

   SHARED
     .op-rule        <hr class="op-rule">  — the rule under an h1.
     .op-lede        <p class="op-lede">   — the standfirst. Also used
                                             inside .op-cta-copy.
     .op-crumbs      <nav class="op-crumbs" aria-label="Breadcrumb"><ol>…
                     one <li> per crumb: an <a> for every ancestor, a
                     <span aria-current="page"> (no href) for the current
                     page, and an aria-hidden <span class="op-crumbs-sep">
                     "/" between crumbs, none after the last. Rendered
                     whole by tools/build.mjs's breadcrumbTrail() as the
                     {{breadcrumb}} token — never hand-written in either
                     template — from the SAME array that builds the
                     matching BreadcrumbList in the page's JSON-LD. Sits
                     as its own element above .op-post-head (post) or
                     .op-hero-grid (hub), never inside either.

   HUB PAGE  (/opinions/)
     .op-hero          on the <section>. Clips X for the plate's bleed.
       .container
         .op-hero-grid       two columns: copy | plate.
           .op-hero-copy     <div>: h1, then .op-rule, then .op-lede.
           .op-hero-plate    <figure class="op-hero-plate bleed-r">
                             containing <picture><img> (or a bare <img>).
                             .bleed-r from site.css is REQUIRED — this file
                             only supplies the matching left bleed below 900.
     .op-list-head     optional <div> above the list: an h2, and anything
                       else (a count, a feed link) pushed to the far end.
     .op-list          <ul>. Two columns; one below 900.
       .op-item        <li>. Carries the hairline rule.
         .op-row       <a href>. Grid: thumb | text | arrow.
           .op-thumb     <span> (or <picture>) wrapping one <img>. 4:3,
                         cover, grey at rest. Hidden below 560.
           .op-rowtext   <span> wrapping the three lines below.
             .op-meta    <span>: "14 August 2026 · 6 min read". Plain text.
             .op-title   <span>: the post title, display serif.
             .op-desc    <span>: one line of description.
           .op-arrow     inline <svg viewBox="0 0 30 10"> with a single
                         <path> — stroke comes from CSS. Hidden below 640.
                         Must be aria-hidden; the <a> carries the label.

   POST PAGE  (/opinions/<slug>/)
     .op-post          on the <section>. Declares --op-column (1160px), the
                       shared measure, and clips X for the hero's bleed.
       .container
         .op-post-head   <header>: .eyebrow kicker, h1, .op-rule, .op-lede.
         .op-hero-photo  <figure class="op-hero-photo bleed-l bleed-r">
                         with <img> and an optional <figcaption>. BOTH
                         bleed classes are required for the full bleed.
         .op-body        grid: rail | article. One column below 960.
           .op-rail      <aside>. Sticky above 960, a masthead row below.
             .op-rail-block   <div>, one per fact. Repeat freely.
               .op-rail-label  <span>: "Published" / "Read time" /
                               "Author" / "Filed under". Sentence case.
               .op-rail-value  <span> (or <time>): the value.
               .op-tags        <ul> of .op-tag <li>, inside a rail block.
           .prose        <article>. See below.
         .op-back        optional <a> after the article: back to the hub.

     .op-cta           <section> AFTER .op-post, before the footer. Dark.
       .container
         .op-cta-inner    flex: copy | button.
           .op-cta-copy   <div>: an h2 and a .op-lede.
           <a class="btn btn--ondark">  — from site.css, unmodified.

   404 PAGE  (templates/404.html, deploy-root only — see tools/build.mjs)
     .op-post          reused bare, no hero photo: gives the header the
                       same --op-column measure and 900px top-padding fix
                       as a real post, with nothing to bleed.
       .container
         .op-post-head   .eyebrow "404", h1, .op-rule, .op-lede — identical
                         contract to the post page above.
         .op-404-nav     <nav>: flex row of a plain <ul><li><a>> list (the
                         site's real sections) plus one <a class="btn">
                         (Book a Call). The only classes this file adds
                         that exist nowhere else on the site.

   PRIVACY PAGE  (templates/privacy.html → /privacy/, see tools/build.mjs)
     .op-post          reused bare, same as the 404 page: no hero photo.
       .container
         .op-post-head   .eyebrow "Legal", h1, .op-rule, .op-lede —
                         identical contract to the post page above.
         .op-legal       <div>: centres bare .prose on --op-column without
                         .op-body's rail grid. See section 10 above.
           .prose        <article>. Hand-authored HTML, not Markdown, but
                         the same bare-tag contract as a post's article.
         .op-back        reused as-is, pointed at / instead of /opinions/.

   .prose — the article body. Styles bare tags, no per-element classes, so
   Markdown output needs no post-processing:
     h2 h3 p a ul ol li blockquote (+ cite) code pre img figure figcaption
     hr strong em, and nested lists.
   h1 is NOT styled inside .prose — the post's only h1 is in .op-post-head,
   and a second one would break the document outline.
   ════════════════════════════════════════════════════════════════════ */
