/* Public Blog Styles - Clean, readable, mobile-first */
:root {
  --bg: #F9F6F3;
  --bg-card: #ffffff;
  --text: #5f4e4a;
  --text-secondary: #8a7a75;
  --main: #2c180e;
  /* Terracotta, on the same warm axis as --main, so a hover reads as the ink
     shifting rather than as a jump into another colour family. The old
     #1a73e8 also sat at 4.18:1 on --bg, just under the AA floor; this is
     5.57:1. */
  --accent: #A4471F;
  --accent-hover: #86391A;
  --border: #e8e2dd;
  --code-bg: #f0ebe7;
  --note-bg: #fff8e1;
  --note-border: #ffc107;
  --max-width: 900px;
  --wide-width: 1400px;
  /* Both rails share one width so the page reads symmetrically. 300px is set
     by the ad side: it is the best-filled display width on AdSense
     (300x250 / 300x600), and narrower units fill poorly. */
  --rail-width: 300px;
  --ad-rail-width: var(--rail-width);
  /* ~75 characters per line at the article's body size — the comfortable
     reading measure. 720px was pushing past 80. */
  --content-width: 700px;
  --radius: 8px;
  --body-line: 1.7;
  /* The sticky header has no set height: it is 1rem of padding either side of
     the 1.4rem wordmark's line box (see .site-header / .site-title, which use
     these same values). A sticky rail below it must offset by *exactly* this —
     any more and `position: sticky` shoves the rail down out of its row while
     the page is still at the top, since its resting position is already above
     the threshold. --rail-sticky-gap buys the gap instead, by lowering the
     whole row rather than the rail alone. */
  --header-h: calc(2rem + 1.4rem * var(--body-line));
  --rail-sticky-gap: 0.75rem;
  /* Font fallback resolves per character, so Latin still renders in a true
     monospace and only CJK glyphs fall through to the Chinese faces. Without
     these the browser picks an arbitrary system font for Chinese inside code,
     which renders at the wrong weight and width. */
  --mono: 'SF Mono', 'Fira Code', ui-monospace, Menlo, Consolas,
          'Noto Sans Mono CJK SC', 'Sarasa Mono SC', 'Microsoft YaHei Mono',
          'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', monospace;
  --shadow: 0 1px 3px rgba(0,0,0,0.06);
}

* { margin: 0; padding: 0; box-sizing: border-box; }

/* Kill the grey flash mobile WebKit paints over a tapped link or button. The
   property inherits, so setting it once here covers every control. */
html {
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
  -webkit-tap-highlight-color: transparent;
}

body {
  font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: var(--body-line);
  font-size: 16px;
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}
body.lang-en { font-family: 'PT Serif', Georgia, serif; }

.container {
  width: 100%;
  max-width: var(--max-width);
  margin: 0 auto;
  padding: 0 1.5rem;
}

/* Header */
.site-header {
  padding: 1rem 2rem;
  background: rgba(250, 247, 245, 0.8);
  backdrop-filter: blur(12px) saturate(180%);
  -webkit-backdrop-filter: blur(12px) saturate(180%);
  position: sticky;
  top: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 2rem;
}
.site-title {
  font-size: 1.4rem;
  font-weight: 700;
  color: var(--main);
  text-decoration: none;
  white-space: nowrap;
  /* The name never yields space to the nav; the nav shrinks first. */
  flex: 0 0 auto;
}
/* Swap the wordmark for the "CM" monogram below the mobile breakpoint.
   To run the monogram on desktop too, flip these two declarations. */
.site-title-short { display: none; }
.site-title-full { display: inline; }
.site-header nav {
  display: flex;
  align-items: center;
  gap: 1.5rem;
  min-width: 0;
}
/* Nav labels are short; letting them wrap mid-word is what makes a tight
   header look broken rather than merely full. */
.site-header nav > a,
.nav-dropdown > a { white-space: nowrap; }
.site-header nav a {
  color: var(--main);
  text-decoration: none;
  font-size: 0.95rem;
  font-weight: 500;
  position: relative;
}
.site-header nav a::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: -3px;
  height: 2px;
  width: 0;
  background: var(--main);
  transition: width 0.25s ease;
}
.site-header nav a:hover::after { width: 100%; }

/* Categories dropdown: the label links to the index, the caret beside it is a
   button that only opens the menu. Two controls, one row. */
.nav-dropdown {
  position: relative;
  display: inline-flex;
  align-items: center;
}
.nav-caret {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* The tap target grows outward, away from the label: padding on the right
     only, so widening it for touch never opens a gap after "Categories". */
  margin-left: 0.25rem;
  padding: 0 0.3rem 0 0.05rem;
  /* Tall enough to be tappable without pushing the header row open. */
  min-height: 1.6rem;
  border: 0;
  background: transparent;
  color: var(--main);
  font-size: 1.05em;
  line-height: 1;
  cursor: pointer;
}
.nav-caret:focus-visible {
  outline: 2px solid var(--main);
  outline-offset: 1px;
  border-radius: 4px;
}
/* Right-aligned to the trigger, not centred on it: the nav sits against the
   right edge of the header, and on pages without the language toggle (article
   pages) a centred menu overflows the viewport and gets clipped. */
.nav-dropdown-menu {
  position: absolute;
  top: 100%;
  right: 0;
  left: auto;
  transform: translateY(-4px);
  margin-top: 0.75rem;
  min-width: 12rem;
  max-width: min(18rem, calc(100vw - 2rem));
  padding: 0.4rem 0;
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;
  z-index: 110;
}
/* Bridge the gap between trigger and menu so the hover isn't lost mid-travel. */
.nav-dropdown-menu::before {
  content: '';
  position: absolute;
  top: -0.75rem;
  left: 0;
  right: 0;
  height: 0.75rem;
}
/* Focus opens it everywhere — that is the caret button's job, and the only
   route a keyboard or a touchscreen has. */
.nav-dropdown:focus-within .nav-dropdown-menu {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
}
/* Hover opens it only on devices that really hover. On a touchscreen the
   emulated hover would swallow the first tap on "Categories" and pop the menu
   instead of following the link. */
@media (hover: hover) {
  .nav-dropdown:hover .nav-dropdown-menu {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
  }
}
.site-header nav .nav-dropdown-menu a {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.5rem 1rem;
  font-weight: 400;
  color: var(--text);
}
.site-header nav .nav-dropdown-menu a::after { display: none; }
.site-header nav .nav-dropdown-menu a:hover {
  color: var(--main);
  background: var(--code-bg);
}
.nav-count { font-size: 0.8em; color: var(--text-secondary); }
.site-header nav .nav-dropdown-menu a.nav-dropdown-all {
  margin-top: 0.4rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--border);
  color: var(--text-secondary);
  font-size: 0.85rem;
}
/* Language switcher: a globe that discloses the options.
   Collapsing two labels into one glyph is what buys the header its room back
   on narrow screens. */
.lang-trigger {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* A comfortable touch target around an 18px glyph. */
  width: 2rem;
  height: 2rem;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--main);
  cursor: pointer;
  transition: background-color 0.2s ease, color 0.2s ease;
}
.lang-trigger svg { display: block; }
.lang-dropdown:hover .lang-trigger,
.lang-dropdown:focus-within .lang-trigger { background: var(--code-bg); }
.lang-trigger:focus-visible { outline: 2px solid var(--main); outline-offset: 2px; }
/* Two short labels don't need the categories menu's width. The category rows
   push their post count to the right edge; these have nothing to push, so the
   menu is sized by its content instead of by a column rule. */
.lang-menu { min-width: 0; width: max-content; }
/* Rows run left to right — tick, then label — instead of pushing a count to
   the far edge the way the category rows do. */
.site-header nav .lang-menu a {
  justify-content: flex-start;
  gap: 0;
  padding: 0.5rem 1.25rem;
}
.site-header nav .lang-menu a.active {
  color: var(--main);
  font-weight: 600;
}
/* Tick marks the language you are already reading. Reserved on every row so
   the labels stay on one axis and the menu keeps one width. */
.site-header nav .lang-menu a::before {
  content: '✓';
  flex: 0 0 auto;
  margin-right: 0.55rem;
  font-size: 0.85em;
  visibility: hidden;
}
.site-header nav .lang-menu a.active::before { visibility: visible; }

/* Empty State */
.empty-state {
  text-align: center;
  color: var(--text-secondary);
  padding: 3rem 0;
}

/* Main */
main { padding: 2rem 0; flex: 1; width: 100%; }

/* Index pages (homepage, category, category list) use the wider measure so the
   newspaper grid gets real columns instead of two cramped ones. */
.container-wide { max-width: var(--wide-width); }

/* List pages always fill the content column instead of shrinking to fit when
   there are only a handful of entries. */
.page-list {
  width: 100%;
  margin: 0 auto;
}

/* ------------------------------------------------------------------ */
/* Newspaper grid                                                      */
/* ------------------------------------------------------------------ */

/* Row-major grid: post 3 lands in row 1 column 3, and reading runs left to
   right the way a reader expects.
   Not `column-count`: CSS multi-column is column-major and balances by height,
   so it filled column 1 top-to-bottom before starting column 2 — and on a short
   list (4 posts, 3 columns) balancing could fill two columns and leave the third
   empty. Masonry packing comes from `.is-masonry` below instead, which keeps
   this reading order. Without JS the grid still renders as aligned rows, with
   `align-items: start` giving each cell its content height. */
.paper-grid {
  --grid-cols: 2;
  --grid-gap: 2.5rem;
  /* One track, used by both the columns and the rule positions below. */
  --grid-track: calc((100% - (var(--grid-cols) - 1) * var(--grid-gap)) / var(--grid-cols));

  display: grid;
  grid-template-columns: repeat(var(--grid-cols), minmax(0, 1fr));
  column-gap: var(--grid-gap);
  /* Cells take their content height instead of stretching to the tallest in
     the row, so a short text-only post ends where its text ends and its rule
     sits directly under it. */
  align-items: start;
  border-top: 1px solid var(--border);

  /* Column rules are painted on the container, not on the cells. A per-cell
     border would now stop at each cell's own height and break the line into
     ragged segments; this keeps the full-height hairline `column-rule` drew,
     centred in the gutter. */
  background-repeat: no-repeat;
  background-size: 1px 100%;
  background-image: linear-gradient(var(--border), var(--border));
  background-position: calc(var(--grid-track) + var(--grid-gap) / 2) 0;
}
.paper-grid-3 {
  --grid-cols: 3;
  background-image:
    linear-gradient(var(--border), var(--border)),
    linear-gradient(var(--border), var(--border));
  background-position:
    calc(var(--grid-track) + var(--grid-gap) / 2) 0,
    calc(2 * var(--grid-track) + 1.5 * var(--grid-gap)) 0;
}
.paper-cell {
  /* Not flex: a flex container fragments badly and this keeps float clearing. */
  display: flow-root;
  min-width: 0;
  padding: 1.5rem 0;
  border-bottom: 1px solid var(--border);
}

/* Masonry packing, switched on by masonry.js once it can measure the cells.
   Rows are 1px tall with no row gap, and each cell is given a `span` equal to
   its own height plus --masonry-gap. A short cell therefore stops holding its
   row open: the cell below slides up until it sits directly under it, so
   columns run at independent heights and nothing lines up across the grid.
   The gap lives in the span maths rather than in `row-gap`, otherwise the
   spannable unit would be 1px + gap and packing would quantise to that. */
/* Between first paint and masonry.js packing the grid, the cells sit in
   aligned rows — a layout the reader would only ever see for a frame before it
   snapped into place. When scripting is available (html.js, set in layout.eta)
   the grid is held invisible until the packer has run. It still occupies its
   space, so nothing below it moves when it appears. The delayed animation is a
   safety release: if masonry.js never arrives, the grid shows anyway. */
.js .paper-grid:not(.is-masonry) {
  visibility: hidden;
  animation: paper-grid-release 0s 2s forwards;
}
@keyframes paper-grid-release { to { visibility: visible; } }

.paper-grid.is-masonry {
  --masonry-gap: 20px;
  grid-auto-rows: 1px;
  row-gap: 0;
  /* Replaces the first row's cell padding, which the packing removes. Matches
     the rails' gap below their rule, so the first card, the first product and
     the first ad unit all start on the same line. */
  padding-top: var(--col-head-gap, 1.5rem);
}
/* Cells hug the one above, and the rule reads as belonging to the cell it
   closes rather than floating midway between two. */
.paper-grid.is-masonry .paper-cell { padding: 0 0 1.25rem; }

/* Category index keeps the aligned ruled grid — its cells are uniform. */
.cat-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  border-top: 1px solid var(--border);
  border-left: 1px solid var(--border);
}
.cat-grid .paper-cell {
  padding: 1.5rem;
  border-right: 1px solid var(--border);
  min-width: 0;
}
.paper-cell-link {
  display: block;
  text-decoration: none;
  color: inherit;
}
/* Posts without a cover simply start at the title — no reserved space. */
.paper-cell-image {
  margin-bottom: 1rem;
  overflow: hidden;
  background: var(--code-bg);
}
.paper-cell-image img {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  transition: opacity 0.3s ease;
}
.paper-cell-link:hover .paper-cell-image img { opacity: 0.85; }
.paper-cell-title {
  font-size: 1.15rem;
  line-height: 1.35;
  font-weight: 700;
  color: var(--main);
  margin: 0;
}
.paper-cell-link:hover .paper-cell-title { color: var(--accent); }
.paper-cell-abstract {
  margin: 0.5rem 0 0;
  font-size: 0.92rem;
  /* Tighter than the body's 1.7: this is a three-line teaser, not a passage
     to read through, and the looser leading only made the cell taller. */
  line-height: 1.5;
  color: var(--text-secondary);
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
/* CJK needs more leading than Latin at the same size to stay readable, so the
   Chinese teaser only tightens partway. */
.lang-zh .paper-cell-abstract { line-height: 1.65; }
/* Date and author share one baseline row under the teaser. */
.paper-cell-byline {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.5rem;
}
/* The row now owns the spacing above the date. */
.paper-cell-byline .paper-cell-date { margin-top: 0; }
.paper-cell-author {
  font-size: 0.8rem;
  color: var(--text-secondary);
}
/* The separator belongs to the pairing, not to either value, so it lives on the
   author and disappears with them when a post has no assigned author. */
.paper-cell-author::before {
  content: '·';
  margin-right: 0.45rem;
}
.paper-cell-date {
  display: block;
  margin-top: 0.5rem;
  /* Mono sets the date apart from the prose and keeps every cell's digits on
     the same width. It renders wider than the serif, so it steps down a size. */
  font-family: var(--mono);
  font-size: 0.8rem;
  color: var(--text-secondary);
}
/* Kicker sits below the card, uppercase and quiet — the NYT press treatment,
   with a small accent square marking it as the one category link in the cell
   rather than another line of caption text. */
.paper-cell-kicker {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  margin-top: 0.75rem;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  /* The fade lives in the colour, not in `opacity`: opacity would wash out the
     accent square along with the label. */
  color: var(--text-secondary);
  text-decoration: none;
  transition: color 0.2s ease;
}
.paper-cell-kicker::before {
  content: '';
  flex: 0 0 auto;
  width: 6px;
  height: 6px;
  /* Takes the label's own colour, so the square stays part of the kicker and
     picks up the hover shift with it instead of drawing attention on its own. */
  background: currentColor;
  transition: transform 0.2s ease;
}
.paper-cell-kicker:hover { color: var(--main); }
.paper-cell-kicker:hover::before { transform: scale(1.4); }
.lang-zh .paper-cell-kicker { letter-spacing: 0.15em; }

/* ------------------------------------------------------------------ */
/* Homepage: product rail + grid                                       */
/* ------------------------------------------------------------------ */

/* Every column opens on one shared hairline: the product rail's title rule, the
   ad rail's label rule and the article grid's top border all land on it. The
   three used to start with boxes of three different heights — a 0.8rem title
   over a 2px rule, a 0.68rem label over a 1px rule, and the grid's border at
   zero — so the ad label floated at a height nothing on its left matched.
   Height is derived from the taller label (title size x its line-height, plus
   the padding and the thicker rule) and `border-box` below puts the rule
   *inside* that height, so 1px and 2px rules still meet on the same line. */
.home-layout,
.list-layout {
  /* Opens the row below the header, and gives the sticky rail room to keep that
     same gap once the page scrolls — see --rail-sticky-gap. */
  padding-top: var(--rail-sticky-gap);
  --col-head: calc(0.8rem * 1.25 + 0.55rem + 2px);
  /* Gap between that hairline and the first card / product / ad unit. */
  --col-head-gap: 1rem;
}

/* Columns are opted into by class, so a missing product rail or unconfigured
   AdSense collapses the layout instead of leaving an empty gutter. */
.home-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 2rem;
  align-items: start;
}
.home-layout.has-rail { grid-template-columns: var(--rail-width) minmax(0, 1fr); }
.home-layout.has-ads { grid-template-columns: minmax(0, 1fr) var(--ad-rail-width); }
.home-layout.has-rail.has-ads {
  grid-template-columns: var(--rail-width) minmax(0, 1fr) var(--ad-rail-width);
}
/* The article column has no label of its own, so it is inset by the header
   height instead — that drops its top border onto the shared hairline. */
.home-main,
.list-main {
  min-width: 0;
  padding-top: var(--col-head);
}

/* Category page: post columns beside the ad rail (no product rail here). */
.list-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 2rem;
  align-items: start;
}
.list-layout.has-ads { grid-template-columns: minmax(0, 1fr) var(--ad-rail-width); }

/* Right rail: mirrors the product rail's top edge, then sticks while the
   article grid scrolls past it. */
.ad-rail {
  min-width: 0;
  position: sticky;
  /* Exactly the row's own resting offset, so the rail starts level with the
     columns beside it and only detaches once the page scrolls. */
  top: calc(var(--header-h) + var(--rail-sticky-gap));
}
/* Shared header row for both rails. `flex-end` seats the two different type
   sizes on the same line above the rule instead of centring each in its own
   line box, and `border-box` keeps the rule at exactly --col-head. */
.ad-rail-label,
.rail-title {
  display: flex;
  align-items: flex-end;
  box-sizing: border-box;
  height: var(--col-head);
  padding-bottom: 0.55rem;
  margin-bottom: var(--col-head-gap);
  text-transform: uppercase;
  line-height: 1.25;
}
.ad-rail-label {
  font-size: 0.68rem;
  letter-spacing: 0.12em;
  color: var(--text-secondary);
  opacity: 0.7;
  border-bottom: 1px solid var(--border);
}
/* Each slot reserves its own height, so the column keeps its shape whether the
   unit fills, goes unsold, or AdSense is not configured at all. */
.ad-rail-slot {
  position: relative;
  display: grid;
  place-items: center;
  overflow: hidden;
  background: var(--code-bg);
  border: 1px dashed var(--border);
  border-radius: var(--radius);
}
.ad-rail-slot + .ad-rail-slot { margin-top: 1.25rem; }
.ad-tall { min-height: 600px; }
.ad-box { min-height: 250px; }
.ad-rail-slot .adsbygoogle {
  display: block;
  width: 100%;
  min-height: inherit;
}

/* Self-sold banner. Width-driven, so an advertiser's artwork keeps its own
   proportions inside a slot whose height is only a minimum. */
.ad-banner {
  display: block;
  width: 100%;
  line-height: 0;
}
.ad-banner img {
  display: block;
  width: 100%;
  height: auto;
  transition: opacity 0.3s ease;
}
.ad-banner:hover img { opacity: 0.92; }

/* Pasted network code. The slot reserves the shape; whatever the snippet
   injects is stretched to the column so a responsive unit has a width to
   measure before it decides what to fill it with. */
.ad-code {
  width: 100%;
  min-height: inherit;
}
.ad-code > ins,
.ad-code > div,
.ad-code > iframe {
  display: block;
  max-width: 100%;
}

/* House "advertise with us" card, shown only while the slot is unsold. It is a
   <button> when the contact form is available and an <a> otherwise, so the
   button's own chrome and font have to be reset back to the card's. */
.ad-cta {
  border: 0;
  background: none;
  font: inherit;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  width: 100%;
  height: 100%;
  padding: 1rem;
  text-align: center;
  text-decoration: none;
  transition: background 0.2s ease;
}
.ad-cta:hover { background: rgba(44, 24, 14, 0.04); }
.ad-cta-title {
  font-size: 0.9rem;
  font-weight: 700;
  color: var(--main);
}
.ad-cta-hint {
  font-size: 0.78rem;
  color: var(--text-secondary);
}
.ad-cta:hover .ad-cta-hint { color: var(--accent); }

/* Shares the header metrics above; only the weight of the rule differs — this
   is a section title, the ad label is a disclosure. */
.rail-title {
  font-size: 0.8rem;
  letter-spacing: 0.12em;
  color: var(--main);
  border-bottom: 2px solid var(--main);
}
.lang-zh .rail-title { letter-spacing: 0.2em; }
.rail-list { list-style: none; }
.rail-item + .rail-item {
  margin-top: 1.25rem;
  padding-top: 1.25rem;
  border-top: 1px solid var(--border);
}
/* The rule separates a captioned product from the next one. A title-less item
   is a standalone image banner: it needs no rule under it, only the gap. */
.rail-item:not(:has(.rail-item-title)) + .rail-item {
  border-top: none;
  padding-top: 0;
}
.rail-item a {
  display: block;
  text-decoration: none;
  color: var(--main);
}
/* Width only: product art is never cropped or forced to an aspect ratio. */
.rail-item img {
  display: block;
  width: 100%;
  height: auto;
  transition: opacity 0.3s ease;
}
.rail-item a:hover img { opacity: 0.85; }
.rail-item-title {
  display: block;
  margin-top: 0.5rem;
  font-size: 0.9rem;
  line-height: 1.4;
}
.rail-item a:hover .rail-item-title { color: var(--accent); }

/* Page Title (archive etc.) */
.page-title {
  font-size: 1.8rem;
  margin: 1.5rem 0 2rem;
  color: var(--main);
}

/* Category label (article page) */
.post-category {
  display: inline-block;
  font-size: 0.75rem;
  font-weight: 700;
  color: var(--text-secondary);
  text-decoration: none;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  margin-bottom: 0.9rem;
}
.post-category:hover { color: var(--main); }
.lang-zh .post-category { letter-spacing: 0.15em; }

/* Post Detail */
.post { max-width: var(--content-width); margin: 0 auto; }

/* Header: kicker → title → standfirst → meta, each with its own weight and
   spacing so the hierarchy reads at a glance instead of as one dense block. */
.post-header {
  margin-bottom: 2.5rem;
  padding-bottom: 1.5rem;
  border-bottom: 1px solid var(--border);
}
.post-title {
  /* Scales with the viewport so long headlines don't dominate small screens. */
  font-size: clamp(1.75rem, 1.2rem + 2.2vw, 2.6rem);
  line-height: 1.2;
  letter-spacing: -0.015em;
  font-weight: 700;
  margin-bottom: 0.75rem;
  color: var(--main);
  /* Avoids a single trailing word on its own line. */
  text-wrap: balance;
}
.lang-zh .post-title { letter-spacing: 0; line-height: 1.3; }

.post-standfirst {
  font-size: 1.1rem;
  line-height: 1.6;
  color: var(--text-secondary);
  margin-bottom: 1.25rem;
  max-width: 42em;
}
.post-meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.75rem;
  font-size: 0.85rem;
  color: var(--text-secondary);
}
.post-date {
  color: var(--text-secondary);
  font-size: 0.85rem;
  letter-spacing: 0.02em;
}

/* ------------------------------------------------------------------ */
/* Author byline + avatars                                             */
/* ------------------------------------------------------------------ */

/* The avatar and the name are one unit: they stay together when the meta row
   wraps on a phone, and the gap between them never grows to the row's gap. */
.post-byline {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
}
.post-author {
  color: var(--main);
  font-weight: 700;
}

/* Stored avatars are a fixed 200x200 WebP, so these only ever scale down.
   `overflow: hidden` is what trims the placeholder silhouette to the circle. */
.avatar {
  display: block;
  flex: 0 0 auto;
  border-radius: 50%;
  overflow: hidden;
  object-fit: cover;
  background: var(--code-bg);
}
.avatar-md { width: 36px; height: 36px; }
/* Placeholder for a user with no avatar: the same circle, filled with a muted
   silhouette. Quiet enough to read as "no photo yet" rather than as content. */
.avatar-default {
  color: var(--text-secondary);
  opacity: 0.65;
}
.avatar-default svg {
  display: block;
  width: 100%;
  height: 100%;
}
.post-edit-link {
  display: inline-block;
  margin-left: 0.75rem;
  font-size: 0.8rem;
  color: var(--text-secondary);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 0.1rem 0.7rem;
  text-decoration: none;
}
.post-edit-link:hover { color: var(--main); border-color: var(--text-secondary); }
.post-cover { margin-bottom: 2.5rem; }
.post-cover img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius);
}

/* Language-specific fonts */
.post-title { font-family: 'PT Serif', Georgia, serif; }
.post-content { font-family: 'PT Serif', Georgia, serif; }
.lang-zh .post-title { font-family: 'Noto Serif SC', Georgia, serif; }
.lang-zh .post-content { font-family: 'Noto Sans SC', sans-serif; }
/* zh version: important elements (nav, footer, titles) use Noto Serif SC */
.lang-zh .site-header,
.lang-zh .site-footer,
.lang-zh .page-title,
.lang-zh .archive-month,
.lang-zh .paper-cell-title,
.lang-zh .rail-title,
.lang-zh .rail-item-title,
.lang-zh .archive-list,
.lang-zh .pagination,
.lang-zh .error-page,
.lang-zh .empty-state,
.lang-zh .post-content h2,
.lang-zh .post-content h3 {
  font-family: 'Noto Serif SC', Georgia, serif;
}

/* Post Content */
.post-content {
  font-size: 1.075rem;
  line-height: 1.75;
}
/* Chinese sets its own measure: Noto Sans SC runs larger on the body than the
   Latin serif at the same size, so it steps down slightly and takes a touch
   more leading than Latin. English keeps the values above. */
.lang-zh .post-content {
  font-size: 17px;
  line-height: 1.8;
}

/* Headings take a large top margin and a small bottom one, so each binds to
   the text it introduces rather than floating between two blocks. */
.post-content h2,
.post-content h3,
.post-content h4 {
  color: var(--main);
  line-height: 1.3;
  font-weight: 700;
  text-wrap: balance;
}
.post-content h2 { font-size: 1.45rem; margin: 2.75rem 0 0.85rem; }
.post-content h3 { font-size: 1.2rem; margin: 2.25rem 0 0.7rem; }
.post-content h4 {
  font-size: 1rem;
  margin: 1.75rem 0 0.6rem;
  letter-spacing: 0.02em;
}
/* No stacked gap when a subheading follows a heading directly. */
.post-content h2 + h3,
.post-content h3 + h4 { margin-top: 1.25rem; }
.post-content > :first-child { margin-top: 0; }

.post-content p { margin-bottom: 1.35rem; }
.post-content > p:last-child { margin-bottom: 0; }
/* The global reset zeroes list padding, which leaves bullets and numbers
   hanging outside the text column. Indent so markers sit inside the measure. */
.post-content ul,
.post-content ol {
  margin: 0 0 1.2rem;
  padding-left: 1.6rem;
}
.post-content ul { list-style: disc outside; }
.post-content ol { list-style: decimal outside; }
.post-content li { margin-bottom: 0.4rem; }
.post-content li::marker { color: var(--text-secondary); }
/* Nested lists indent further but don't add another bottom gap. */
.post-content li > ul,
.post-content li > ol {
  margin: 0.4rem 0 0;
}
.post-content ul ul { list-style: circle outside; }
.post-content blockquote ul,
.post-content blockquote ol { padding-left: 1.4rem; }
/* Body links are underlined by default: with only a colour change they were
   indistinguishable from surrounding text until hover. */
.post-content a {
  color: var(--main);
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.18em;
  text-decoration-color: color-mix(in srgb, var(--main) 35%, transparent);
  transition: text-decoration-color 0.15s ease, color 0.15s ease;
}
.post-content a:hover {
  color: var(--accent);
  text-decoration-color: currentColor;
}
.post-content img {
  display: block;
  max-width: 100%;
  height: auto;
  border-radius: var(--radius);
  margin: 1.75rem auto;
}
.post-content figure { margin: 1.75rem 0; }
.post-content figure img { margin: 0 auto 0.6rem; }
.post-content figcaption {
  font-size: 0.85rem;
  line-height: 1.55;
  color: var(--text-secondary);
  text-align: center;
}
.post-content hr {
  border: 0;
  height: 1px;
  background: var(--border);
  margin: 2.5rem 0;
}
.post-content pre {
  background: var(--code-bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1rem 1.15rem;
  overflow-x: auto;
  margin: 1.6rem 0;
  font-size: 0.875rem;
  line-height: 1.65;
  /* Code blocks scroll rather than stretching the article's measure. */
  max-width: 100%;
  tab-size: 2;
}
/* Without this the 0.9em on `code` compounds with the 0.875rem on `pre`,
   shrinking fenced blocks to ~0.79rem. */
.post-content pre code {
  font-size: inherit;
  background: none;
  padding: 0;
  border: 0;
}
.post-content pre,
.post-content code,
.post-content kbd,
.post-content samp {
  font-family: var(--mono);
}
.post-content code { font-size: 0.9em; }
.post-content :not(pre) > code {
  background: var(--code-bg);
  border: 1px solid var(--border);
  padding: 0.1em 0.38em;
  border-radius: 4px;
  /* Long identifiers wrap instead of pushing the column wider. */
  overflow-wrap: break-word;
}

/* The rule was --accent (blue), the one cool colour in a warm palette; it
   read as a UI element rather than as part of the prose. */
.post-content blockquote {
  border-left: 2px solid var(--main);
  padding: 0.25rem 0 0.25rem 1.25rem;
  margin: 1.75rem 0;
  color: var(--text-secondary);
  font-style: italic;
}
.lang-zh .post-content blockquote { font-style: normal; }
.post-content blockquote p:last-child { margin-bottom: 0; }

/* Note Container */
.note {
  background: var(--note-bg);
  border-left: 4px solid var(--note-border);
  padding: 1rem 1.25rem;
  border-radius: 0 var(--radius) var(--radius) 0;
  margin: 1.2rem 0;
}
.note-title {
  font-weight: 600;
  margin-bottom: 0.5rem;
}

/* Table */
.table-wrap {
  overflow-x: auto;
  margin: 1.2rem 0;
}
.post-content table {
  width: 100%;
  border-collapse: collapse;
}
.post-content th, .post-content td {
  padding: 0.6rem 1rem;
  border: 1px solid var(--border);
  text-align: left;
  font-size: 0.95rem;
  line-height: 1.6;
}
.post-content th {
  background: var(--code-bg);
  font-weight: 700;
  color: var(--main);
  font-size: 0.85rem;
  letter-spacing: 0.03em;
  text-transform: uppercase;
}
.lang-zh .post-content th { text-transform: none; letter-spacing: 0.06em; }

/* Archive */
.archive-group { margin-bottom: 2rem; }
.archive-month { font-size: 1.3rem; margin-bottom: 0.75rem; color: var(--main); }
.archive-list { list-style: none; }
.archive-list li {
  padding: 0.4rem 0;
  display: flex;
  gap: 1rem;
}
.archive-list time { color: var(--text-secondary); font-size: 0.9rem; min-width: 100px; }
.archive-list a { color: var(--main); text-decoration: none; }
.archive-list a:hover { color: var(--accent); }

/* Pagination (archive and other simple lists) */
.pagination {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 1rem;
  margin-top: 2rem;
  padding: 1rem 0;
}
.pagination a {
  color: var(--main);
  text-decoration: none;
  padding: 0.4rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.pagination a:hover { background: var(--code-bg); }

/* Numbered pager (homepage, category) */
.pager {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  margin-top: 2.5rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--border);
}
.pager-list {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  list-style: none;
  margin: 0 auto;
}
.pager-num,
.pager-arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  min-width: 2.25rem;
  height: 2.25rem;
  padding: 0 0.6rem;
  border-radius: var(--radius);
  color: var(--main);
  text-decoration: none;
  font-size: 0.9rem;
  font-variant-numeric: tabular-nums;
  transition: background 0.18s ease, color 0.18s ease;
}
.pager-num:hover,
.pager-arrow:hover { background: var(--code-bg); }
.pager-num.is-current {
  background: var(--main);
  color: var(--bg-card);
  font-weight: 700;
}
.pager-arrow.is-disabled {
  color: var(--text-secondary);
  opacity: 0.35;
  pointer-events: none;
}
.pager-gap {
  min-width: 1.5rem;
  text-align: center;
  color: var(--text-secondary);
}

/* Soft page transition. The region holds its height while loading so the
   footer never jumps between pages of different length. */
[data-list-region] {
  transition: opacity 0.18s ease;
}
[data-list-region].is-loading {
  opacity: 0.45;
  pointer-events: none;
}
[data-list-region].is-entering {
  opacity: 0;
  transform: translateY(6px);
}
[data-list-region]:not(.is-entering) {
  transform: none;
  transition: opacity 0.22s ease, transform 0.22s ease;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

@media (prefers-reduced-motion: reduce) {
  [data-list-region],
  [data-list-region]:not(.is-entering) { transition: none; transform: none; }
}

@media (max-width: 560px) {
  .pager { justify-content: center; }
  .pager-arrow-text { display: none; }
}

/* Ad Slot */
.ad-slot-post {
  max-width: var(--content-width);
  margin-top: 3rem;
}
.ad-slot {
  margin: 2rem auto;
  text-align: center;
  min-height: 100px;
  background: var(--code-bg);
  border-radius: var(--radius);
  /* A banner is width-driven and would otherwise sit on the tinted panel with
     its corners showing square against the rounded box. */
  overflow: hidden;
}
/* The article slot only renders when it is actually sold or coded, so it does
   not need the placeholder tint an empty box would want. */
.ad-slot:has(.ad-banner) { background: none; }

/* Footer */
.site-footer {
  padding: 1.5rem 0;
  text-align: center;
  color: var(--text-secondary);
  font-size: 0.85rem;
}

/* Error Page */
.error-page {
  text-align: center;
  padding: 5rem 1rem;
}
.error-page .error-code {
  font-size: 6rem;
  font-weight: 700;
  line-height: 1;
  color: var(--border);
  letter-spacing: 0.05em;
}
.error-page h1 {
  font-size: 1.6rem;
  color: var(--main);
  margin: 1rem 0 0.5rem;
}
.error-page p { margin: 0.5rem 0 2rem; color: var(--text-secondary); }
.error-page .error-home {
  display: inline-block;
  color: var(--main);
  text-decoration: none;
  padding: 0.5rem 1.25rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-card);
}
.error-page .error-home:hover { box-shadow: var(--shadow); }

/* Cookie Consent overrides */
.cc-window { font-size: 0.9rem; }

/* Responsive */

/* Below the wide breakpoint the rail can't hold its own column; it drops under
   the articles so the reading content stays first. */
/* Between tablet and full width there isn't room for three columns without
   squeezing the articles, so the ad rail drops to its own full-width row and
   the grid keeps the space. Threshold tracks the two 300px rails plus gutters:
   below this the middle column is narrower than the ad beside it. */
@media (max-width: 1379px) {
  .home-layout.has-rail.has-ads {
    grid-template-columns: var(--rail-width) minmax(0, 1fr);
  }
  /* Three columns beside an ad rail gets too narrow to read here. Setting the
     count re-derives the track, so the single rule lands on its own. */
  .paper-grid-3 {
    --grid-cols: 2;
    background-image: linear-gradient(var(--border), var(--border));
    background-position: calc(var(--grid-track) + var(--grid-gap) / 2) 0;
  }
  .list-layout.has-ads { grid-template-columns: minmax(0, 1fr); }
  /* Nothing sits beside the articles any more, so there is no shared line to
     inset for — the grid goes back to starting at the top of its column. */
  .list-layout.has-ads .list-main { padding-top: 0; }
  .list-layout.has-ads .ad-rail {
    position: static;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 1.25rem;
    align-items: start;
  }
  .list-layout.has-ads .ad-rail-label { grid-column: 1 / -1; margin-bottom: 0; }
  .list-layout.has-ads .ad-rail-slot + .ad-rail-slot { margin-top: 0; }
  .list-layout.has-ads .ad-tall { min-height: 250px; }
  /* With the ad rail dropped to its own row and no product rail left beside it,
     the articles are alone in their row and have nothing to align to. */
  .home-layout.has-ads:not(.has-rail) .home-main { padding-top: 0; }
  .home-layout.has-ads .ad-rail {
    grid-column: 1 / -1;
    position: static;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 1.25rem;
    align-items: start;
  }
  .home-layout.has-ads .ad-rail-label { grid-column: 1 / -1; margin-bottom: 0; }
  .home-layout.has-ads .ad-rail-slot + .ad-rail-slot { margin-top: 0; }
  .home-layout.has-ads .ad-tall { min-height: 250px; }
}

@media (max-width: 1023px) {
  .home-layout,
  .home-layout.has-rail,
  .home-layout.has-ads,
  .home-layout.has-rail.has-ads {
    grid-template-columns: minmax(0, 1fr);
    gap: 2.5rem;
  }
  /* Reading content first, then the ad, then the product rail.
     The ad rail's own grid treatment is inherited from the 1379px block. */
  .home-main { order: 1; padding-top: 0; }
  .ad-rail { order: 2; }
  .product-rail { order: 3; }
  .rail-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
    gap: 1.5rem;
  }
  .rail-item + .rail-item {
    margin-top: 0;
    padding-top: 0;
    border-top: none;
  }
}

@media (max-width: 640px) {
  /* Single column: source order is newest-first, so the reading order is
     correct without any reshuffling. */
  /* One column has no gutter, so no rule to paint. */
  .paper-grid,
  .paper-grid-3 {
    --grid-cols: 1;
    background-image: none;
  }
  .cat-grid {
    grid-template-columns: minmax(0, 1fr);
    border-left: none;
  }
  .cat-grid .paper-cell { padding: 1.25rem 0; border-right: none; }
  .paper-cell { padding: 1.25rem 0; }

  /* Header: the full wordmark plus three nav items does not fit a phone.
     The name becomes the "CM" monogram and every gap tightens, so the row
     stays on one line instead of wrapping or overflowing. */
  .site-header {
    padding: 0.75rem 1rem;
    gap: 0.75rem;
  }
  .site-title { font-size: 1.2rem; letter-spacing: 0.04em; }
  .site-title-full { display: none; }
  .site-title-short { display: inline; }
  .site-header nav { gap: 1.1rem; }
  .site-header nav a { font-size: 0.9rem; }
  /* Roomier tap target for the caret than the desktop pointer needs — still
     grown on the right only, so the gap to "Categories" does not open up. */
  .nav-caret { padding: 0 0.55rem 0 0.05rem; min-height: 2rem; }
  /* The categories list is long enough to want the width; cap it to the
     viewport so it can never push a horizontal scrollbar. */
  .nav-dropdown-menu { min-width: min(15rem, calc(100vw - 2rem)); }
  /* The language menu sizes to its own two labels and stays inset from the
     screen edge — it hangs off the globe, which already sits at the header's
     right padding. */
  .lang-menu { min-width: 0; }

  /* One step down on a phone: the narrow column makes 17px feel crowded.
     Latin is unchanged at both widths. */
  .lang-zh .post-content { font-size: 16px; }
}

@media (max-width: 480px) {
  .post-title { font-size: 1.5rem; }
  .container { padding: 0 1rem; }
}

/* Smallest phones (~360px): trade the last of the nav's breathing room rather
   than let the language toggle push off the edge. */
@media (max-width: 380px) {
  .site-header { padding: 0.7rem 0.85rem; }
  .site-header nav { gap: 0.8rem; }
  .site-header nav a { font-size: 0.85rem; }
  .lang-trigger { width: 1.75rem; height: 1.75rem; }
}

/* ------------------------------------------------------------------ */
/* Contact widget                                                      */
/* ------------------------------------------------------------------ */

/* Bottom-left launcher. Sits above the cookie banner's stacking context but
   below the header dropdowns, which must stay reachable while it is open. */
.contact-fab {
  position: fixed;
  left: 1.5rem;
  bottom: 1.5rem;
  z-index: 90;
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  /* Solid rather than outlined: an outlined pill on a warm background reads as
     page furniture and gets skipped. This is meant to be found. */
  padding: 0.75rem 1.15rem;
  border: 0;
  border-radius: 999px;
  background: var(--main);
  /* Tight blur, not a soft halo: at 14px the spread washed a grey film over the
     warm page behind it and read as dirt rather than lift. */
  box-shadow: 0 3px 3px rgba(44, 24, 14, 0.15);
  color: var(--bg-card);
  font: inherit;
  font-size: 0.9rem;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  transition: transform 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
}
.contact-fab:hover,
.contact-fab.is-active {
  background: var(--accent);
  transform: translateY(-2px);
  /* Follows the resting shadow's proportions — the lift comes from the offset
     tracking the 2px rise, not from more blur. */
  box-shadow: 0 5px 5px rgba(44, 24, 14, 0.18);
}
.contact-fab:focus-visible { outline: 2px solid var(--main); outline-offset: 3px; }
.contact-fab svg { display: block; flex: 0 0 auto; }

.contact-panel {
  position: fixed;
  left: 1.5rem;
  bottom: 4.75rem;
  z-index: 91;
  width: min(22rem, calc(100vw - 3rem));
  max-height: min(32rem, calc(100vh - 7rem));
  overflow-y: auto;
  padding: 1.25rem;
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.contact-panel.is-open { opacity: 1; transform: translateY(0); }

.contact-panel-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1rem;
}
.contact-heading {
  font-size: 1.05rem;
  font-weight: 700;
  color: var(--main);
}
.contact-close {
  border: 0;
  background: transparent;
  color: var(--text-secondary);
  font-size: 1.4rem;
  line-height: 1;
  padding: 0 0.25rem;
  cursor: pointer;
}
.contact-close:hover { color: var(--main); }

.contact-field { display: block; margin-bottom: 0.85rem; }
.contact-label {
  display: block;
  margin-bottom: 0.3rem;
  font-size: 0.8rem;
  color: var(--text-secondary);
}
.contact-label em { font-style: normal; opacity: 0.75; }
.contact-field input,
.contact-field textarea {
  display: block;
  width: 100%;
  padding: 0.55rem 0.7rem;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg);
  color: var(--text);
  font: inherit;
  font-size: 0.9rem;
}
.contact-field textarea { resize: vertical; min-height: 6rem; line-height: 1.5; }
.contact-field input:focus,
.contact-field textarea:focus {
  outline: none;
  border-color: var(--main);
}

/* Honeypot: removed from view without `display:none`, which some bots skip. */
.contact-hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

.contact-context {
  margin: 0 0 0.75rem;
  font-size: 0.75rem;
  line-height: 1.45;
  color: var(--text-secondary);
}
.contact-context span { color: var(--text); }

/* Holds no height until it has something to say, so the panel doesn't jump. */
.contact-status {
  margin: 0;
  font-size: 0.8rem;
  line-height: 1.45;
}
.contact-status:not(:empty) { margin-bottom: 0.75rem; }
.contact-status.is-error { color: var(--accent); }
.contact-status.is-ok { color: #2f6b42; }

.contact-submit {
  width: 100%;
  padding: 0.6rem 1rem;
  border: 0;
  border-radius: 6px;
  background: var(--main);
  color: var(--bg-card);
  font: inherit;
  font-size: 0.9rem;
  cursor: pointer;
  transition: opacity 0.2s ease;
}
.contact-submit:hover { opacity: 0.88; }
.contact-submit:disabled { opacity: 0.5; cursor: default; }

@media (max-width: 640px) {
  /* Icon only, to save the width. A fixed square keeps it a true circle
     instead of a pill that happens to hold one glyph, and 44px is the minimum
     comfortable touch target. */
  .contact-fab {
    left: 1rem;
    bottom: 1rem;
    width: 44px;
    height: 44px;
    padding: 0;
    justify-content: center;
  }
  .contact-fab-label { display: none; }
  .contact-fab svg { width: 20px; height: 20px; }
  .contact-panel {
    left: 1rem;
    right: 1rem;
    bottom: 4.25rem;
    width: auto;
    max-height: calc(100vh - 6rem);
  }
}
