/* ==========================================================================
   foojay.io — Hugo theme stylesheet
   Replaces the v0 structural scaffolding with the site's visual design.

   THE PALETTE IS THE LIVE SITE'S, READ OUT OF THE LIVE SITE'S OWN CSS
   (wp-content/themes/foojay/css/main.css). The scaffold's original guess was a
   warm amber accent over navy ink with "Java blue" as the secondary, described
   here as "reconstructed from the live foojay.io theme". It was reconstructed
   wrong: there is no amber token in that stylesheet at all. Its `:root` is
   --text-main #37474f, --blue #3562e5, --green #5eb761, --section-bg #edf9fe,
   and #4fc3f7 (the logo blue) appears 153 times. So every brand token below is
   now either that file's value or derived from one of its hues.

   Three things about that are deliberate rather than transcription:

   1. THE ACCENT IS #50c2f7, THE LOGO'S OWN VALUE, not the #4fc3f7 the live CSS
      uses -- one step apart and indistinguishable, but the logo PNG is the
      artwork everything else has to sit beside, so it wins.
   2. A BRIGHT BLUE CANNOT BE ACCENT AND ACCENT-TEXT AT ONCE. #50c2f7 measures
      2.02:1 on white, so it is a fill, a border and a tint here and never text
      -- --brand-accent-strong is the same hue walked down to 4.75:1. The live
      site does not make this split and pays for it: #4fc3f7 is its body-link
      colour, i.e. links at 2.02:1.
   3. THE LIVE SITE'S MUTED INK IS NOT COPIED EITHER. rgba(55,71,79,.5) is
      2.56:1 on white. --ink-muted is the same hue at 4.94:1.

   Realigning hues to the live design is the point; importing its contrast
   failures is not. Every pair below was measured, and the numbers in the
   comments are WCAG ratios against the surface named. If you change a value,
   re-measure it.

   Dark mode has no upstream to copy -- the live site has none -- so it is the
   same hues with the lightness inverted, and the same measurements.
   ========================================================================== */

:root {
  /* Brand -- the logo blue, its readable sibling, and its tint.
     accent      #50c2f7  fills, borders, ticks, the featured panel's edge
     accent-strong        accent AS TEXT: 4.75 on white, 4.59 on --page-bg
     accent-soft          the featured panel's ground: --ink on it is 8.57 */
  --brand-accent:        #50c2f7;
  --brand-accent-strong: #087ab0;
  --brand-accent-soft:   #e4f4fc;
  /* THE FOCUS RING IS ITS OWN TOKEN, and in the light scheme it is NOT
     --brand-accent. WCAG 1.4.11 asks a focus indicator for 3:1 against what it
     sits on, and #50c2f7 measures 2.02 on --surface, 1.95 on --page-bg and 1.88
     on --surface-sunken -- the same reason point 2 above rules the accent out as
     text, applied to the one piece of UI a keyboard user navigates by. The
     readable sibling is 4.40 on --surface and 4.25 on --surface-sunken.
     Dark keeps #50c2f7, which is already 8.57 there. */
  --focus-ring:          #087ab0;
  /* The label on an accent FILL. Was #1c1204 in five places and #14100a in
     three more -- a warm near-black chosen for amber, which reads as a brown
     cast on blue. One token now, cool, 8.07 on --brand-accent. */
  --on-accent:           #06232f;
  /* Two more "label on a fill" tokens, and they exist because those fills
     INVERT between the schemes while --brand-accent does not:
     --brand-accent-strong is a dark blue on light and a pale one on dark, and
     the ink ramp does the same. A literal #fff was right in exactly one scheme
     each time -- .btn--accent:hover measured 1.60:1 in dark, .sponsor-badge
     2.78. Both were wrong before this palette too; they are fixed here because
     re-measuring every pair is what a palette change is. */
  --on-accent-strong:    #ffffff;   /* 4.75 on --brand-accent-strong */
  --on-ink:              #ffffff;   /* 9.65 on --ink, 4.94 on --ink-muted */

  /* Secondary = the live theme's --blue, and here as there it is the
     INTERACTIVE colour: `a { color }` is this token. 5.21 on white. */
  --brand-secondary:     #3562e5;
  --brand-secondary-soft:#eff2fd;

  /* Category chips are GREEN, which is what the live site does with a tag
     (--home-tags-text #5eb761 on --home-tags-bg rgba(94,183,97,.1)). Its own
     family rather than a third use of --brand-secondary, because the chips and
     the links are different things and a blue accent has already claimed the
     rest of the blue. #5eb761 is 2.49:1 as text, so the text is the same hue
     walked down to 5.18 and #5eb761 stays as the tint it is upstream. */
  --tag-soft:       #eff8ef;
  --tag-text:       #377b39;

  /* Ink & surfaces -- --text-main verbatim, then its own hue for the two
     steps below it (the live site's rgba(.75)/(.5) are 4.74 and 2.56). */
  --ink:            #37474f;   /* 9.65 on white */
  --ink-soft:       #4d646f;   /* 6.24 */
  --ink-muted:      #5a7481;   /* 4.94 -- the floor, meta lines live here */
  --surface:        #ffffff;
  --surface-sunken: #edf9fe;   /* live --section-bg / --block-bg */
  /* Was --surface-navy, and the name is kept because 30-odd rules use it: the
     header and footer ground. The live site's is --nav-bg #37474f, a dark
     blue-grey rather than a navy, i.e. the ink colour itself. */
  --surface-navy:   #37474f;
  /* Page background: the live --home-bg, a barely-there blue rather than the
     grey off-white the scaffold guessed, so cards read as white against it. */
  --page-bg:        #f7fcfe;
  /* Page-head band: a step into the section blue so the title area reads. */
  --page-head-bg:   #e2f3fb;

  /* Footer keeps its own tokens: it is dark in the light scheme, and needs a
     different treatment once the whole page is dark. Now the same blue-grey
     the live nav uses, with its lighter steps taken from that hue. */
  --footer-bg:     #37474f;
  --footer-fg:     #c3cfd5;
  --footer-link:   #dfe8ec;
  --footer-head:   #ffffff;
  --footer-muted:  #9fb0b8;
  --footer-border: #4d646f;
  /* live --border-color rgba(55,71,79,.1) and a stronger step of it */
  --border:         #ebeded;
  --border-strong:  #d6dcde;

  /* The RSS mark on partials/feed-link.html. Not the canonical #f26522: that
     measures 2.77 on --page-head-bg, where most of these links live, and 1.4.11
     asks 3:1 of a graphic that carries meaning. This is the same orange walked
     down until it clears 3:1 on every light ground (3.15 on the page head, 3.58
     on --surface); the dark scheme takes it back up. It is deliberately not
     --tier-gold: that token means "gold sponsor" and nothing else. */
  --feed:           #e8590c;

  /* THE TWO ADMONITION SEVERITIES THAT HAD NO COLOUR OF THEIR OWN. An AsciiDoc
     article has five (NOTE, TIP, IMPORTANT, WARNING, CAUTION) and the palette
     had four usable accents, so WARNING and CAUTION were both mapped to --feed
     and rendered identically -- two different severities the reader could not
     tell apart, which is the one thing an admonition exists to do.
     Semantic rather than single-use names: any future alert or banner wants
     the same two.

     MEASURED AGAINST THE ADMONITION GROUND (--surface-sunken #edf9fe), not
     against white, because that is what the label actually sits on:
     --warning 5.13, --caution 5.50, both clear of 4.5. Reusing --feed would
     have measured 3.34 there -- it is tuned for the RSS mark on
     --page-head-bg, a different ground and a different job, which is the same
     reason its own comment gives for not being --tier-gold. */
  --warning:        #b34700;
  --caution:        #8b5a00;

  /* Type */
  --font-display: "Barlow", "Segoe UI", system-ui, sans-serif;
  --font-body: "Source Sans 3", "Segoe UI", system-ui, sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace;

  /* Metrics */
  --max-width: 1240px;
  /* The article READING MEASURE -- the cap on .post-single / .page-single and
     on a board member page. Sized to fill the sidebar layout's article column
     rather than to a round number of characters: 1240 - 2*40 gutter - 330
     sidebar - 52 gap = 778px of column, against 96ch = 770px at the 17px body
     size, so the cap no longer leaves ~70px of empty gutter beside the text on
     a desktop article. It is still doing work below 960px, where the sidebar
     collapses and the column becomes the full 1160px -- ~145 characters to a
     line, which is what this token exists to prevent. (`ch` resolves against
     the element's own font-size, so an article that is also `.prose` caps a
     shade wider; the two were already 88ch apart in the same way.) */
  --measure: 96ch;
  --gutter: clamp(1rem, 4vw, 2.5rem);
  --radius: 10px;
  --radius-sm: 6px;
  --header-h: 76px;

  /* Blue-tinted, which is the live theme's signature: every shadow token there
     is rgba(79,195,247,.1-.3) rather than a neutral grey. Kept subtler than
     upstream's, since this theme puts shadows on far more surfaces. */
  --shadow-sm: 0 1px 2px rgba(55, 71, 79, .06), 0 2px 8px rgba(79, 195, 247, .10);
  --shadow-md: 0 4px 12px rgba(55, 71, 79, .07), 0 16px 40px rgba(79, 195, 247, .16);

  --ease: cubic-bezier(.2, .7, .3, 1);
}

/* --------------------------------------------------------------- base ---- */

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

html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--page-bg);
  color: var(--ink-soft);
  font-family: var(--font-body);
  font-size: 1.0625rem;
  line-height: 1.65;
  text-wrap: pretty;
}

img, svg { max-width: 100%; height: auto; display: block; }

a { color: var(--brand-secondary); text-decoration: none; }
a:hover { color: var(--brand-accent-strong); text-decoration: underline; text-underline-offset: 2px; }
/* One focus indicator for everything focusable, including the two kinds that
   are not natively focusable and are given a tabindex: a click-to-enlarge image
   (js/lightbox.js) and a sideways-scrolling table region, which a keyboard user
   can only scroll once it can hold focus. 3px rather than 2 so the ring clears
   WCAG 1.4.11's area test against a light border as well as its contrast one. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible,
dialog:focus-visible,
[tabindex]:focus-visible {
  outline: 3px solid var(--focus-ring);
  outline-offset: 2px;
  border-radius: 3px;
}

h1, h2, h3, h4, h5 {
  font-family: var(--font-display);
  color: var(--ink);
  line-height: 1.15;
  letter-spacing: -.01em;
  margin: 0 0 .5em;
  font-weight: 700;
}
h1 { font-size: clamp(1.9rem, 1.3rem + 2.2vw, 2.9rem); }
h2 { font-size: clamp(1.5rem, 1.1rem + 1.4vw, 2.1rem); }
h3 { font-size: 1.25rem; }
h4 { font-size: 1.0625rem; }

p { margin: 0 0 1.1em; }

code, pre { font-family: var(--font-mono); font-size: .9em; }
code { background: var(--surface-sunken); padding: .1em .35em; border-radius: 4px; }
pre { background: var(--surface-navy); color: #e6edf3; padding: 1.1rem 1.25rem; border-radius: var(--radius); overflow-x: auto; }
pre code { background: none; padding: 0; color: inherit; }

/* <kbd> is a KEY, not a snippet of code -- same mono face and tint as `code`,
   plus a border and a thicker bottom edge so a run of them reads as something
   you press. The keyboard walkthrough on /accessibility/ is the main user;
   four articles carry one too, and were rendering at the browser default. */
kbd {
  font-family: var(--font-mono); font-size: .85em; white-space: nowrap;
  background: var(--surface-sunken); color: var(--ink);
  padding: .1em .4em; border-radius: 4px;
  border: 1px solid var(--border); border-bottom-width: 2px;
}

/* A ```mermaid fence renders as <pre class="mermaid"> (see
   _markup/render-codeblock.html), and mermaid replaces its text with an <svg>
   IN PLACE -- the <pre> stays. So the dark code-block styling above has to come
   off, or every diagram sits on a navy slab with code padding.

   Keyed on mermaid's OWN `data-processed`, which it stamps on a block once
   rendered, so the two states style themselves: before rendering (and with no
   JavaScript at all) the block keeps the code-block look and shows the diagram
   source, which is honest and readable; after rendering it is a centred,
   responsive figure. Nothing has to be toggled from the script. */
pre.mermaid[data-processed] {
  background: none; color: inherit; padding: 0;
  margin: 1.5rem 0; text-align: center; line-height: normal;
}
/* Mermaid sizes the SVG in the diagram's own units, which overflows a phone
   without this. height:auto keeps the aspect ratio while width shrinks. */
pre.mermaid[data-processed] svg { max-width: 100%; height: auto; }
blockquote {
  margin: 1.5rem 0; padding: .25rem 0 .25rem 1.25rem;
  border-left: 3px solid var(--brand-accent);
  color: var(--ink); font-size: 1.1rem;
}
hr { border: 0; border-top: 1px solid var(--border); margin: 2.5rem 0; }

.u-sr {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

.skip-link {
  position: absolute; left: 1rem; top: -3rem; z-index: 200;
  background: var(--ink); color: var(--on-ink); padding: .6rem 1rem; border-radius: 0 0 var(--radius-sm) var(--radius-sm);
  transition: top .15s var(--ease);
}
.skip-link:focus { top: 0; color: var(--on-ink); text-decoration: none; }

/* --------------------------------------------------------------- shell --- */

.shell { max-width: var(--max-width); margin-inline: auto; padding-inline: var(--gutter); }

.site-main { display: block; }

.layout-with-sidebar {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 330px;
  gap: clamp(1.5rem, 3vw, 3.25rem);
  max-width: var(--max-width);
  margin: 2.5rem auto 4rem;
  padding-inline: var(--gutter);
  align-items: start;
}
.content-col { min-width: 0; }
.content-col.full-width {
  grid-column: 1 / -1;
  max-width: var(--max-width);
  margin: 2.5rem auto 4rem;
  padding-inline: var(--gutter);
}

/* -------------------------------------------------------------- header --- */

.site-header {
  position: sticky; top: 0; z-index: 100;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  transition: box-shadow .2s var(--ease);
}
.site-header[data-scrolled="true"] { box-shadow: var(--shadow-sm); }

.site-header__inner {
  max-width: var(--max-width);
  margin-inline: auto;
  padding-inline: var(--gutter);
  min-height: var(--header-h);
  display: flex;
  align-items: center;
  gap: clamp(1rem, 2vw, 2rem);
}

/* Brand -------------------------------------------------------- */
.logo { display: flex; align-items: center; gap: .6rem; color: var(--ink); flex: 0 0 auto; }
.logo:hover { text-decoration: none; color: var(--ink); }
.logo__mark { width: 34px; height: 34px; }
/* The logo PNG carries the wordmark, so it is sized by height and replaces the
   text mark entirely. The file is cropped to the artwork (no transparent
   margin), so these heights are the height the mark actually renders at —
   re-export a padded PNG and it silently shrinks again. */
.logo__img { height: 36px; width: auto; }
.site-footer .logo__img { height: 44px; }
.logo__word {
  font-family: var(--font-display);
  font-size: 1.4rem; font-weight: 500; letter-spacing: -.02em; line-height: 1;
}
.logo__word b { font-weight: 800; }
.logo__word .dot { color: var(--brand-accent); }
.logo__tag {
  display: none;
  font-size: .75rem; text-transform: uppercase; letter-spacing: .11em;
  color: var(--ink-muted); font-weight: 600;
  padding-left: .7rem; margin-left: .1rem;
  border-left: 1px solid var(--border-strong);
}

/* Primary nav -------------------------------------------------- */
/* The nav sits right after the brand and the free space collects before the
   actions, rather than `margin-left: auto` on the nav pushing every item to the
   far right — which left a void beside the logo and made the bar read as one
   1240px-wide strip on a big screen. */
.primary-nav { margin-left: .5rem; }
.primary-nav__list { display: flex; align-items: center; gap: .15rem; list-style: none; margin: 0; padding: 0; }

.nav-link {
  appearance: none; background: none; border: 0; cursor: pointer;
  display: inline-flex; align-items: center; gap: .4rem;
  font-family: var(--font-display); font-size: 1rem; font-weight: 600;
  color: var(--ink); padding: .55rem .7rem; border-radius: var(--radius-sm);
  line-height: 1.2; white-space: nowrap;
  transition: background .15s var(--ease), color .15s var(--ease);
}
.nav-link:hover { background: var(--surface-sunken); color: var(--brand-accent-strong); text-decoration: none; }
.nav-link[aria-current="page"] { color: var(--brand-accent-strong); }

/* chevron, drawn in CSS so there is no icon dependency */
.nav-link--parent::after {
  content: ""; width: .42em; height: .42em; margin-top: -.18em;
  border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transition: transform .2s var(--ease);
  opacity: .65;
}
.nav-item[data-open="true"] > .nav-link { background: var(--surface-sunken); color: var(--brand-accent-strong); }
.nav-item[data-open="true"] > .nav-link--parent::after { transform: rotate(-135deg); margin-top: .16em; }

/* Dropdown panels ---------------------------------------------- */
.site-header__inner { position: relative; }
.nav-item { position: relative; }

/* Multi-column panels are too wide to hang off their trigger: drop the item's
   containing block so the panel is laid out against .site-header__inner and
   clamp it to that width. */
.nav-item:has(.nav-panel__inner--cols-2),
.nav-item:has(.nav-panel__inner--cols-3) { position: static; }

.nav-panel {
  position: absolute; top: calc(100% + .4rem); left: 50%;
  transform: translate(-50%, -6px);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-md);
  padding: .6rem;
  max-width: calc(100vw - 2rem);
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity .16s var(--ease), transform .16s var(--ease), visibility .16s;
}
.nav-item:has(.nav-panel__inner--cols-2) > .nav-panel,
.nav-item:has(.nav-panel__inner--cols-3) > .nav-panel {
  left: 50%; right: auto;
  max-width: calc(100% - 2 * var(--gutter));
}
.nav-item[data-open="true"] > .nav-panel {
  opacity: 1; visibility: visible; pointer-events: auto;
  transform: translate(-50%, 0);
}
/* keyboard/no-JS fallback */
.nav-item:focus-within > .nav-panel {
  opacity: 1; visibility: visible; pointer-events: auto; transform: translate(-50%, 0);
}
/* keep the last panel from running off the right edge */
.nav-item:last-child > .nav-panel { left: auto; right: 0; transform: translate(0, -6px); }
.nav-item:last-child[data-open="true"] > .nav-panel,
.nav-item:last-child:focus-within > .nav-panel { transform: translate(0, 0); }

.nav-panel__inner { display: grid; gap: .25rem 1.75rem; align-items: start; }
.nav-panel__inner--cols-1 { min-width: 250px; }
.nav-panel__inner--cols-2 { grid-template-columns: repeat(2, minmax(180px, 1fr)); }
.nav-panel__inner--cols-3 { grid-template-columns: repeat(3, minmax(170px, 1fr)); }

.nav-panel__list {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: 1px;
  align-content: start; grid-auto-rows: min-content;
}

.nav-panel a {
  display: block; padding: .55rem .7rem; border-radius: var(--radius-sm);
  color: var(--ink); font-size: .96rem; line-height: 1.35;
}
.nav-panel a:hover { background: var(--brand-accent-soft); color: var(--brand-accent-strong); text-decoration: none; }
.nav-panel a .nav-panel__desc {
  display: block; font-size: .82rem; color: var(--ink-muted); margin-top: .1rem;
}
.nav-panel a:hover .nav-panel__desc { color: var(--ink-soft); }

/* Third level: flattened into a titled column instead of a nested flyout.
   This is the main fix for the live site's cramped 3-deep hover menus. */
.nav-group { padding: .35rem 0; }
.nav-group__title {
  display: block;
  font-family: var(--font-display); font-size: .74rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .1em;
  color: var(--ink-muted);
  padding: .3rem .7rem .45rem;
}
a.nav-group__title:hover { background: none; color: var(--brand-accent-strong); text-decoration: underline; }
.nav-group .nav-panel__list a { font-size: .9rem; padding-block: .38rem; color: var(--ink-soft); }
.nav-group + .nav-group { border-top: 1px solid var(--border); margin-top: .3rem; }
.nav-panel__inner--cols-2 .nav-group + .nav-group,
.nav-panel__inner--cols-3 .nav-group + .nav-group { border-top: 0; margin-top: 0; }

.nav-panel__foot {
  grid-column: 1 / -1;
  margin-top: .35rem; padding: .7rem .7rem 0;
  border-top: 1px solid var(--border);
}
.nav-panel__foot a { font-weight: 600; font-size: .9rem; color: var(--brand-accent-strong); padding-inline: 0; }

/* Header actions ----------------------------------------------- */
.header-actions { display: flex; align-items: center; gap: .6rem; flex: 0 0 auto; }
.site-header__inner > .header-actions { margin-left: auto; }
/* The drawer copy exists only for the <=900px off-canvas menu. */
.header-actions--mobile { display: none; }

/* Collapsed to its magnifier until asked for — see search-form.html. The input
   stays in the DOM (and in the tab order only when open) so the form still
   submits, and so no layout jump happens on expand. */
.site-search { position: relative; display: flex; align-items: center; gap: .35rem; }
.site-search input {
  font: inherit; font-size: .92rem;
  /* `visibility` rather than only opacity: a 0-width invisible input would
     otherwise still be a tab stop. */
  width: 0; padding: .5rem 0; opacity: 0; visibility: hidden;
  background: var(--surface-sunken);
  border: 1px solid transparent; border-radius: 999px;
  color: var(--ink);
  /* `visibility` steps rather than eases, and it is delayed on the way OUT so
     the field is still there while it shrinks. On the way IN it must flip at
     once: a visibility:hidden element cannot take focus, so easing it would
     make the click land the caret nowhere. */
  transition: width .2s var(--ease), padding .2s var(--ease), opacity .15s var(--ease),
              visibility 0s linear .2s, background .15s var(--ease), border-color .15s var(--ease);
}
.site-search input::placeholder { color: var(--ink-muted); }
.site-search[data-open="true"] input {
  width: 210px; padding: .5rem .9rem; opacity: 1; visibility: visible;
  background: var(--surface); border-color: var(--border-strong);
  transition: width .2s var(--ease), padding .2s var(--ease), opacity .15s var(--ease),
              visibility 0s, background .15s var(--ease), border-color .15s var(--ease);
}
.site-search input:focus { outline-offset: 0; }

.site-search__toggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; flex: 0 0 auto;
  background: none; border: 1px solid var(--border); border-radius: 999px;
  color: var(--ink-soft); cursor: pointer;
  transition: border-color .15s var(--ease), color .15s var(--ease), background .15s var(--ease);
}
.site-search__toggle:hover { border-color: var(--brand-accent); color: var(--brand-accent-strong); background: var(--brand-accent-soft); }
.site-search__toggle svg { width: 19px; height: 19px; }
.site-search[data-open="true"] .site-search__toggle { border-color: var(--border-strong); color: var(--ink); }

.btn {
  display: inline-flex; align-items: center; gap: .45rem;
  font-family: var(--font-display); font-weight: 700; font-size: .95rem;
  padding: .6rem 1.1rem; border-radius: 999px;
  border: 1px solid transparent; cursor: pointer; white-space: nowrap;
  transition: background .15s var(--ease), color .15s var(--ease), border-color .15s var(--ease);
}
.btn--accent { background: var(--brand-accent); color: var(--on-accent); }
.btn--accent:hover { background: var(--brand-accent-strong); color: var(--on-accent-strong); text-decoration: none; }
.btn--ghost { border-color: var(--border-strong); color: var(--ink); }
.btn--ghost:hover { border-color: var(--ink); color: var(--ink); text-decoration: none; }
.btn--navy { background: var(--ink); color: var(--on-ink); }
.btn--navy:hover { background: #475c66; }

/* Theme toggle ------------------------------------------------- */
.theme-toggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; flex: 0 0 auto;
  background: none; border: 1px solid var(--border); border-radius: 999px;
  color: var(--ink-soft); cursor: pointer;
  transition: border-color .15s var(--ease), color .15s var(--ease), background .15s var(--ease);
}
.theme-toggle:hover { border-color: var(--brand-accent); color: var(--brand-accent-strong); background: var(--brand-accent-soft); }
.theme-toggle svg { width: 19px; height: 19px; }
.theme-toggle .icon-sun { display: none; }
:root[data-theme="dark"] .theme-toggle .icon-sun { display: block; }
:root[data-theme="dark"] .theme-toggle .icon-moon { display: none; }

/* Mobile toggle ------------------------------------------------ */
.nav-toggle {
  display: none;
  margin-left: auto;
  width: 42px; height: 42px;
  align-items: center; justify-content: center;
  background: none; border: 1px solid var(--border); border-radius: var(--radius-sm);
  cursor: pointer; color: var(--ink);
}
.nav-toggle__bars, .nav-toggle__bars::before, .nav-toggle__bars::after {
  content: ""; display: block; width: 18px; height: 2px; background: currentColor; border-radius: 2px;
  transition: transform .2s var(--ease), opacity .15s var(--ease);
}
.nav-toggle__bars { position: relative; }
.nav-toggle__bars::before { position: absolute; top: -6px; }
.nav-toggle__bars::after { position: absolute; top: 6px; }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars { background: transparent; }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars::before { transform: translateY(6px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars::after { transform: translateY(-6px) rotate(-45deg); }

.nav-backdrop {
  position: fixed; inset: 0; z-index: 90;
  background: rgba(13, 29, 47, .45);
  opacity: 0; visibility: hidden; transition: opacity .2s var(--ease), visibility .2s;
}
body[data-nav-open="true"] .nav-backdrop { opacity: 1; visibility: visible; }

/* -------------------------------------------------------- page headers -- */

.page-head {
  background: var(--page-head-bg);
  border-bottom: 1px solid var(--border);
  padding: clamp(2rem, 4vw, 3.25rem) 0;
}
.page-head__inner { max-width: var(--max-width); margin-inline: auto; padding-inline: var(--gutter); }
.page-head .eyebrow { margin-bottom: .5rem; }
.page-head h1 { margin-bottom: .35rem; }
/* Page-head text runs the full width of the shell. The usual reading-column
   cap (62ch) left the right half of every page head empty, and a head is a
   short intro read once, not body copy read for minutes. */
.page-head p { color: var(--ink-soft); margin: 0; }
.page-head__meta { display: flex; align-items: center; flex-wrap: wrap; gap: .9rem; }
.page-head__meta p { margin: 0; }

/* AI portal: its editorial intro lives in the page head, so its paragraphs need
   the spacing `.page-head p` deliberately removes for the single-line meta text
   every other page head holds. */
.page-head__intro { margin: 0 0 1.1rem; }
.page-head__intro p { margin: 0; }
.page-head__intro p + p { margin-top: .75rem; }
/* /sitemap/'s jump links: a chip row in the page head rather than the sidebar
   "On this page" panel, because that page is full-width so its 2000-row
   articles table and 344-row authors table get the whole shell -- a rail would
   take ~300px back off exactly the thing that needs them. Reuses .chip-row, so
   there is nothing here but the spacing. */
.page-head__jump { margin-top: .9rem; }
/* A section-head whose subject is a chip list (the portal's "Topics covered")
   rather than a lead paragraph. */
.section-head .chip-row { margin-top: .75rem; }
.btn--sm { padding: .3rem .75rem; font-size: .82rem; }

.eyebrow {
  display: inline-flex; align-items: center; gap: .5rem;
  font-family: var(--font-display); font-size: .78rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .12em; color: var(--brand-accent-strong);
}
.eyebrow::before { content: ""; width: 22px; height: 3px; background: var(--brand-accent); border-radius: 2px; }
/* The AI portal's eyebrow is a back link to /today/ (as it is on WordPress);
   keep it looking like an eyebrow rather than a body link. */
.eyebrow a { color: inherit; }

.section-head {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 1.5rem; flex-wrap: wrap;
  margin: 0 0 1.5rem;
  padding-bottom: .85rem;
  border-bottom: 1px solid var(--border);
}
.section-head h2 { margin: 0; }
.section-head p { margin: .3rem 0 0; color: var(--ink-muted); font-size: .98rem; }
.section-head .more { font-family: var(--font-display); font-weight: 700; font-size: .92rem; color: var(--brand-accent-strong); }

.band { padding: clamp(2.25rem, 4vw, 3.5rem) 0; }
.band--sunken { background: var(--surface-sunken); border-block: 1px solid var(--border); }

/* The home page's one-line strapline, directly under the sticky header
   (layouts/index.html). Sunken with a bottom rule so it reads as the foot of
   the site chrome rather than as the first item of content -- it belongs to the
   masthead, and must not compete with the lead card immediately below it.

   Sized to cost ~44px of the fold. If this ever grows past one line on a
   desktop width, shorten /who-we-are/'s description rather than restyling
   here: the length of that string is the real constraint, and it is also what
   search results and link previews show. */
.home-intro {
  background: var(--surface-sunken);
  border-bottom: 1px solid var(--border);
}
.home-intro p {
  margin: 0; padding: .72rem 0;
  font-size: .95rem; color: var(--ink-soft);
}
/* nowrap so the link never breaks across lines away from its arrow. */
.home-intro a { font-weight: 600; white-space: nowrap; }
/* --------------------------------------------------- home: events strip -- */
/* The "next two weeks" band (partials/upcoming-events.html). One row that
   scrolls sideways, not a grid that wraps: the band is a glance at what is
   coming, and a wrapped second row of eleven meetups is a list -- which is
   what /calendar/ is for. */
.event-strip {
  list-style: none; margin: 0; padding: 0 .25rem .5rem 0;
  display: grid; grid-auto-flow: column;
  /* A card wide enough for two lines of title, and no wider than a third of a
     narrow viewport, so the next one always peeks in and the row reads as
     scrollable without a scrollbar having to say so. */
  grid-auto-columns: minmax(min(17rem, 78%), 1fr);
  gap: clamp(.75rem, 1.5vw, 1.1rem);
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
}
.event-strip__item {
  scroll-snap-align: start;
  display: flex; flex-direction: column; gap: .35rem; min-width: 0;
  padding: .9rem 1rem; border-radius: var(--radius);
  /* Sunken cards on a plain band, rather than plain cards on a sunken one:
     the podcast band below is sunken, and two sunken bands in a row read as
     one block with two headings. */
  background: var(--surface-sunken); border: 1px solid var(--border);
  /* The group's own colour, the same hue the calendar gives it. */
  border-left: 3px solid var(--cal-dot, var(--brand-secondary));
}
.event-strip__date {
  font-family: var(--font-display); font-weight: 700; font-size: .8rem;
  text-transform: uppercase; letter-spacing: .08em; color: var(--brand-accent-strong);
  font-variant-numeric: tabular-nums;
}
.event-strip__title {
  font-family: var(--font-display); font-weight: 700; font-size: 1rem;
  line-height: 1.3; color: var(--ink);
  /* Two lines then ellipsis: the cards are one row, so they share a height and
     a four-line title would set it for all of them. */
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
a.event-strip__title:hover { color: var(--brand-accent-strong); text-decoration: none; }
.event-strip__meta {
  margin: auto 0 0; display: flex; flex-wrap: wrap; gap: .2rem .5rem;
  font-size: .82rem; color: var(--ink-muted); line-height: 1.4;
}
.event-strip__group { color: var(--ink-soft); font-weight: 600; }
/* Outlined, for the same reason the agenda's is: the hue is hashed, so light
   text on it is a contrast lottery. */
.event-strip__group--type {
  padding: .02rem .4rem; border-radius: 999px;
  border: 1px solid var(--cal-dot, var(--border)); color: var(--ink-soft);
  font-size: .92em; text-transform: uppercase; letter-spacing: .05em;
}
.event-strip__where { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.event-strip__time { font-variant-numeric: tabular-nums; }

/* ------------------------------------------------------------ post grid -- */

.post-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(255px, 1fr));
  gap: clamp(1.25rem, 2vw, 1.75rem);
}

.post-card { display: flex; flex-direction: column; gap: .6rem; min-width: 0; }
.post-card-image {
  display: block; overflow: hidden; border-radius: var(--radius);
  background: var(--surface-sunken); aspect-ratio: 300 / 200;
  /* Positioning context for the .thumb-ph tile, which sits behind the image. */
  position: relative;
}
.post-card-image img {
  width: 100%; height: 100%; object-fit: cover;
  transition: transform .35s var(--ease);
  /* position+z-index, not source order: .thumb-ph is absolutely positioned and
     a positioned element paints ABOVE a non-positioned sibling regardless of
     where it sits in the DOM -- so without this the tile would cover the
     image on every card that has one. */
  position: relative; z-index: 1;
  /* And its own opaque ground, because a great many WordPress heroes are
     transparent PNG logos: without this the tile's diagonal stripes showed
     THROUGH them. The tile is the fallback for a missing image, not a mat to
     sit a present one on. */
  background: var(--surface-sunken);
}
.post-card:hover .post-card-image img { transform: scale(1.04); }

/* --- Derived thumbnail placeholder (partials/post-thumb.html, and the same
   markup built in JS by search/single.html) ------------------------------------
   What a card or a search result shows when the post has no `image:` -- and
   what shows THROUGH when a hotlinked hero has gone 404, since the <img> on
   top removes itself in onerror. Sits under the image rather than instead of
   it, so one box handles both cases and no template has to know which.

   --ph-hue is set inline by the template, hashed from the label (the post's
   first category), so a category reads as one colour across the site with no
   colour ever configured -- same idea as a JUG's dot on /calendar/. Saturation
   and lightness are fixed here, which is what keeps 123 arbitrary hues looking
   like one family instead of a paint chart. */
.thumb-ph {
  position: absolute; inset: 0; z-index: 0;
  display: grid; place-items: center; padding: .9rem;
  background:
    repeating-linear-gradient(135deg,
      transparent 0 11px, hsl(var(--ph-hue) 60% 100% / .45) 11px 22px),
    linear-gradient(155deg, hsl(var(--ph-hue) 46% 91%), hsl(var(--ph-hue) 40% 79%));
}
.thumb-ph__label {
  font-family: var(--font-display); font-weight: 700;
  font-size: .8rem; line-height: 1.25;
  text-transform: uppercase; letter-spacing: .08em;
  text-align: center; text-wrap: balance;
  color: hsl(var(--ph-hue) 52% 25%);
}
.post-card--lead .thumb-ph__label { font-size: 1.05rem; letter-spacing: .1em; }

.post-card-title { font-size: 1.1rem; margin: 0; line-height: 1.3; }
.post-card-title a { color: var(--ink); }
.post-card-title a:hover { color: var(--brand-accent-strong); text-decoration: none; }

.post-card-meta {
  display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
  color: var(--ink-muted); font-size: .85rem;
}
.post-card-meta a { color: var(--ink-muted); font-weight: 600; }
.post-card-meta a:hover { color: var(--brand-accent-strong); }
/* The dot goes on whatever FOLLOWS the byline, because the byline now leads the
   meta line (post-card.html). `.byline-group + time`, not `time + a`: byline.html
   wraps its output in that span so the flex row treats a multi-author byline as
   ONE item (see the partial), and a selector keyed on <a> would additionally
   only match when the byline happens to be a link -- which it is not for a
   legacy slug with no author bundle, nor for the collapsed "N authors" form.
   Both of those silently lost their separator before. */
.post-card-meta .byline-group + time::before,
.post-card-meta .views::before { content: "·"; margin-right: .5rem; color: var(--border-strong); }

/* THE FACE IN FRONT OF A BYLINE (byline.html, `avatars`). Round, like every
   other face on the site (.author-card img, .author-avatar).

   THE IMAGE IS INSIDE THE <a>, NOT BESIDE IT, and that is load-bearing.
   .post-card-meta is a flex row with a .5rem gap, so an <img> placed as a
   sibling of the name would become its own flex item and take that gap on both
   sides -- the same failure .byline-group exists to fix for the comma between
   two names. Nested in the link it is the link's business, and the outer row
   still sees one item.

   The LINK is the flex container, which is what lets the circle be bigger than
   the .85rem text: `align-items: center` centres the name against the portrait
   whatever size it is, where a `vertical-align` offset has to be re-tuned every
   time the size changes and still drifts as the line box grows. inline-flex, so
   several authors still flow as text with the commas between them, and so it
   works unchanged on the plain <span> form (a legacy slug with no bundle, or
   the collapsed "12 authors") where there is no image at all.

   The background is a placeholder for the moment before a lazy image decodes,
   so a card does not flicker as faces arrive. */
.byline { display: inline-flex; align-items: center; gap: .4rem; }
.byline-avatar {
  width: 1.9rem; height: 1.9rem;
  border-radius: 50%; object-fit: cover;
  background: var(--surface-sunken);
  /* A hairline, so a light-background portrait still reads as a disc against
     the card rather than bleeding into it. */
  box-shadow: 0 0 0 1px var(--border);
}

/* THREE OR MORE AUTHORS: the faces overlap into a row and the names collapse to
   a count (the $pile branch in byline.html). The overlap is what keeps twelve
   credits inside a 255px card -- laid out end to end, four portraits and a
   count are already wider than the card.

   The ring is --surface rather than --border here: each disc has to read
   against the DISC BEHIND IT, not against the card, so the ring is the card's
   own background punching a gap between neighbours. Drawn with box-shadow, not
   a border, so it sits outside the 1.9rem box and the circles keep their size.

   The FIRST author is the face on top, via descending z-index rather than a
   `direction: rtl` flip. The flip does put the first child in front -- painting
   order follows the DOM -- but it also reverses the row, which lands the "+N"
   chip at the LEFT end where it reads as part of the first face instead of as
   the overflow of the last. Five children at most, so five rules is the whole
   ladder. */
.byline-faces { display: inline-flex; }
.byline-faces > * { position: relative; }
.byline-faces > :nth-child(1) { z-index: 5; }
.byline-faces > :nth-child(2) { z-index: 4; }
.byline-faces > :nth-child(3) { z-index: 3; }
.byline-faces > :nth-child(4) { z-index: 2; }
/* A little over a tenth of a face tucked behind its neighbour: enough to read
   as a stack, little enough that each portrait is still a recognisable person. */
.byline-faces > * + * { margin-left: -.4rem; }
.byline-faces .byline-avatar { box-shadow: 0 0 0 2px var(--surface); display: block; }
.byline-face { display: inline-flex; border-radius: 50%; }
/* HOVER DOES NOT RESTACK, and that is a fix rather than an omission. A link
   box is a SQUARE around a round portrait, so in the overlap strip two boxes
   are under the pointer at once. Raising the hovered one to the top moved its
   square over its neighbour, which handed the pointer to the other face, which
   raised THAT one -- the two swapped back and forth for as long as the cursor
   sat in the overlap, flickering the ring and the label between two people.

   Leaving the nth-child ladder alone settles it: the face drawn on top in a
   given strip is also the one that receives the pointer there, so what you
   point at is what you see, and nothing moves. The cost is that the ring on a
   partly-covered face is clipped by the disc in front of it, which is what a
   stack of overlapping things should look like anyway. */
.byline-faces .byline-face:hover .byline-avatar,
.byline-faces .byline-face:focus-visible .byline-avatar { box-shadow: 0 0 0 2px var(--brand-accent-strong); }

/* THE NAME ON HOVER. A pile shows faces and a count, so this is the only place
   a reader is told which portrait is whom -- hence :focus-visible too, and
   hence the name staying on the <img> as alt regardless.

   It sits ABOVE the row, which is also why it survives the no-restack rule: the
   label never overlaps a sibling face, so it does not matter that a later face
   sits lower in the ladder. The only overflow: hidden on a card is on
   .post-card-image, which this is not inside, so nothing clips it. */
.byline-face::after,
.byline--count[data-name]::after {
  content: attr(data-name);
  position: absolute; bottom: calc(100% + .4rem); left: 50%;
  transform: translateX(-50%) translateY(.15rem);
  padding: .2rem .45rem; border-radius: .35rem;
  background: var(--ink); color: var(--surface);
  font-size: .72rem; font-weight: 600; line-height: 1.3; white-space: nowrap;
  opacity: 0; pointer-events: none;
  transition: opacity .12s ease, transform .12s ease;
}
.byline-face:hover::after,
.byline-face:focus-visible::after,
.byline--count[data-name]:hover::after,
.byline--count[data-name]:focus-visible::after { opacity: 1; transform: translateX(-50%) translateY(0); }

/* THE COUNT ANSWERS "WHO ELSE?". Only four faces fit, so on a twelve-author
   post eight people are represented by the word "authors" alone -- hovering the
   count spells all of them out. It wraps, unlike a single name: twelve names on
   one nowrap line is wider than the viewport. max-content up to a cap, so two
   names stay on one line and twelve become a readable block rather than a
   column one name wide.

   `cursor: help` because this is the one hoverable thing in the byline that is
   not a link, and nothing else would say so. tabindex is deliberately NOT set:
   the faces beside it are already focusable and carry the same names, so making
   the count a fifth stop would repeat them for no gain. */
.byline--count[data-name] { position: relative; cursor: help; }
.byline--count[data-name]::after {
  white-space: normal; width: max-content; max-width: 16rem; text-align: center;
}
@media (prefers-reduced-motion: reduce) {
  .byline-face::after,
  .byline-face:hover::after,
  .byline-face:focus-visible::after { transition: none; transform: translateX(-50%); }
}
/* Initial-in-a-disc, for an author bundle with no `avatar:`. All 354 have one
   today, so this is a safety net for the next one added rather than something
   on screen -- but it has to hold the row's shape if it ever fires, hence the
   same disc and the .byline-faces prefix to outrank the `display: block`
   above. */
.byline-faces .byline-avatar--initial {
  display: inline-flex; align-items: center; justify-content: center;
  font-size: .8rem; font-weight: 700; color: var(--ink-muted);
}
/* WITH FACES ON, THE GROUP SPACES ITSELF and byline.html emits no comma between
   names -- a portrait already shows where one author ends and the next starts.
   That makes this the one case where .byline-group is itself a flex container,
   which is safe for the reason the wrapper exists at all: .post-card-meta sees
   the group as a single item either way, so the row's own .5rem gap still never
   reaches the names inside it. It also feeds the gap between the face row and
   the "N authors" count in the pile branch, which is why that has no margin of
   its own. flex-wrap, so two long names on a narrow card break to a second line
   rather than overflowing the card. */
.byline-group--avatars {
  display: inline-flex; flex-wrap: wrap; align-items: center;
  column-gap: .6rem; row-gap: .15rem;
}

/* A NARROW CARD DROPS THE VIEW COUNT, because a face costs horizontal room the
   meta line does not have to spare. Cards are not all one width: the grid beside
   the sidebar gives 379px, the full-width bands on the home page ("Podcasts",
   4 across) give 269px. At 269 the line wants 314 -- and that is with ONE
   author, so it is the avatar, not the pile, that does it. Shrinking the
   portrait to fit needs 1.25rem, which is back below the size this was raised
   to, so the fix is to drop a field instead: the date says more about a post
   than its view count, and a fresh post has no count to show anyway.

   A CONTAINER query, not a media query, and that distinction is the whole point:
   both grids exist at the same viewport width, so only the card's OWN width can
   tell them apart. 330px is between the two, with room for the longer names.

   `container-type: inline-size` on the card is what makes it queryable. It
   contains layout and inline size, which is safe here -- a card is a grid item
   whose width comes from the grid, and nothing inside it is positioned against
   the page. */
.post-card { container-type: inline-size; }
/* THE META LINE PAINTS ABOVE THE THUMBNAIL, so a hover label can rise into it.
   .post-card-image is position: relative, which puts it in the positioned layer;
   a label growing upward out of the byline was drawn UNDER it and lost its top
   edge -- visible only on the multi-line "N authors" list, since a single name
   is short enough to clear the image's bottom edge.

   The lift goes on the whole meta row, NOT on the hovered face. Raising the
   hovered face is what caused the pointer to oscillate between two portraits in
   their overlap strip (see the no-restack note below); lifting their shared
   parent moves the whole row above the image and leaves the faces' order among
   themselves exactly as it was. */
.post-card-meta { position: relative; z-index: 1; }
@container (max-width: 330px) {
  .post-card-meta .views { display: none; }
}

.post-card-categories, .post-categories, .chip-row {
  display: flex; flex-wrap: wrap; gap: .4rem;
}
.post-card-categories a, .post-categories a, .chip-row a {
  font-family: var(--font-display); font-size: .76rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .06em;
  padding: .22rem .6rem; border-radius: 999px;
  /* Green, as a tag is on the live site (--home-tags-bg / --home-tags-text).
     Not --brand-secondary: that token is the LINK colour, and a chip is not a
     link into prose. */
  background: var(--tag-soft); color: var(--tag-text);
}
.post-card-categories a:hover, .post-categories a:hover, .chip-row a:hover {
  background: var(--brand-accent-soft); color: var(--brand-accent-strong); text-decoration: none;
}

.post-card-excerpt { margin: 0; font-size: .95rem; color: var(--ink-soft); }

/* ---------------------------------------------------- highlight panel ---
   THE SINGLE DEFINITION OF "THIS BLOCK IS THE ONE TO LOOK AT". Two users so
   far: the most recent post on the home page (post-card-lead.html) and the
   monthly featured authors at the top of /today/author/
   (featured-authors-band.html). A third gets it by adding the class -- which
   is the reason it is a class rather than a selector list, since the whole
   point is that the site has ONE highlight treatment and not one per section.

   The ground is --brand-accent-soft: the logo blue's own tint, already carrying
   the eyebrow tick and the accent button, so this introduces no colour that
   exists for one block only -- and it retunes itself for dark, where the token
   is a translucent blue over the page rather than a pale solid.

   --ink-muted IS REDEFINED INSIDE THE PANEL, and that is the interesting part:
   the token measures 4.39:1 on this ground against 4.94 on white, and it is
   used by a dozen descendants (card meta, an author's article count, a latest
   article's date). Stepping the token up once here fixes all of them at 4.71
   and keeps it lighter than --ink-soft, so the hierarchy inside the panel
   survives; naming each descendant instead would be a dozen rules that a new
   descendant silently misses. Dark needs no equivalent -- --ink-muted is 5.76
   on the dark panel already. */
.highlight-panel {
  --ink-muted: #566f7b;
  background: var(--brand-accent-soft);
  border: 1px solid var(--brand-accent);
  border-radius: var(--radius);
}
/* An eyebrow inside a highlight panel is a LABEL, not a chip.

   It used to be a solid accent pill, which solved a contrast problem and
   created a worse affordance one: a filled, rounded, uppercase pill is exactly
   what every clickable thing on this site looks like -- .btn, .chip-row a, the
   category chips on a card, the pagination cells -- so "Most recent post" read
   as a button that does nothing when clicked. An affordance that lies is worse
   than low contrast, because the reader acts on it.

   So the pill is gone and the contrast problem is solved the other way: the
   text takes the panel's own --ink (9.4:1 on --brand-accent-soft) instead of
   --brand-accent-strong, which is the token that could only manage 4.22 here.
   The 22px tick comes back as the marker and takes --brand-accent-strong,
   where a 3px bar of the lighter accent would have vanished into the tint.
   Nothing else on the site draws a label this way, so it cannot be mistaken
   for a control. */
.highlight-panel .eyebrow { color: var(--ink); }
.highlight-panel .eyebrow::before { background: var(--brand-accent-strong); }

/* Lead / featured card -- the most recent post.

   It sits in the highlight panel above rather than behind the hairline rule it
   used to carry, because the home page opens straight into this card: there is
   no hero and the <h1> is visually hidden (see index.html), so the first thing
   a reader sees is a card whose only distinction from the ten below it was
   being wider. Every card on the page is unfilled, so one filled block is all
   the focus this needs. Layout only here; the highlight is not this card's to
   define. */
.post-card--lead {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  gap: clamp(1.25rem, 3vw, 2.25rem);
  align-items: center;
}
.post-card--lead .post-card-image { aspect-ratio: 16 / 9; }

/* THE TWO COLUMNS CANNOT AGREE ON A HEIGHT, and these two rules are what keeps
   the disagreement small. The image is locked to 16/9, so its height follows its
   column width and nothing else; the text column -- the NARROWER of the two --
   holds everything and grows. align-items:center then splits the difference, so
   the image floats in the middle of a tinted band.

   It is not a long-title bug. Measured against the real archive, the text column
   runs 1.46x the image height at the median 48-character title and 1.88x at 90,
   and 10% of the 2161 posts are over 70 characters -- so it comes round about
   every tenth lead post, and a long title only makes it obvious.

   Shrinking the image is the tempting fix and it makes things WORSE: a narrower
   column loses height at 9/16 of the width it gives up while the text saves a
   single line, taking 1.88x to 2.16x. Stretching the image to fill the row does
   equalise them, at the cost of cropping -- 55% of a 700x307 banner, and a great
   many foojay heroes are wide screenshots where the crop is destructive.

   So the height comes off the TEXT instead, and the title size is the real
   culprit: 2.1rem in a 326px column is about 19 characters per line, far too
   narrow for display type. 1.75rem gives 23 and drops a 90-character title from
   5 lines to 4. With the excerpt clamped the gap roughly halves (92px to 43px at
   a 1240px window) with nothing cropped, no conditional, and no threshold for an
   author to fall either side of. */
.post-card--lead .post-card-title {
  font-family: var(--font-display);
  font-size: clamp(1.35rem, 1rem + 1vw, 1.75rem);
  /* A 4-line headline should not end on one orphan word. */
  text-wrap: balance;
}
.post-card--lead .post-card-excerpt {
  font-size: 1.05rem;
  /* The excerpt is a summary and is the expendable half: a long title takes its
     space rather than stretching the card. -webkit-line-clamp is unprefixed
     nowhere yet, and every engine we serve supports the -webkit- form. */
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  overflow: hidden;
}

/* The most-recent-posts panel (lead-posts.html) -- the box the lead card and the
   label now live INSIDE, rather than the card being the box itself.

   It carries the padding and the bottom margin the lead card used to, because
   the card is no longer the only thing in here: on a day that published several
   articles the panel holds a grid of ordinary cards instead, and both shapes
   want the same ground, the same inset and the same gap to the grid below. The
   tint, border and radius come from .highlight-panel, which AGENTS.md's rule
   makes the single definition of "look at this one" -- so this class must not
   restate any of them. */
.lead-panel {
  padding: clamp(1.1rem, 2.5vw, 1.75rem);
  margin-bottom: clamp(1.5rem, 3vw, 2.25rem);
}
/* The label. An <h2> in the markup for the heading order (see the partial), so
   its own margin has to be set rather than inherited from the h2 rule, which is
   in `em` of a font-size .eyebrow has already overridden. */
.lead-panel > .eyebrow { margin: 0 0 .9rem; }

/* auto-FIT, not the auto-fill .post-grid uses: this grid holds 2 to 5 cards
   rather than a full row of 10, and auto-fill leaves the tracks it could not
   fill standing at 1fr each -- so two same-day posts would sit in the left two
   thirds of the panel with an empty third beside them. auto-fit collapses the
   empty tracks, so however many there are they share the width. The 255px floor
   is .post-grid's, deliberately: these are the same cards as the grid below and
   comparing them is the point, so the panel is what marks them out, not a size
   nothing else on the page uses. */
.lead-panel__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(255px, 1fr));
  gap: clamp(1.25rem, 2vw, 1.75rem);
}


/* ------------------------------------------------------------- sidebar --- */

.sidebar { display: grid; gap: 1.5rem; position: sticky; top: calc(var(--header-h) + 1.25rem); }
.sidebar .widget {
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.15rem 1.25rem;
  margin: 0;
}
/* h2, not h4: these are the top-level headings of the complementary landmark
   they sit in, and as h4s they skipped a level or two under the page's h1/h2s
   on every page of the site (WCAG 1.3.1). The size is set here, so the tag was
   never carrying the look. Same for the footer's column headings below. */
.sidebar h2 {
  margin: 0 0 .8rem; padding: 0 0 .55rem;
  font-size: .82rem; text-transform: uppercase; letter-spacing: .11em;
  color: var(--ink);
  border-bottom: 2px solid var(--brand-accent);
}
.sidebar ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .1rem; }
.sidebar li a {
  display: block; padding: .3rem 0; font-size: .95rem; color: var(--ink-soft);
}
.sidebar li a:hover { color: var(--brand-accent-strong); }
.sidebar .view-all {
  display: inline-block; margin-top: .8rem;
  font-family: var(--font-display); font-weight: 700; font-size: .88rem;
  color: var(--brand-accent-strong);
}
/* Sidebar sponsor widget (partials/sidebar-sponsors.html).

   One ROW PER TIER, not one row per sponsor: seven logo+name rows ran ~420px in
   a sticky column that also holds the TOC and a banner, so bronze fell below the
   fold. Tier drives the logo size as well as the order, so gold still reads
   first and largest -- the sidebar counterpart to the tier grouping on
   /our-sponsors/. */
.sponsor-tiers { display: grid; gap: .55rem; }
.sponsor-tier__label {
  margin: 0 0 .25rem;
  font-family: var(--font-display); font-size: .68rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .1em; color: var(--ink-muted);
}
/* The tier's own colour on the caption, which is where the gold/silver/bronze
   tokens now do the work the per-sponsor <em> used to. */
.sponsor-tier--gold   .sponsor-tier__label { color: var(--tier-gold); }
.sponsor-tier--silver .sponsor-tier__label { color: var(--tier-silver); }
.sponsor-tier--bronze .sponsor-tier__label { color: var(--tier-bronze); }
.sponsor-tier--community-partner .sponsor-tier__label { color: var(--tier-community-partner); }
/* A GRID OF WIDE TILES, not a flex row of square ones, and the reason is what a
   sponsor logo actually is: most are WORDMARKS, two to four times wider than
   they are tall. `object-fit: contain` in a SQUARE tile therefore fits them to
   the tile's WIDTH and leaves two thirds of the height empty -- a 42px bronze
   tile rendered the adesso and APIdia wordmarks about 32x11px, which is not a
   readable logo, it is a smudge. Tier still sets the size, but only the HEIGHT;
   the width comes from the column.

   The track max is 92px because the widget's content box is 330px of sidebar
   less 1.25rem of padding each side, and three gold logos plus two .35rem gaps
   have to fit on one line: 3*92 + 2*5.6 = 287 <= 288. auto-fit then does the
   rest -- a tier with 4+ sponsors shrinks its tracks instead of overflowing,
   and a tier with one gets one 92px tile rather than a stretched one. */
.sponsor-tier__row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(56px, 92px));
  align-items: center; gap: .35rem;
}
.sponsor-side { display: block; color: var(--ink-soft); }
.sponsor-side:hover { color: var(--brand-accent-strong); }
/* Width from the grid track, height per tier -- so gold still reads largest
   while every tier gets the full column width to show its wordmark in. */
.sponsor-logo--side { width: 100%; height: 46px; padding: .3rem; border-radius: 8px; }
.sponsor-side--gold   .sponsor-logo--side { height: 60px; }
.sponsor-side--silver .sponsor-logo--side { height: 52px; }
.sponsor-side--bronze .sponsor-logo--side { height: 46px; }
/* Smallest of the four: the ladder is what the paid tiers buy, and a partner
   sits outside it rather than beneath bronze -- but the row still has to say
   which logos are which without a label on every one. */
.sponsor-side--community-partner .sponsor-logo--side { height: 42px; }

/* Sidebar "Coming soon" widget (partials/coming-soon.html) -- the posts that
   are written and scheduled but not published yet. Home page only, and it
   renders nothing when the queue is empty, so on most days the aside is exactly
   what it was.

   Rows of plain text rather than the link list every other widget here is,
   because the titles deliberately are NOT links: the page does not exist yet. */
/* A full row of gap, not the .1rem the link-list widgets use: an item here is
   two lines (title + meta), so at a tight gap the second title crowds the first
   item's date and the two dashed rules read as one continuous list rather than
   two entries.

   BOTH SELECTORS ARE NEEDED, and the bare one never won on its own. `.sidebar
   ul` above is (0,1,1) and sets `gap: .1rem`, so a lone `.coming-soon-side`
   (0,1,0) lost the gap whatever the source order -- the widget shipped at
   1.6px, i.e. exactly the crowding this comment claims to have fixed, with
   nothing to say so. The list form keeps ONE declared value while giving the
   in-sidebar case (0,2,0), the way `.sidebar .coming-soon-side__meta a` below
   restates only what collides. Same trap as the `.prose` and `.sitemap-table`
   overrides. */
.coming-soon-side,
.sidebar .coming-soon-side { display: grid; gap: 1rem; }
/* NO MARKER DOWN THE SIDE OF AN ITEM. It was a dashed accent rule, on the
   reasoning that a dash is the visual difference between "here is an article"
   and "there will be one" -- Frank's call, and the rule was carrying the idea
   badly: it was the only dashed border on the site, so it read as an artefact
   rather than as a tense, and it indented the titles out of line with every
   other widget in the aside. The section heading already says "Coming soon",
   and a title that is plain text rather than a link is the other half of
   saying it. So an item is a plain row and the gap above does the separating.
   If these ever need dividing more firmly, a hairline `border-top` on
   `__item + __item` is the shape to reach for -- not a rule beside each one. */
.coming-soon-side__title {
  margin: 0;
  font-family: var(--font-display); font-weight: 700;
  font-size: .95rem; line-height: 1.3; color: var(--ink);
}
/* A flex row with a gap, like .event-strip__meta and .post-card-meta -- the
   site's other meta lines -- rather than relying on template whitespace for the
   space between the date and the byline. That is also what makes the
   __by wrapper load-bearing: a flex container makes EVERY child its own item,
   so without it "by" and the author's name would be two items with the gap
   applied between them, rendering "by  Frank Delporte". Same reason
   byline.html wraps its own output in .byline-group. */
.coming-soon-side__meta {
  margin: .15rem 0 0;
  display: flex; flex-wrap: wrap; gap: 0 .4rem;
  font-size: .82rem; color: var(--ink-muted); line-height: 1.4;
}
.coming-soon-side__meta time { font-weight: 600; color: var(--ink-soft); }
/* `.sidebar li a` is (0,1,2) and turns every link inside a widget list into a
   PADDED BLOCK -- so the author link dropped onto its own line and spaced
   itself out like a nav item. Restated at (0,2,1) so the meta line stays one
   sentence. Colour and hover are deliberately left to that rule. */
.sidebar .coming-soon-side__meta a { display: inline; padding: 0; font-size: inherit; }

/* "On this page" table of contents + scroll-spy (partials/toc.html,
   static/js/toc.js). Nested for h2 > h3 (Hugo's default TOC levels). */
.toc #TableOfContents ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .05rem; }
.toc #TableOfContents > ul > li > ul { margin-top: .05rem; padding-left: .9rem; }
.toc #TableOfContents a {
  display: block; padding: .32rem 0 .32rem .75rem; font-size: .92rem;
  color: var(--ink-soft); border-left: 2px solid transparent; line-height: 1.3;
}
.toc #TableOfContents a:hover { color: var(--brand-accent-strong); }
.toc #TableOfContents a.is-active { color: var(--brand-accent-strong); border-left-color: var(--brand-accent-strong); font-weight: 600; }

/* End matter -- "About the author", "Related posts", "Join the discussion" --
   appended to the panel by partials/toc.html. Ruled off from the article's own
   headings above it, because those three are on nearly every post and without a
   break a five-heading article reads as an eight-heading one.

   The divider goes on the FIRST tail item via the adjacent-sibling selector
   rather than on a fixed child position: which entries exist varies per post
   (a post with no related posts has two, a fork with giscus unconfigured has
   one), so :nth-child would put the line in the wrong place. `li:not(.toc__tail)
   + li.toc__tail` is "the first tail item that follows a real heading" -- and
   it correctly draws nothing on a podcast episode whose panel is tail-only,
   where there is no article section to separate it from.

   Nested headings are in a child <ul>, so a top-level li is never a .toc__tail's
   previous sibling by accident. */
.toc #TableOfContents li:not(.toc__tail) + li.toc__tail {
  margin-top: .55rem; padding-top: .55rem; border-top: 1px solid var(--border);
}
/* A shade quieter than a heading link: it is a destination on the page, not
   something the article says. is-active still wins -- it is set on the <a>, and
   the scroll-spy highlights these like any other entry. */
.toc #TableOfContents .toc__tail > a { color: var(--ink-muted); }

/* ------------------------------------------------------------- article --- */

.post-single, .page-single { max-width: var(--measure); }
/* Opt out of the reading measure (--measure). That cap is right for prose --
   a very long line is hard to track back to the next one -- but
   wrong for pages whose content is a grid or table rather than paragraphs,
   which just get a wide empty gutter on the right instead. */
.page-single--wide { max-width: none; }
.post-single .post-meta {
  display: flex; align-items: center; gap: .6rem; flex-wrap: wrap;
  color: var(--ink-muted); font-size: .93rem; margin-bottom: 1.25rem;
}
.post-single .post-meta a { font-weight: 600; }
.post-single .post-hero, .post-single img {
  width: 100%; border-radius: var(--radius); margin: 1.5rem 0;
}
/* THE BYLINE PORTRAIT IS NOT AN ARTICLE IMAGE. The rule above is for pictures
   in the body, but `.post-single img` is a class PLUS a type selector (0,1,1)
   and reaches every <img> on the page -- including the author face in the
   header, whose own `.byline-avatar` is only (0,1,0). So the portrait lost its
   1.9rem box, stretched to the full width of its flex item and took a 1.5rem
   margin with it.

   BOTH FORMS BROKE, not just the single-author one it was reported on.
   `.byline-faces .byline-avatar` (0,2,0) does outrank the broad rule, but it
   declares only box-shadow and display -- the SIZE still came from the plain
   `.byline-avatar`, so the pile's faces stretched as well. Hence this override
   is on `.byline-avatar` inside .post-single rather than on the single-author
   case alone: one rule, both bylines. The same specificity trap the
   .sitemap-table, .site-table and .tableblock rules each document. */
.post-single .byline-avatar {
  width: 1.9rem; height: 1.9rem; margin: 0; border-radius: 50%;
}

/* A RELATED-POST THUMBNAIL IS NOT AN ARTICLE IMAGE EITHER -- the same (0,1,1)
   trap, one section further down the page. `.post-card-image img` (line ~701)
   is also (0,1,1), so it TIES with `.post-single img` and loses on source
   order alone; `height: 100%` survives only because the broad rule never
   declares a height. What does land is `margin: 1.5rem 0`, which pushes the
   thumbnail down inside a box that is `aspect-ratio: 300 / 200` with
   `overflow: hidden` -- so the card clips the bottom of the image and exposes
   a 1.5rem band of the .thumb-ph placeholder along the TOP, which reads as a
   coloured stripe above every related thumbnail (green on a Java post, since
   --ph-hue is hashed from the first category). Reported on
   /today/voip-vpn-builders/.

   Scoped to the image inside the card link rather than re-scoping the broad
   rule to `.prose`: the hero and a handful of body images sit outside .prose,
   so narrowing it there would be a much wider change than the bug needs. */
.post-single .post-card-image img { margin: 0; }
.post-single h2, .post-single h3 { margin-top: 2rem; }
.post-single .post-categories { margin-top: 1.75rem; }

.related-posts {
  margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border);
}
.related-posts h3 { font-size: 1.1rem; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 1rem; }

/* Cards rather than a bulleted list, because what makes a reader take the next
   article is the picture and the date as much as the title -- the same reason
   every listing on the site is a grid of partials/post-card.html and not a
   list of links. It IS that partial: the strip only supplies the grid and a
   smaller type scale, so a related card cannot drift from a card anywhere else.

   THREE across the article column (~778px inside the sidebar layout), which
   puts a card at ~248px. Four fitted, but at ~178px the card's meta line wraps
   onto a second row -- see posts/single.html for why three is fixed rather than
   derived from how many related posts there are.

   The formula is the gallery shortcode's: each cell is at least its exact
   third, so the grid never opens a fourth column, while the 165px floor drops
   it to two and then one on a narrow screen instead of squashing them.

   auto-FILL, not the gallery's auto-fit, and that is the whole reason a lone
   related post is not a hero. auto-fit COLLAPSES a track with nothing in it, so
   the single card on the 7 posts that list one related article would stretch to
   the full 778px with a 519px-tall thumbnail. The gallery can use auto-fit
   safely because it sets its column count to the image count and so never has
   an empty track; here the columns are fixed and the cards are not. */
.related-posts__grid {
  --related-gap: 1rem;
  gap: var(--related-gap);
  grid-template-columns: repeat(auto-fill, minmax(max(165px,
      calc((100% - 2 * var(--related-gap)) / 3)), 1fr));
}
/* The card, one step down. Class-on-class so each rule outranks the base
   .post-card-* single-class rules above rather than tying with them and
   depending on source order for something this visible. */
.related-posts__grid .post-card { gap: .45rem; }
.related-posts__grid .post-card-title { font-size: .95rem; }
.related-posts__grid .post-card-meta { font-size: .75rem; gap: .35rem; }
.related-posts__grid .post-card-categories a { font-size: .66rem; padding: .18rem .5rem; }

/* giscus comments. The widget is an iframe that giscus sizes itself, so there is
   nothing to style inside it -- only the section that holds it, matching
   .related-posts above. The heading is ours (the iframe has none) and doubles as
   the anchor target for links to the discussion. */
.giscus-section {
  margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border);
  scroll-margin-top: 1.5rem;
}
.giscus-section__title { font-size: 1.1rem; text-transform: uppercase; letter-spacing: .08em; }
.giscus-section .giscus, .giscus-section .giscus-frame { width: 100%; }

/* The WordPress comment archive, under the giscus widget (partials/legacy-comments.html).
   Rule off and heading match .giscus-section above, because they are two
   sections of one conversation and a second visual language would read as a
   second kind of thing.

   THE BODY IS NOT INSIDE .prose, so the rules that make running text readable
   there do not reach it. Only two are actually missing: the paragraph rhythm,
   and the LINK UNDERLINE -- which is not a style preference. --brand-secondary
   on --ink-soft is 1.2:1 and colour alone needs 3:1 (WCAG 1.4.1), exactly the
   measurement that put `.prose a { text-decoration: underline }` in this file.
   A comment is running text written by a stranger, full of links to their repo.
   Everything else (code, pre, blockquote) comes from the base rules near the
   top, so nothing is restated here. */
.legacy-comments {
  margin-top: 2.5rem; padding-top: 1.5rem; border-top: 1px solid var(--border);
  scroll-margin-top: 1.5rem;
}
.legacy-comments__title { font-size: 1.1rem; text-transform: uppercase; letter-spacing: .08em; }
.legacy-comments__note {
  font-size: .9rem; color: var(--ink-muted); max-width: 62ch; margin-top: .35rem;
}
.legacy-comments__list { list-style: none; padding: 0; margin: 1.5rem 0 0; }

/* Indent by nesting depth, as a custom property so one rule covers every level
   and the fallback is depth 0. data-depth is clamped in the template (see there
   for why not math.Min). The step is small on purpose: at 3 levels a 2rem step
   would leave the deepest reply in a column half the width of the first. */
.legacy-comment { --legacy-indent: 0rem; margin-top: 1.25rem; }
.legacy-comment[data-depth="1"] { --legacy-indent: 1.25rem; }
.legacy-comment[data-depth="2"] { --legacy-indent: 2.5rem; }
.legacy-comment[data-depth="3"] { --legacy-indent: 3.75rem; }
.legacy-comment > article {
  margin-left: var(--legacy-indent);
  padding: .9rem 1.1rem;
  background: var(--surface-sunken);
  border-radius: var(--radius);
  /* The one cue that a reply is a reply and not just an indented card -- the
     indent alone is ambiguous once a body wraps. */
  border-left: 3px solid transparent;
}
.legacy-comment:not([data-depth="0"]) > article { border-left-color: var(--brand-accent); }

.legacy-comment__meta {
  display: flex; flex-wrap: wrap; gap: .25rem .6rem; align-items: baseline;
  margin-bottom: .4rem;
}
.legacy-comment__author { font-family: var(--font-display); font-weight: 600; }
.legacy-comment__date { font-size: .85rem; color: var(--ink-muted); }

.legacy-comment__body > *:first-child { margin-top: 0; }
.legacy-comment__body > *:last-child { margin-bottom: 0; }
.legacy-comment__body p { margin: .6rem 0; }
.legacy-comment__body a { text-decoration: underline; text-underline-offset: 2px; }
/* A commenter pasting a 90-character stack trace line must not widen the page
   (1.4.10); a bare URL as link text is the common case here. */
.legacy-comment__body { overflow-wrap: break-word; }

/* On a phone the indent competes with the text for a 390px viewport, so the
   nesting collapses to the border alone -- which is the cue that carries the
   meaning anyway. */
@media (max-width: 34rem) {
  .legacy-comment[data-depth] { --legacy-indent: 0rem; }
}

/* -------------------------------------------------------------- authors -- */

.author-profile {
  display: grid; grid-template-columns: 96px minmax(0, 1fr);
  gap: 1.25rem 1.5rem; align-items: start;
  padding-bottom: 1.75rem; margin-bottom: 2rem;
  border-bottom: 1px solid var(--border);
}
.author-profile h1 { grid-column: 2; margin: 0 0 .35rem; font-size: clamp(1.6rem, 1.2rem + 1.2vw, 2.2rem); }
.author-profile .author-bio, .author-profile .author-social { grid-column: 2; }
.author-avatar { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; grid-row: 1 / span 3; }
.author-bio { margin: 0 0 .8rem; color: var(--ink-soft); }
.author-social { display: flex; flex-wrap: wrap; gap: .4rem; list-style: none; margin: 0; padding: 0; }
.author-social a {
  display: inline-block; font-size: .85rem; font-weight: 600;
  padding: .25rem .7rem; border: 1px solid var(--border-strong); border-radius: 999px;
  color: var(--ink-soft);
}
.author-social a:hover { border-color: var(--brand-accent); color: var(--brand-accent-strong); text-decoration: none; }

.author-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 1.25rem; }
.author-card {
  display: grid; justify-items: center; gap: .6rem; text-align: center;
  padding: 1.25rem .75rem;
  background: var(--surface-sunken); border: 1px solid var(--border); border-radius: var(--radius);
  color: var(--ink); font-family: var(--font-display); font-weight: 600; font-size: .95rem;
}
.author-card:hover { border-color: var(--brand-accent); text-decoration: none; }
.author-card img { width: 72px; height: 72px; border-radius: 50%; object-fit: cover; }
/* The tile's article count and latest year (authors/list.html). Body font at
   normal weight, because .author-card sets --font-display at 600 for the NAME
   and a sub-line in the same face reads as a second name.
   --ink-muted measures 4.61 on --surface-sunken in light and 5.35 in dark, so
   it clears the 4.5:1 WCAG 1.4.3 asks of small text in both schemes.
   The negative margin is against .author-card's own `gap: .6rem`, which is the
   right distance between the avatar and the name and far too much between the
   name and its own sub-line -- a single grid `gap` cannot differ per row, and
   the alternative is a wrapper element around the two text lines. */
.author-card__meta {
  margin-top: -.3rem;
  font-family: var(--font-body); font-weight: 400; font-size: .8rem;
  color: var(--ink-muted);
}

/* Featured authors: the monthly editorial spotlight (params.featuredAuthors).
   Two shapes, one source -- a two-up band at the top of /today/author/
   (partials/featured-authors-band.html) and a sidebar widget on the home page
   (partials/featured-authors-widget.html). */

.featured-authors { margin-bottom: 2.25rem; }
.featured-author-grid {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.25rem;
}
/* Layout only: the ground, the border and the radius come from
   .highlight-panel, which these cards carry in the markup. They used to be
   --surface-sunken with a plain --border, i.e. indistinguishable from any other
   sunken box on the page -- the editorial spotlight looked like furniture. */
.featured-author {
  display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 1.25rem;
  align-items: start;
  padding: 1.4rem 1.5rem;
}
.featured-author__portrait { display: block; }
/* Column + auto margin on the latest-article box so the two cards line their
   boxes up at the bottom however long the bios above them run. */
.featured-author__body { display: flex; flex-direction: column; min-height: 100%; }
.featured-author__name { margin: 0; font-size: 1.25rem; }
.featured-author__name a { color: var(--ink); }
.featured-author__name a:hover { color: var(--brand-accent-strong); }
.featured-author__count { margin: .15rem 0 .7rem; font-size: .88rem; }
.featured-author__count a { color: var(--ink-muted); }
.featured-author .author-bio { font-size: .95rem; }
.featured-author .author-social { margin-top: .9rem; margin-bottom: 1rem; }
/* Latest article: a whole-block link, so the title reads as the target and the
   label/date are supporting text rather than three separate link targets. */
.featured-author__latest {
  display: grid; gap: .2rem; margin-top: auto; padding: .8rem .95rem;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-sm);
  color: var(--ink);
}
.featured-author__latest:hover { border-color: var(--brand-accent); text-decoration: none; }
.featured-author__latest:hover strong { color: var(--brand-accent-strong); }
.featured-author__latest-label {
  font-size: .72rem; text-transform: uppercase; letter-spacing: .11em; color: var(--ink-muted);
}
.featured-author__latest strong { font-family: var(--font-display); font-size: .98rem; line-height: 1.35; }
.featured-author__latest time { font-size: .82rem; color: var(--ink-muted); }

@media (max-width: 560px) {
  .featured-author { grid-template-columns: minmax(0, 1fr); }
}

/* Sidebar widget (home page). Same content, one column, smaller type. */
.featured-side-list { display: grid; gap: 1.1rem; }
.featured-side + .featured-side { padding-top: 1.1rem; border-top: 1px solid var(--border); }
.featured-side__head { display: flex; align-items: center; gap: .7rem; color: var(--ink); }
.featured-side__head img { width: 44px; height: 44px; border-radius: 50%; object-fit: cover; }
.featured-side__head strong { font-family: var(--font-display); font-size: .95rem; }
.featured-side__head:hover { text-decoration: none; }
.featured-side__head:hover strong { color: var(--brand-accent-strong); }
.featured-side__latest {
  display: block; margin-top: .55rem; font-size: .9rem; line-height: 1.35; color: var(--ink-soft);
}
.featured-side__latest:hover { color: var(--brand-accent-strong); }
.featured-side__label {
  display: block; font-size: .7rem; text-transform: uppercase; letter-spacing: .11em;
  color: var(--ink-muted);
}
/* The shared social pills are sized for a full-width column; shrink them so
   four of them still fit the aside without wrapping to four lines. The two
   layout declarations are not cosmetic: `.sidebar ul`/`.sidebar li a` are more
   specific than `.author-social`/`.author-social a`, so inside the aside the
   pill row would otherwise inherit the widget-list treatment (grid + block
   links) and stack as four full-width bars. */
.featured-side__social { margin-top: .55rem; }
.featured-side__social .author-social { display: flex; flex-wrap: wrap; gap: .3rem; }
.featured-side__social .author-social a {
  display: inline-block; font-size: .74rem; padding: .12rem .5rem;
}

/* ------------------------------------------------------- events calendar -- */
/* /calendar/ (themes/foojay/layouts/events/single.html). Two views of one set
   of events: the server-rendered agenda, and the month grid the script builds.
   Each JUG's dot colour arrives as --cal-dot on the element itself, derived
   from the group slug in the template -- there are no per-group rules here. */

.calendar {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 300px;
  gap: clamp(1.5rem, 3vw, 2.5rem);
  align-items: start;
}
/* Left rail on the live calendar, right rail here: the site's other wide pages
   all put their secondary column on the right, and the grid should start at
   the same x as every other page's content. */
.calendar__aside { order: 2; display: grid; gap: 1.5rem; align-content: start; }
.calendar__main { order: 1; min-width: 0; }

.cal-intro { margin-bottom: 1.75rem; }
.cal-stats { margin: 0; color: var(--ink-soft); font-size: .95rem; }
.cal-stats strong { color: var(--ink); font-variant-numeric: tabular-nums; }

/* Two ways onto the calendar, so two buttons: an event by a pull request here,
   a JUG upstream in the World Wide JUGs directory. Wrapped so they sit together
   rather than inheriting the aside's 1.5rem section gap. */
.cal-ctas { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; }
/* Side by side in a 300px rail, so they need to be narrower than a default
   button and their labels centred -- .btn is an inline-flex box, so text-align
   alone would leave both labels hard against the left edge. */
.cal-cta {
  justify-self: stretch; justify-content: center; text-align: center;
  padding: .5rem .6rem; font-size: .85rem;
}

/* The group legend doubles as the key to the grid's dots and as the list of
   which JUGs actually have something coming up. */
.cal-legend__head {
  margin: 0 0 .75rem; font-size: .82rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .1em; color: var(--ink-muted);
}
/* Capped and scrollable: both lists grow with the data -- 90 JUGs publish a
   calendar and a busy month puts dozens of them here -- and an aside that runs
   longer than the calendar it explains pushes the sponsor widgets and the
   source note off the bottom of the page.

   THE CAP IS FIVE ROWS, and 17rem is that arithmetic rather than a round
   number: a row is a .92rem name line plus a .85em country/date line at the
   body's 1.65 line-height (24.3px + 20.6px = 44.9px), and four .55rem gaps
   between five of them comes to 260px / 16.24rem. The extra ~0.8rem is
   deliberate -- it leaves a sliver of the sixth row showing under the fade, and
   a row visibly cut in half says "there is more" far better than a clean edge
   does. Rows without a country line are shorter, so a list of those shows more
   than five; the cap is a maximum, not a count.

   It used to be 27rem, which was most of the calendar's height beside it.

   THE CUT EDGE HAS TO LOOK CUT. macOS (and mobile) hide the scrollbar until
   something scrolls, so a list capped mid-row looked like a list that simply
   ended, and the only way to discover the other thirty groups was to happen to
   hover over them. `scrollbar-width: thin` does not fix that -- it styles a
   scrollbar that is not being painted yet.

   The fix is a pair of scroll shadows, and they are worth the four gradients
   because they are SELF-CANCELLING: each edge is a shadow painted on the box
   (`scroll`) with an opaque cover painted on the content (`local`) directly
   over it. At the top of the list the cover sits on the shadow and hides it;
   scroll down and the cover travels away with the content, uncovering it. So
   the shadow appears exactly when there is more in that direction and vanishes
   at the end of the list -- no JavaScript, no scroll listener, and no false
   "there is more" at the bottom of a short list.

   The covers must be OPAQUE and must match what is behind the list, which is
   the page itself (.cal-legend sets no background of its own) -- hence
   --page-bg rather than --surface. Both are tokens, so this follows the theme.
   The shadows are neutral black/white at low alpha rather than an ink token
   for the same reason: they read as depth in both schemes. */
.cal-legend__scroll {
  max-height: 17rem; overflow-y: auto; overscroll-behavior: contain;
  scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
  /* Standing room for the scrollbar, so it never sits on the event count and
     the rows don't shift sideways the moment the list gets long enough. */
  padding-right: .25rem;

  background:
    linear-gradient(var(--page-bg) 30%, transparent) top / 100% 1.75rem no-repeat,
    linear-gradient(transparent, var(--page-bg) 70%) bottom / 100% 1.75rem no-repeat,
    radial-gradient(farthest-side at 50% 0, rgb(0 0 0 / .17), transparent) top / 100% .8rem no-repeat,
    radial-gradient(farthest-side at 50% 100%, rgb(0 0 0 / .17), transparent) bottom / 100% .8rem no-repeat;
  background-attachment: local, local, scroll, scroll;
}
/* A black shadow is invisible on a near-black ground; invert it rather than
   dropping the affordance in the scheme where the cut edge is hardest to see. */
:root[data-theme="dark"] .cal-legend__scroll,
:root:not([data-theme="light"]) .cal-legend__scroll {
  background:
    linear-gradient(var(--page-bg) 30%, transparent) top / 100% 1.75rem no-repeat,
    linear-gradient(transparent, var(--page-bg) 70%) bottom / 100% 1.75rem no-repeat,
    radial-gradient(farthest-side at 50% 0, rgb(255 255 255 / .16), transparent) top / 100% .8rem no-repeat,
    radial-gradient(farthest-side at 50% 100%, rgb(255 255 255 / .16), transparent) bottom / 100% .8rem no-repeat;
  background-attachment: local, local, scroll, scroll;
}
/* Drawn INSIDE the box: an outline-offset on a scroll container is clipped on
   the axis it scrolls, so the ring's bottom edge would not be painted. Same
   rule as .table-scroll. */
.cal-legend__scroll:focus-visible { outline-offset: -3px; }

.cal-legend__list {
  list-style: none; margin: 0; padding: 0; display: grid; gap: .55rem;
}
/* The list's own length, beside its heading. The page intro already counts
   groups, but a reader looking at a list that stops mid-row is not looking at
   the intro -- this is what tells them eight of forty-three. */
.cal-legend__total {
  display: inline-block; margin-left: .35rem; padding: .05rem .4rem;
  border-radius: 999px; background: var(--surface-sunken);
  border: 1px solid var(--border);
  color: var(--ink-muted); font-variant-numeric: tabular-nums;
  letter-spacing: 0;
}
.cal-legend__item { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: .55rem; align-items: baseline; font-size: .92rem; }
.cal-legend__name a { color: var(--ink); font-weight: 600; }
.cal-legend__name a:hover { color: var(--brand-accent-strong); }
.cal-legend__country { display: block; color: var(--ink-muted); font-size: .85em; }
.cal-legend__count {
  color: var(--ink-muted); font-variant-numeric: tabular-nums; font-size: .85em; font-weight: 700;
}

.cal-dot {
  width: .55rem; height: .55rem; border-radius: 50%;
  background: var(--cal-dot, var(--brand-secondary));
  display: inline-block; flex: none;
}

/* Under the calendar and across both columns, not in the rail: it is prose,
   and prose in a 300px column is a stack of four-word lines that crowds out
   the legends the rail is actually for. */
.cal-source {
  margin: 1.75rem 0 0; padding-top: 1.25rem; border-top: 1px solid var(--border);
  color: var(--ink-muted); font-size: .85rem; line-height: 1.55;
}
.cal-source__synced { display: inline-block; margin-top: .35rem; }
.cal-source__errors { display: inline-block; margin-top: .35rem; }
.cal-empty { color: var(--ink-muted); }

/* Toolbar ------------------------------------------------------- */

/* Sticky under the site header: in list view the arrows scroll the agenda, and
   a 40-event agenda is several screens long -- a toolbar that scrolled away
   would let you step forward exactly once. */
.cal-toolbar {
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: 1rem; margin-bottom: 1.25rem;
  position: sticky; top: var(--header-h); z-index: 5;
  background: var(--surface); padding: .75rem 0;
}
.cal-nav { display: flex; align-items: center; gap: .5rem; }
.cal-nav__btn {
  width: 2rem; height: 2rem; border-radius: 50%; cursor: pointer;
  border: 1px solid var(--border-strong); background: var(--surface); color: var(--ink);
  font-size: 1.1rem; line-height: 1; display: grid; place-items: center;
}
.cal-nav__btn:hover:not(:disabled) { border-color: var(--ink); }
/* In list view the arrows step between months that HAVE events, so there is a
   real first and last -- an arrow that would go nowhere is dimmed rather than
   silently doing nothing, which is how it read before. */
.cal-nav__btn:disabled { opacity: .35; cursor: default; }
.cal-nav__label {
  font-family: var(--font-display); font-size: 1.15rem; font-weight: 700; color: var(--ink);
  min-width: 10.5ch; text-align: center;
}
.cal-nav__today { margin-left: .35rem; cursor: pointer; }

.cal-switch {
  display: inline-flex; border: 1px solid var(--border-strong);
  border-radius: 999px; overflow: hidden; background: var(--surface);
}
.cal-switch__btn {
  border: 0; background: transparent; cursor: pointer;
  padding: .35rem 1rem; font-family: var(--font-display); font-weight: 600;
  font-size: .88rem; color: var(--ink-soft);
}
.cal-switch__btn[aria-pressed="true"] { background: var(--brand-accent); color: var(--on-accent); }

/* Month grid ---------------------------------------------------- */

/* Scrolls inside itself rather than pushing the page sideways: a month grid
   has a floor below which seven columns stop being legible. */
.cal-grid { overflow-x: auto; }
.cal-table {
  width: 100%; min-width: 34rem; border-collapse: collapse; table-layout: fixed;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  overflow: hidden;
}
.cal-table th {
  text-align: left; padding: .6rem .7rem; font-size: .74rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .08em; color: var(--ink-muted);
  border-bottom: 1px solid var(--border);
}
.cal-table th abbr { text-decoration: none; }
.cal-cell {
  vertical-align: top; height: 7.5rem; padding: .4rem .45rem;
  border: 1px solid var(--border); border-width: 0 1px 1px 0;
}
.cal-cell:last-child { border-right: 0; }
.cal-table tbody tr:last-child .cal-cell { border-bottom: 0; }
.cal-cell.is-outside { background: var(--surface-sunken); }
.cal-cell.is-outside .cal-cell__day { opacity: .45; }
.cal-cell__day {
  display: inline-grid; place-items: center; min-width: 1.6rem; height: 1.6rem;
  font-size: .82rem; font-weight: 600; color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}
.cal-cell.is-today .cal-cell__day {
  background: var(--brand-accent); color: var(--on-accent); border-radius: 50%;
}
.cal-cell__events { list-style: none; margin: .2rem 0 0; padding: 0; display: grid; gap: .2rem; }

/* An event chip: a real link to the event, with the click intercepted to open
   the detail dialog (see the layout). */
.cal-chip {
  display: block; padding: .3rem .45rem; border-radius: var(--radius-sm);
  background: var(--surface-sunken); border-left: 3px solid var(--cal-dot, var(--brand-secondary));
  font-size: .78rem; line-height: 1.25; color: var(--ink);
}
.cal-chip:hover { background: var(--brand-accent-soft); color: var(--ink); text-decoration: none; }
.cal-chip__time { display: block; color: var(--ink-muted); font-variant-numeric: tabular-nums; font-size: .95em; }
/* A hand-added event (a conference, a community day) reads as a band across
   the days it runs, so it needs to be distinguishable from a single evening's
   meetup at a glance. Tinted with its OWN colour rather than one shared
   "conference" colour: --cal-dot is already on the element, so the band and
   the legend dot match without a second variable. The flat declaration above
   it is the fallback for a browser without color-mix. */
.cal-chip--event {
  background: var(--surface-sunken);
  background: color-mix(in srgb, var(--cal-dot) 14%, var(--surface-sunken));
  border-left-width: 4px;
}
.cal-chip--event:hover { background: color-mix(in srgb, var(--cal-dot) 26%, var(--surface-sunken)); }
/* Two lines of title, then ellipsis: a cell is ~140px wide, and one line cuts
   most meetup titles off before the topic. The full title is in the chip's
   tooltip, the detail dialog and the list view. */
.cal-chip__title {
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden; font-weight: 600;
}
/* Where it is, in the cell itself -- the city, or the JUG's country when the
   feed gave no venue. One line: a cell is ~140px wide, and the full address is
   in the dialog and the list. */
.cal-chip__place {
  display: block; color: var(--ink-muted); font-size: .95em;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* Agenda (also the no-JavaScript view) -------------------------- */

/* Room for the sticky header AND the sticky toolbar above it, so an arrow
   click lands the month's heading in view rather than underneath them. */
.cal-month { scroll-margin-top: calc(var(--header-h) + 5rem); }
.cal-month + .cal-month { margin-top: 2rem; }
.cal-month__head {
  margin: 0 0 .85rem; font-size: 1.05rem; text-transform: uppercase; letter-spacing: .06em;
  padding-bottom: .5rem; border-bottom: 1px solid var(--border);
}
.cal-month__list { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; }
.cal-row {
  display: grid; grid-template-columns: 3.4rem minmax(0, 1fr); gap: 1rem; align-items: center;
  padding: .8rem .95rem; background: var(--surface);
  border: 1px solid var(--border); border-radius: var(--radius);
}
.cal-row__date { text-align: center; line-height: 1.1; }
.cal-row__day {
  display: block; font-family: var(--font-display); font-size: 1.45rem; font-weight: 800;
  color: var(--ink); font-variant-numeric: tabular-nums;
}
.cal-row__weekday { font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-muted); }
.cal-row__title { font-family: var(--font-display); font-weight: 700; color: var(--ink); }
.cal-row__title:hover { color: var(--brand-accent-strong); }
.cal-row__meta {
  margin: .25rem 0 0; display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
  color: var(--ink-muted); font-size: .88rem;
}
.cal-row__group { color: var(--ink-soft); font-weight: 600; }
/* In the agenda a meetup's "group" is the JUG's name, which is self-evidently
   a name; a hand-added event's is its TYPE ("Conference", "Workshop"), which
   is a label and reads as one. */
/* Outlined rather than filled: the hue is derived from a hash, so it lands
   anywhere on the wheel, and light text on a hashed background is a contrast
   lottery. The border carries the colour, the text stays legible. */
.cal-row--event .cal-row__group {
  padding: .05rem .45rem; border-radius: 999px;
  border: 1px solid var(--cal-dot, var(--border)); color: var(--ink-soft);
  font-size: .78em; text-transform: uppercase; letter-spacing: .06em;
}
.cal-row__time { font-variant-numeric: tabular-nums; }
/* The UTC offset beside a time, rendered only on a day whose events do not all
   share one -- see partials/calendar-events.html. It is a qualifier on the time,
   not a second fact, so it sits back a step rather than competing with it. */
.cal-row__zone { font-size: .85em; color: var(--ink-muted); font-variant-numeric: normal; }
.cal-row__sep { opacity: .5; }
/* An online event has no venue to name, so say so rather than leaving the
   place silently blank. */
.cal-row__online { color: var(--brand-secondary); font-weight: 600; }

/* Event detail dialog ------------------------------------------- */

.cal-dialog {
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); color: var(--ink-soft);
  padding: 1.6rem 1.75rem; max-width: 26rem; width: calc(100% - 2rem);
  box-shadow: var(--shadow-md);
}
/* Don't give the dialog a `position` of its own: the modal is centred by the
   browser's own `position: fixed; inset: 0; margin: auto`, and overriding it
   drops the card wherever it happens to sit in the flow. The close button is
   absolute against that fixed box. */
.cal-dialog::backdrop { background: rgba(13, 29, 47, .55); }
.cal-dialog__close {
  position: absolute; top: .5rem; right: .6rem; border: 0; background: none; cursor: pointer;
  font-size: 1.4rem; line-height: 1; color: var(--ink-muted);
}
.cal-dialog__close:hover { color: var(--ink); }
.cal-dialog__group {
  margin: 0 0 .5rem; display: flex; align-items: center; gap: .45rem;
  font-size: .82rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
  color: var(--ink-muted);
}
.cal-dialog__group::before {
  content: ""; width: .55rem; height: .55rem; border-radius: 50%; flex: none;
  background: var(--cal-dot, var(--brand-secondary));
}
.cal-dialog__title { margin: 0 0 .6rem; font-size: 1.25rem; line-height: 1.25; }
.cal-dialog__when { margin: 0; color: var(--brand-accent-strong); font-weight: 600; }
.cal-dialog__where { margin: .3rem 0 0; color: var(--ink-muted); font-size: .92rem; }
.cal-dialog__link { margin-top: 1.1rem; }

@media (max-width: 900px) {
  .calendar { grid-template-columns: 1fr; }
  /* The legend and the "add your JUG" call belong under the calendar once they
     are no longer a rail beside it. */
  .calendar__aside { order: 2; }
  .cal-cell { height: auto; min-height: 4.5rem; }
  .cal-chip__time { display: none; }
}
@media (max-width: 560px) {
  .cal-toolbar { justify-content: center; }
  .cal-chip { padding: .2rem .3rem; font-size: .7rem; }
  .cal-cell { padding: .25rem .2rem; }
}

/* ----------------------------------------------------------- pagination -- */

/* Markup comes from partials/pagination.html, not Hugo's internal template --
   see that file for the three things this could not fix from the outside. Every
   cell (arrow, number, current, disabled) is ONE .pagination__btn, so they share
   a box by construction. The old rules styled `.page-item a` AND
   `.page-item span`, which double-boxed the arrows (their glyph sits in a nested
   span) and highlighted `.active span` -- an element Hugo stopped emitting. */
.pagination { margin: 2rem 0 0; }
.pagination--top { margin: 0 0 1.75rem; }
/* Fixed-width row with the arrows at its outer edges and the numbers centred
   between them, so « and » land on the same x-position on every page. The
   number of cells in the middle changes as you page (the "1"/last buttons and
   the ellipses come and go, and 1-digit numbers become 3-digit) -- in one flat
   flex row that dragged the two most-clicked controls sideways under the cursor.
   `flex: 1` on the list is what holds the arrows out at the edges. */
.pagination__row {
  display: flex; align-items: center; gap: .35rem;
  width: min(34rem, 100%); margin-inline: auto;
}
.pagination__list {
  flex: 1;
  display: flex; gap: .35rem; flex-wrap: wrap; justify-content: center;
  list-style: none; margin: 0; padding: 0;
}
/* On a phone there is no width left to hold the arrows apart -- keeping the
   three-part row there overflowed the viewport and stranded « on a line of its
   own. So below the fixed width it becomes two stacked rows: the numbers, then
   « and » paired underneath, which is a better thumb target than one at each
   screen edge anyway. `order` does the stacking, so the DOM keeps
   prev -> numbers -> next for a screen reader and for tab order. */
@media (max-width: 34rem) {
  .pagination__row { flex-wrap: wrap; justify-content: center; row-gap: .5rem; }
  .pagination__list { flex: 1 1 100%; order: -1; }
  .pagination__btn { min-width: 2.25rem; height: 2.25rem; padding: 0 .45rem; }
  .pagination__gap { min-width: 1.1rem; height: 2.25rem; }
  /* Five numbers plus both numbered ends and two ellipses is nine cells, which
     overflows a 390px screen and silently clipped the last-page button -- the
     one carrying the total. Drop to current +/-1 instead; the ends stay. */
  .pagination__cell--far { display: none; }
}
/* inline-flex + a fixed height, rather than padding: a glyph and a 3-digit
   number have different intrinsic box sizes, and padding alone let them differ. */
.pagination__btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 2.5rem; height: 2.5rem; padding: 0 .6rem;
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  color: var(--ink); font-weight: 600; font-size: .92rem; line-height: 1;
  background: var(--surface);
}
.pagination__btn > span { display: block; line-height: 1; }
a.pagination__btn:hover { border-color: var(--ink); text-decoration: none; }
.pagination__btn.is-current {
  background: var(--brand-accent); border-color: var(--brand-accent);
  color: var(--on-accent); cursor: default;
}
.pagination__btn.is-disabled { color: var(--ink-muted); opacity: .45; background: transparent; }
.pagination__gap {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.5rem; height: 2.5rem; color: var(--ink-muted);
}
/* No .pagination__count: the first/last buttons are numbered, so the row itself
   says how many pages there are (see partials/pagination.html). */

/* --------------------------------------------------------------- footer -- */

.site-footer {
  margin-top: 4rem;
  background: var(--footer-bg);
  color: var(--footer-fg);
  padding: clamp(2.5rem, 5vw, 4rem) 0 1.5rem;
  border-top: 0;
}
.site-footer a { color: var(--footer-link); }
.site-footer a:hover { color: var(--brand-accent); text-decoration: none; }

.footer-cols {
  display: grid;
  grid-template-columns: minmax(220px, 1.2fr) repeat(auto-fit, minmax(170px, 1fr));
  gap: 2rem clamp(1.5rem, 3vw, 3rem);
  max-width: var(--max-width);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
/* The footer grid's tracks have hard minimums (220px + 170px + a 24px gap =
   414px), so below roughly 446px they no longer fit the content box and the last
   column overflowed it -- clipped rather than scrollable, so it read as a
   mis-centred page rather than as an overflow. Two flexible columns with the
   brand across the top fits a 390px phone (2 x 140 + 24 = 304) and keeps the
   footer from becoming five stacked blocks. */
@media (max-width: 560px) {
  .footer-cols { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
  .footer-brand { grid-column: 1 / -1; }
}
.footer-brand .logo { color: var(--footer-head); margin-bottom: .75rem; }
.footer-brand .logo:hover { color: var(--footer-head); }
.footer-brand .logo__tag { display: inline-block; color: var(--footer-muted); border-color: var(--footer-border); }
.footer-brand p { font-size: .95rem; color: var(--footer-muted); max-width: 32ch; }

.site-footer h2 {
  color: var(--footer-head); font-size: .8rem; text-transform: uppercase; letter-spacing: .12em;
  margin: 0 0 .9rem;
}
.footer-col ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .45rem; }
.footer-col li a { font-size: .95rem; }

.social-links { display: flex; flex-wrap: wrap; gap: .5rem; }
.social-links a {
  display: inline-block; font-size: .85rem; font-weight: 600;
  padding: .3rem .8rem; border: 1px solid var(--footer-border); border-radius: 999px;
}
.social-links a:hover { border-color: var(--brand-accent); }

.footer-legal {
  display: flex; flex-wrap: wrap; gap: .4rem 1.25rem; align-items: center;
  max-width: var(--max-width);
  margin: 2.5rem auto 0;
  padding: 1.25rem var(--gutter) 0;
  border-top: 1px solid var(--footer-border);
  color: var(--footer-muted); font-size: .87rem;
}

/* ----------------------------------------------------------- responsive -- */

@media (min-width: 1120px) {
  .logo__tag { display: inline-block; }
}

@media (max-width: 1080px) {
  .header-actions .btn { display: none; }
  .site-search[data-open="true"] input { width: 160px; }
}

@media (max-width: 960px) {
  .layout-with-sidebar { grid-template-columns: minmax(0, 1fr); }
  .sidebar { position: static; }
  .post-card--lead { grid-template-columns: minmax(0, 1fr); }
}

/* Mobile drawer nav ------------------------------------------- */
@media (max-width: 900px) {
  .nav-toggle { display: inline-flex; }

  .primary-nav {
    position: fixed; z-index: 95;
    inset: var(--header-h) 0 0 auto;
    width: min(400px, 88vw);
    margin-left: 0;
    background: var(--surface);
    border-left: 1px solid var(--border);
    padding: 1rem 1rem 3rem;
    overflow-y: auto; overscroll-behavior: contain;
    transform: translateX(100%);
    /* VISIBILITY, not just the transform. The drawer is off-screen when closed
       but still rendered, so every link in it stayed in the tab order: on a
       phone, tabbing off the menu button walked a keyboard user through a nav
       they cannot see, with the focus ring nowhere on screen (WCAG 2.4.3, and
       2.4.11 -- a focused element may not be hidden). visibility:hidden takes
       the whole subtree out of the tab order and is still animatable, unlike
       display:none. Delayed on the way out so the slide still plays, immediate
       on the way in so a click can focus the first link. */
    visibility: hidden;
    transition: transform .25s var(--ease), visibility 0s linear .25s;
  }
  body[data-nav-open="true"] .primary-nav {
    transform: translateX(0);
    visibility: visible;
    transition: transform .25s var(--ease), visibility 0s;
  }

  .primary-nav__list { flex-direction: column; align-items: stretch; gap: .1rem; }

  .nav-item,
  .nav-item:has(.nav-panel__inner--cols-2),
  .nav-item:has(.nav-panel__inner--cols-3) { position: static; }
  .nav-item:has(.nav-panel__inner--cols-2) > .nav-panel,
  .nav-item:has(.nav-panel__inner--cols-3) > .nav-panel { left: auto; max-width: none; }
  .nav-link { width: 100%; justify-content: space-between; font-size: 1.05rem; padding: .75rem .6rem; }
  .nav-link--parent::after { margin: 0; }

  .nav-panel {
    /* `inherit`, not `visible`: the closed drawer hides itself with
       visibility, and a descendant that hard-sets `visible` climbs back out of
       that and into the tab order. Everything inside the drawer follows the
       drawer -- see the input rule below, which is where this actually bit. */
    position: static; transform: none; opacity: 1; visibility: inherit; pointer-events: auto;
    border: 0; box-shadow: none; border-radius: 0;
    padding: 0 0 .5rem .6rem;
    margin-left: .35rem;
    border-left: 2px solid var(--border);
    display: none;
  }
  .nav-item[data-open="true"] > .nav-panel { display: block; transform: none; }
  .nav-item:focus-within > .nav-panel { transform: none; }
  .nav-item:last-child > .nav-panel { transform: none; }

  .nav-panel__inner,
  .nav-panel__inner--cols-1,
  .nav-panel__inner--cols-2,
  .nav-panel__inner--cols-3 { grid-template-columns: minmax(0, 1fr); min-width: 0; gap: .1rem; }

  .nav-group + .nav-group { border-top: 1px solid var(--border); margin-top: .35rem; }

  /* desktop copy hidden; the drawer copy takes over */
  .site-header__inner > .header-actions { display: none; }
  .primary-nav .header-actions--mobile { display: flex; position: static; flex-direction: column; align-items: stretch; gap: .75rem; margin-top: 1.25rem; }
  .primary-nav .header-actions--mobile .btn { display: inline-flex; justify-content: center; }
  .primary-nav .header-actions--mobile .site-search { display: flex; }
  .primary-nav .header-actions--mobile .site-search label { flex: 1 1 auto; min-width: 0; }
  /* The drawer's search field is always open (there is room here, and no hover
     to discover a magnifier with), so it has to undo the collapsed-header rule
     -- but with `inherit` rather than `visible`, or it stays focusable while
     the drawer is closed and off-screen, which is exactly the bug the drawer's
     own visibility rule exists to fix. Measured: tabbing the header at 390px
     landed here, in a menu the reader cannot see. */
  .primary-nav .header-actions--mobile .site-search input {
    width: 100%; padding: .5rem .9rem; opacity: 1; visibility: inherit;
  }
}

@media (max-width: 560px) {
  .author-profile { grid-template-columns: minmax(0, 1fr); }
  .author-avatar { grid-row: auto; }
  .author-profile h1, .author-profile .author-bio, .author-profile .author-social { grid-column: 1; }
  .post-grid { grid-template-columns: minmax(0, 1fr); }
}

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

/* --------------------------------------------------- added components ---- */

.post-card-body { display: grid; gap: .55rem; align-content: center; }

/* A row, not a line of text: it carries the "view all" button and (on the home
   page and /ai/) the feed link beside it. */
.section-more {
  margin: 2rem 0 0; display: flex; align-items: center; flex-wrap: wrap; gap: 1rem;
}

.chip-row--lg { gap: .6rem; }
.chip-row--lg a { font-size: .85rem; padding: .45rem 1rem; }
.chip-count, .sidebar .count {
  display: inline-block; font-weight: 700; opacity: .55; font-size: .85em;
  font-variant-numeric: tabular-nums;
}

.post-categories--top { margin-bottom: .9rem; }
.reading-time { color: var(--ink-muted); }
/* The read counter. tabular-nums so a column of cards doesn't jitter as the
   digit widths change, nowrap so "11,459 views" never breaks across lines. */
.views {
  color: var(--ink-muted); font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* Placements outside a post: the page head, the author profile, a glossary
   card and the featured-author card. Posts and post cards get their spacing
   and separator dots from .post-meta / .post-card-meta instead. */
.page-head__views { margin-top: .6rem; font-size: .9rem; }
/* That margin assumes the count sits BELOW .page-head__meta, which is a flex
   row -- nested inside one it just pushes the count half a line down off the
   baseline, which is what /calendar/ shipped. Cheap guard, because the symptom
   is a small vertical offset that is easy to miss in review. */
.page-head__meta .page-head__views { margin-top: 0; }
/* THE FEED LINK (partials/feed-link.html) -- one look, wherever a feed is
   offered. Typographically it is the read count's sibling: same .9rem, same
   --ink-soft, so it sits on a meta line beside "52,391 views" without shouting.
   What separates it from a statistic is the border, the RSS mark and the
   pointer -- a bare orange word next to a bare grey word reads as two labels,
   not as a link and a number.

   Sized in `em` off its own font-size (icon included) rather than in rem, so
   the same partial drops onto a section footer next to a .btn without a second
   size rule. */
.feed-link {
  display: inline-flex; align-items: center; gap: .4rem;
  font-size: .85rem; font-weight: 600;
  line-height: 1.3; color: var(--ink-soft); white-space: nowrap;
  padding: .25rem .7rem .25rem .55rem;
  border: 1px solid var(--border-strong); border-radius: 999px;
}
/* Those five values are `.author-social a`'s, deliberately: the feed link is
   one pill in that row, so it has to BE that pill rather than a near-miss of
   it. What the row cannot supply is what the partial adds -- and
   `.author-social a` is 0-1-1, so it outranks `.feed-link` (0-1-0) and would
   win on `display: inline-block`, which drops the icon onto the text baseline.
   Restate only what actually collides.

   `line-height` collides the other way round: .feed-link's own 1.3 is what a
   meta line next to a read count wants, but a pill in this row is sized by the
   body's 1.65 and the RSS one came out ~5px shorter than the Bluesky one
   beside it. `inherit` takes the row's value rather than restating 1.65, so
   the two cannot drift apart again. */
.author-social a.feed-link { display: inline-flex; align-items: center; line-height: inherit; }
.author-social a.feed-link:hover, .author-social a.feed-link:focus-visible {
  border-color: var(--feed); color: var(--ink);
}
.feed-link:hover, .feed-link:focus-visible {
  color: var(--ink); border-color: var(--feed);
  background: var(--surface); text-decoration: none;
}
/* The mark keeps its own colour on hover: it is the thing a reader recognises
   from every other site, and --feed is the one token here that is not part of
   the brand palette for exactly that reason. */
.feed-link__icon { width: 1em; height: 1em; color: var(--feed); flex: 0 0 auto; }
/* The count and the feed link share ONE line -- .page-head__views holds either,
   or both, so it is a flex row rather than two stacked blocks. `flex-wrap` for
   a narrow screen, where the pill drops under the count instead of pushing the
   page head sideways. */
.page-head__views {
  display: flex; align-items: center; flex-wrap: wrap; gap: .8rem;
}
.author-profile .author-meta { grid-column: 2; margin: 0 0 .8rem; font-size: .9rem; }
.pedia-card .views { display: block; margin-top: .55rem; font-size: .82rem; }
.featured-author__count .views::before {
  content: "·"; margin-right: .45rem; color: var(--border-strong);
}
/* An author with no articles yet still gets a count, and then the separator
   would be leading a line it has nothing to separate. */
.featured-author__count .views:first-child::before { content: none; }
.post-single .post-meta > * + *::before {
  content: "·"; margin-right: .55rem; color: var(--border-strong);
}

/* Long-form body copy */

/* A LONG UNBREAKABLE TOKEN IN AN ARTICLE BODY HAS TO BREAK, because the body
   is 2000 imported WordPress posts and what is in them is not ours to choose.
   `.layout-with-sidebar` is `minmax(0, 1fr) 330px`, so an over-wide word does
   not stretch the column -- it spills out of it and runs UNDER the sidebar,
   which is what a 220-character query string did on
   /today/mongodb-search-server-improved/ (a URL in bold, no spaces to break
   at). `overflow-wrap` inherits, so this one declaration also covers a long
   token inside `<code>`, a table cell or a list item; `<pre>` is unaffected,
   since `white-space: pre` forbids wrapping outright and those scroll
   sideways in their own box instead. `break-word` rather than `anywhere`:
   both break only a word that cannot fit on a line of its own, but `anywhere`
   also feeds the break back into min-content sizing, which would let a table
   or a flex item quietly reflow around a URL. */
.prose { font-size: 1.09rem; overflow-wrap: break-word; }

/* A LINK IN RUNNING TEXT IS UNDERLINED, and this is not a style preference.
   `a { text-decoration: none }` up in the base leaves colour as the only thing
   separating a link from the sentence around it, and --brand-secondary against
   --ink-soft measures 1.2:1 (1.8 in the dark scheme) where WCAG 1.4.1 asks for
   3:1 before colour may carry that distinction alone. It is not fixable by
   picking a different blue either: a link colour that clears 3:1 against BOTH
   the body text and the page would have to be a colour foojay does not own.
   So the underline goes back on inside the article body only -- everywhere else
   on the site a link sits in a card, a nav or a list, where position and
   surrounding chrome already say it is one.

   Scoped OUT of the UI that happens to live inside .prose: /jugs/ and
   /java-champions/ render a filter, a map and a data table in there, the
   galleries wrap their images in a link to the full-size original, and both
   would otherwise sprout underlines under a face or a thumbnail. */
.prose a { text-decoration: underline; text-underline-offset: 2px; }
/* The exclusions have to OUTRANK the line above, which is why the underline is
   a bare `.prose a` (0,1,1) and not `.prose a:not(.champions-table a)...`: every
   :not() adds its argument's specificity, so the chained form measured 0,4,1 and
   beat every exclusion here -- 422 champion avatars kept their underline and
   nothing said why. Measured in the browser, not reasoned about. */
.prose .champions-table a,
.prose .jugs-table a,
.prose .cluster-map a,
.prose .leaflet-container a,
.prose .gallery a,
.prose .chip-row a,
.prose .list-filter a,
.prose a.btn,
.prose a.chip,
.prose a:has(> img) { text-decoration: none; }
/* Contain floated images (alignleft/alignright) so a trailing float can't
   escape the article and overlap the footer. */
.prose::after { content: ""; display: block; clear: both; }
/* ...but on layouts/_default/single.html the end matter lives INSIDE .prose,
   so that ::after only clears once the comments are already laid out and a
   trailing alignleft image runs straight through them -- /who-we-are/ ends on
   two floated logos, and they overlapped "Edit this page on GitHub" and the
   giscus box, squeezing that text into a two-character column. Each end-matter
   block clears for itself; the article's own ::after still handles the footer. */
.prose > .series-nav,
.prose > .page-source,
.prose > .giscus-section,
.prose > .legacy-comments { clear: both; }
.prose > * + * { margin-top: 1.1em; }
.prose h2 { margin-top: 2.25rem; }
.prose h3 { margin-top: 1.75rem; }
/* So a TOC-link jump (or any #heading link) doesn't leave the heading
   tucked under the sticky header. */
/* Anything the reader can JUMP to has to clear the sticky header, or the
   heading it lands on is drawn underneath it and the section looks like it
   starts at its second paragraph.

   The article's own headings are the common case. The three below are the end
   matter partials/toc.html links to from the "On this page" panel; they are
   <section>s outside .prose, so the rule above does not reach them and they
   each scrolled their own title out of sight. [id] rather than the bare class
   because only a section that is a target needs it -- an author callout gets
   its id only when it is the FIRST of a post's authors, which is the one the
   panel points at. A new end-matter section needs a selector here at the same
   moment it gets its id; there is nowhere else to notice. (#transcript needs
   nothing: it is an <h2> inside .prose.) */
.prose h2, .prose h3,
.author-callout[id], .related-posts[id], .giscus-section[id], .legacy-comments[id] {
  scroll-margin-top: calc(var(--header-h) + 1rem);
}
.prose ul, .prose ol { padding-left: 1.4rem; }
.prose li + li { margin-top: .35rem; }
.prose figure { margin: 1.75rem 0; }
.prose figcaption { font-size: .9rem; color: var(--ink-muted); margin-top: .5rem; }
.prose table { width: 100%; border-collapse: collapse; font-size: .96rem; }
.prose th, .prose td { text-align: left; padding: .6rem .75rem; border-bottom: 1px solid var(--border); }
.prose th { font-family: var(--font-display); color: var(--ink); }

/* --------------------------------------------------------------------------
   ASCIIDOC OUTPUT (.adoc articles, rendered by asciidoctor -- see hugo.toml
   [security.exec] for why the binary is named there).

   Asciidoctor emits its own class vocabulary, and none of it is Goldmark's.
   Everything here exists because a real block came out looking wrong without
   it -- checked against a probe article carrying the four constructs the
   feature was actually asked for: named blocks, tables, callouts and
   admonitions.

   SPACING FIRST, because it is the one that looks like nothing is wrong.
   Asciidoctor wraps every section in `.sect1 > .sectionbody`, so `.prose > * +
   *` (the rule that spaces a Markdown article out) only ever sees a stack of
   .sect1 divs -- everything INSIDE a section is a grandchild and got no
   spacing at all. Paragraphs survived on the global `p { margin-bottom }`;
   code blocks, tables and admonitions, which are bare <div>s, did not.
   -------------------------------------------------------------------------- */
.prose .sectionbody > * + *,
.prose .sect1 + .sect1,
.prose .sect2 + .sect2 { margin-top: 1.1em; }

/* AN ADMONITION IS A <table> IN ASCIIDOCTOR'S HTML -- two cells, label and
   body. Left alone it inherits `.prose table` (full width, collapsed borders)
   and `.prose td` (a bottom rule on every cell), i.e. it renders as a bordered
   two-column data table containing the word "Note". Unpicking that is what
   most of this block is.

   Laid out as BLOCKS rather than as a table: the label sits above the body
   instead of in a narrow first column, which needs no width guess, cannot
   squeeze the text into a ribbon on a phone, and reads in the right order when
   a screen reader linearises it. */
.prose .admonitionblock {
  border-left: 3px solid var(--admonition-accent, var(--brand-accent-strong));
  background: var(--surface-sunken);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
  padding: .9rem 1.1rem;
}
.prose .admonitionblock table,
.prose .admonitionblock tbody,
.prose .admonitionblock tr,
.prose .admonitionblock td { display: block; width: auto; }
/* The bottom rule `.prose th, .prose td` puts on every cell, and its padding:
   both are for data tables and neither belongs on a callout. */
.prose .admonitionblock td { border-bottom: 0; padding: 0; }
.prose .admonitionblock td.icon { margin-bottom: .35rem; }
.prose .admonitionblock td.icon .title {
  font-family: var(--font-display); font-size: .78rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .08em;
  color: var(--admonition-accent, var(--brand-accent-strong));
}
.prose .admonitionblock td.content > :last-child { margin-bottom: 0; }
/* One custom property per severity rather than five near-identical rule sets.
   All five differ: WARNING and CAUTION shared --feed at first, on the reasoning
   that the palette had no severity colours and inventing one would be an accent
   nobody else could reuse -- but two severities rendering identically defeats
   the point of labelling them separately, so --warning and --caution are now
   real palette tokens. See their definition for the contrast measurements. */
.prose .admonitionblock.note      { --admonition-accent: var(--brand-accent-strong); }
.prose .admonitionblock.tip       { --admonition-accent: var(--tag-text); }
.prose .admonitionblock.important { --admonition-accent: var(--brand-secondary); }
.prose .admonitionblock.warning   { --admonition-accent: var(--warning); }
.prose .admonitionblock.caution   { --admonition-accent: var(--caution); }

/* TABLE CELL ALIGNMENT. `[cols="<1,^1,>1"]` makes asciidoctor put
   halign-left / halign-center / halign-right (and valign-*) on every cell --
   and then nothing happened, because `.prose th, .prose td` sets
   `text-align: left` and is a class PLUS a type selector, which outranks a
   bare `.halign-center`. The same specificity trap the .sitemap-table and
   .site-table rules document, hit for the third time.

   Written class-on-class, and scoped to .tableblock so these generic-sounding
   names cannot reach anything else. */
.prose .tableblock.halign-left   { text-align: left; }
.prose .tableblock.halign-center { text-align: center; }
.prose .tableblock.halign-right  { text-align: right; }
.prose .tableblock.valign-top    { vertical-align: top; }
.prose .tableblock.valign-middle { vertical-align: middle; }
.prose .tableblock.valign-bottom { vertical-align: bottom; }

/* The caption on a named block -- `.A named code block` above a source block,
   which is half of why people reach for AsciiDoc. Scoped to the block types
   that carry one: `.title` is far too generic a class to style loose, and
   asciidoctor also uses it for the admonition label handled above. */
.prose .listingblock > .title,
.prose .exampleblock > .title,
.prose .imageblock > .title,
.prose .literalblock > .title {
  font-family: var(--font-display); font-size: .9rem; font-weight: 600;
  color: var(--ink-muted); margin-bottom: .4rem;
}
.prose table.tableblock > caption.title {
  text-align: left; font-family: var(--font-display); font-size: .9rem;
  font-weight: 600; color: var(--ink-muted); margin-bottom: .4rem;
}

/* CALLOUTS: the <1> markers in a source block and the numbered list under it.
   BOTH SIDES ARE DRAWN AS THE SAME CIRCLED DIGIT, and neither of them is drawn
   here -- the point of this block is that one of them cannot be.

   INSIDE THE CODE BLOCK the marker is already the character ① by the time it
   reaches the browser: partials/article-html.html swaps asciidoctor's
   `<b class="conum">(1)</b>` for the glyph, because EnlighterJS rebuilds a
   block from its textContent and no element survives that. So there is nothing
   to style -- it inherits the code font like every other character in the
   snippet -- and no `.conum` rule below, because no .conum element reaches the
   page. (There was one, colouring the parenthesised form. It never matched.)

   THE CIRCLE ASCIIDOCTOR DRAWS ITSELF IS NOT AVAILABLE EITHER WAY. It needs
   the number in a `data-value` attribute to put back with ::after inside a
   CSS-drawn circle, and asciidoctor only writes that attribute under
   `icons=font` -- which in the same pass replaces every admonition's
   "Note"/"Warning" label with a Font Awesome <i>, an icon font this site does
   not load and would have to start loading on every article with a callout.

   THE LIST THAT EXPLAINS THE MARKERS is numbered with the same glyphs rather
   than with "1.", so the reader matches a shape to a shape instead of a shape
   to a digit. @counter-style is unsupported on Safari before 17: there this
   declaration is dropped and the list renders `decimal`, which is what it
   showed until now and still lines up with the markers. Past ⑳ the fixed
   system runs out and falls back to decimal on its own -- see the same limit,
   and why it is not worth extending, in article-html.html. */
@counter-style foojay-callout {
  system: fixed;
  symbols: "①" "②" "③" "④" "⑤" "⑥" "⑦" "⑧" "⑨" "⑩"
           "⑪" "⑫" "⑬" "⑭" "⑮" "⑯" "⑰" "⑱" "⑲" "⑳";
  suffix: " ";
}
.prose .colist { font-size: .96rem; }
.prose .colist > ol { padding-left: 1.6rem; list-style-type: foojay-callout; }
/* The same character comes out visibly smaller here than in the code block,
   because the two resolve through different font stacks (--font-mono has the
   glyph; --font-body falls through to whatever the system offers). Nudged up
   so the marker and the thing it points at read as one pair. ::marker takes
   font properties and little else, which is all this needs. */
.prose .colist > ol > li::marker { font-size: 1.2em; }
.prose .colist p { margin-bottom: 0; }
.prose .colist li + li { margin-top: .35rem; }

/* A wide data table scrolls INSIDE ITS OWN BOX rather than taking the page
   with it (see the templates for the measurements). Only below 60rem: on a
   desktop both tables fit, and a scroll container there would needlessly clip
   the sticky-header trick the sitemap tables use.

   `overscroll-behavior-x: contain` stops a sideways flick at the end of the
   table from turning into a browser back-gesture, which is what makes a
   horizontally scrolling region tolerable on a touchpad at all. */
@media (max-width: 60rem) {
  .table-scroll { overflow-x: auto; overscroll-behavior-x: contain; }
}
/* The ring is drawn INSIDE the box: an outline-offset on a scroll container is
   clipped on the axis it scrolls, so the right-hand edge of the indicator would
   simply not be painted. */
.table-scroll:focus-visible,
.sitemap-table-wrap:focus-visible { outline-offset: -3px; }

/* /ai-instructions/: the three derived tables (shortcodes site-facts,
   site-numbers, site-endpoints). They render INSIDE .prose, so every rule here
   has to outrank `.prose th, .prose td`, which is 0-1-1 -- a bare
   `.site-table__num` would lose its alignment to it without a word. Hence
   class-on-class throughout, the same specificity trap the .sitemap-table and
   series rules document.

   Row headers rather than a first column of plain cells: each row is a
   field/value pair, so `th[scope="row"]` is what it actually is, and it gives a
   screen reader the label when it reads the value. The width is capped so the
   value column does not collapse on a long label, and released below 48rem
   where a two-column split of the shell leaves too little for either. */
.prose .site-table th[scope="row"] {
  width: 30%; vertical-align: top; font-weight: 600; color: var(--ink);
}
.prose .site-table td { vertical-align: top; }
.prose .site-table th[scope="row"] code { font-size: .85em; overflow-wrap: anywhere; }
/* tabular-nums so a column of counts lines up digit under digit -- the numbers
   are there to be compared, and proportional figures make 2,153 and 344 look
   like arbitrary widths. */
.prose .site-table th.site-table__num,
.prose .site-table td.site-table__num {
  text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums;
}
.site-table__asof { font-size: .9rem; color: var(--ink-muted); margin-top: .6rem; }
@media (max-width: 48rem) {
  .prose .site-table th[scope="row"] { width: auto; }
}

/* /ad-stats/: the banner report (themes/foojay/layouts/ad-stats/single.html).
   Both tables are .site-table, so the width, padding, borders and the
   right-aligned tabular figures come from the rules above and only what is
   genuinely different is written here.

   The sort ARROWS need nothing: .sort-btn and the th[aria-sort] rules further
   down are generic rather than scoped to .sitemap-table. The active-column
   colour IS scoped there, so it is restated for this table -- a header whose
   arrow has changed but whose colour has not reads as a control that did not
   take. `.prose .site-table` to outrank `.prose th`, the specificity trap the
   .site-table and .sitemap-table rules both document. */
.prose .site-table thead th[aria-sort="ascending"],
.prose .site-table thead th[aria-sort="descending"] { color: var(--ink); }
.prose .site-table tbody tr:hover { background: var(--surface-sunken); }
/* The slug under a headline: present because it is the identity the counter
   and the folder use, but secondary to the headline it sits under -- and
   `overflow-wrap` because 2026-09-azul-jvm-drag-ai-infrastructure has no
   break opportunity of its own on a narrow screen. */
.prose .site-table .ad-stats__slug {
  font-size: .82em; color: var(--ink-muted); overflow-wrap: anywhere;
}
.prose .site-table .ad-stats__sponsor { white-space: nowrap; }
/* Dates, so tabular figures line the two ISO days up either side of the arrow. */
.prose .site-table .ad-stats__ran {
  white-space: nowrap; color: var(--ink-muted); font-variant-numeric: tabular-nums;
}

/* /jugs/ page: data/jugs.yaml source note + table (themes/foojay/layouts/jugs/single.html) */
.jugs-source-note { font-size: .92rem; color: var(--ink-muted); }
.jugs-table__links { font-size: .9rem; white-space: nowrap; }
.jugs-table__edit { text-decoration: none; opacity: .55; }
.jugs-table__edit:hover { opacity: 1; }

/* /java-champions/ page: data/java-champions.yaml source note + table
   (themes/foojay/layouts/champions/single.html) */
.champions-source-note { font-size: .92rem; color: var(--ink-muted); }
.champions-table__name { display: flex; align-items: center; gap: .6rem; }
/* background + empty alt is the fallback for a dead hotlink, same as
   .cluster-map__avatar above: these files live on javachampions.org. */
.champions-table__name img { width: 36px; height: 36px; border-radius: 50%; flex: 0 0 auto; object-fit: cover; background: var(--surface-sunken); }
.champions-table__status {
  font-size: .78rem; color: var(--ink-muted); background: var(--surface-sunken);
  border: 1px solid var(--border); border-radius: 999px; padding: .1rem .55rem;
}
/* Champions can have many links (Website, Twitter, Mastodon, Bluesky,
   LinkedIn, GitHub, YouTube, Sessionize, Speaker Deck). Let the cell wrap onto
   several lines instead of forcing one long nowrap row that stretches the
   whole table. Each link stays intact (no mid-word break); only the middot
   separators are break points. */
.champions-table__links { font-size: .9rem; line-height: 1.7; }
.champions-table__links a { white-space: nowrap; }

/* The clustered world map, shared by /java-champions/ and /jugs/ --
   static/js/cluster-map.js draws both, so these styles are deliberately not
   named after either page.

   A marker carries its own count, so a point holding 16 people doesn't look
   like one holding a single champion, which is the whole point of the map. A
   cluster uses the identical badge (cluster-map.js's iconCreateFunction sums
   what its children stand for, not how many there are), so a total splits into
   smaller totals as you zoom in and the number never changes meaning.

   Badges are sized by content rather than a fixed Leaflet iconSize -- "1" and
   "422" are different widths, and a fixed box would either clip the wide one or
   leave the narrow one off-centre. The translate is what re-centres one on its
   coordinate, since Leaflet cannot offset an icon whose size it doesn't know.

   Popups list everyone at a point, so they need a height cap: 22 champions
   share the USA centroid and 16 share London, which is a popup taller than the
   map it opens in.

   Note the popup list rules are class-on-class-descendant on purpose. Both
   these and `.prose ul` (line ~1361) are 0-1-1, and a Leaflet popup is
   appended INSIDE the map container, which is itself inside `.prose` -- so the
   tie is broken by source order and these have to stay below the .prose block,
   not be moved above it.

   `position: relative; z-index: 0` is what keeps the map UNDER the header's
   nav panels. Leaflet stacks its own panes and controls at z-index 200-1000,
   and `.leaflet-container` is only `position: relative` (z-index auto), so
   without a stacking context of its own those numbers compete in the ROOT
   context against `.site-header`'s 100 -- and win. The header itself never
   overlaps the map so it looked fine, but a dropdown panel hanging down over
   the map was painted behind the tiles. Any z-index below 100 does it; 0
   keeps the map where it sits in source order. */
.cluster-map { height: 480px; margin: 1.5rem 0 .75rem; border-radius: var(--radius); overflow: hidden; border: 1px solid var(--border); position: relative; z-index: 0; }
.cluster-map__note { font-size: .88rem; color: var(--ink-muted); margin: 0 0 1.5rem; }
.cluster-map__popup { font-size: .9rem; max-height: 15rem; overflow-y: auto; }
.cluster-map__popup strong { display: block; margin-bottom: .35rem; }
.cluster-map__popup ul { margin: .35rem 0 0; padding-left: 1.1rem; }
.cluster-map__popup li + li { margin-top: .15rem; }
.cluster-map__place { color: var(--ink-muted); font-size: .85rem; }

/* Avatars in a popup (the champions map supplies them, /jugs/ doesn't -- see
   cluster-map.js). The circle carries its own background and a fixed size, so
   an avatar that 404s upstream leaves an empty circle with the row still
   aligned instead of a broken-image glyph; that is the whole fallback, and it
   is why the img keeps an empty alt. */
.cluster-map__avatar {
  width: 24px; height: 24px; flex: 0 0 auto;
  border-radius: 50%; object-fit: cover;
  background: var(--surface-sunken);
}
.cluster-map__who { display: flex; align-items: center; gap: .5rem; }
.cluster-map__who .cluster-map__avatar { width: 42px; height: 42px; }
/* Declared after the `ul` rule above, which it ties on specificity with. */
.cluster-map__popup .cluster-map__faces { list-style: none; padding-left: 0; }
.cluster-map__popup .cluster-map__faces li { display: flex; align-items: center; gap: .45rem; }
.cluster-map__popup .cluster-map__faces li + li { margin-top: .3rem; }

.cluster-map__marker {
  background: none; border: 0;   /* Leaflet's .leaflet-div-icon default box */
  width: auto !important; height: auto !important;
}
.cluster-map__marker span {
  display: grid; place-items: center;
  box-sizing: border-box; min-width: 2rem; height: 2rem;
  padding: 0 .45rem; border-radius: 999px;
  background: var(--brand-secondary); color: #fff;
  font-size: .8rem; font-weight: 700; line-height: 1;
  border: 2px solid #fff; box-shadow: 0 1px 4px rgb(0 0 0 / .35);
  transform: translate(-50%, -50%);
}
.cluster-map__marker--md span { min-width: 2.4rem; height: 2.4rem; font-size: .85rem; }
.cluster-map__marker--lg span { min-width: 2.9rem; height: 2.9rem; font-size: .9rem; }
/* A cluster is several places at once, and it has to be obvious at a glance
   which is which. Under the amber palette that was accent-vs-secondary, i.e.
   orange vs blue. Now that the accent is blue too, the pair has to be the two
   FAMILIES rather than two steps of one: --brand-secondary (indigo) for a
   place, --brand-accent (the bright logo blue) for a cluster. Two steps of the
   same family -- which #087ab0 next to #3562e5 was -- read as the same badge
   twice at map size.

   Its label is --on-accent and needs no dark override, because --brand-accent
   is the one brand fill that does not change between schemes. */
.cluster-map__marker--cluster span { background: var(--brand-accent); color: var(--on-accent); }
/* The PLACE badge's fill inverts with the scheme (--brand-secondary is a dark
   indigo on light and a pale one on dark), so its fixed white label was only
   right in one of them -- it measured 2.4:1 in dark. The cluster above sets its
   own label and is unaffected. The ring goes from white to the sunken surface
   for the same reason: a white ring is a halo on a dark map. */
:root[data-theme="dark"] .cluster-map__marker span { color: var(--on-accent); border-color: var(--surface-sunken); }
:root[data-theme="dark"] .cluster-map__marker--cluster span { color: var(--on-accent); }
.cluster-map__marker:hover span { background: var(--brand-accent); }

/* Author box under an article */
.author-callout {
  display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 1.25rem;
  align-items: start;
  margin-top: 2.5rem; padding: 1.4rem 1.5rem;
  background: var(--surface-sunken); border: 1px solid var(--border); border-radius: var(--radius);
}
.author-callout .author-avatar { width: 72px; height: 72px; }
.author-callout h3 { margin: .3rem 0 .7rem; font-size: 1.15rem; }
.author-callout h3 a { color: var(--ink); }
.author-callout h3 a:hover { color: var(--brand-accent-strong); }
.author-callout p { margin: 0; font-size: .95rem; }
.author-callout .author-social { margin-top: 1rem; }

@media (max-width: 560px) {
  .author-callout { grid-template-columns: minmax(0, 1fr); }
}

/* -------------------------------------------------------- dark scheme ----
   Set by the inline script in baseof.html: a stored preference wins, then the
   OS setting. Single source of truth — no duplicated prefers-color-scheme
   block to keep in sync.
   ------------------------------------------------------------------------ */

:root { color-scheme: light; }

:root[data-theme="dark"] {
  color-scheme: dark;

  /* The accent needs NO lightening for dark, which the amber it replaces did
     (#f2951b had to become #f9a83a): the logo blue is 8.57 on --surface as it
     stands, so the mark, the buttons and the ticks are one colour in both
     schemes. --brand-accent-strong is still the text variant, lightened rather
     than darkened because the ground flipped. */
  --brand-accent:        #50c2f7;   /* 8.57 on --surface */
  --brand-accent-strong: #8ed6fa;   /* 10.83 */
  --brand-accent-soft:   rgba(80, 194, 247, .14);
  /* The one scheme where the accent IS a contrasting ring: 8.57 on --surface,
     11.9 on --page-bg. The light scheme's readable sibling would be 2.3 here. */
  --focus-ring:          #50c2f7;
  /* #3562e5 is 2.4:1 on a dark ground -- an indigo that dark cannot be a link
     colour here, so it is the same hue lightened to 6.08. */
  --brand-secondary:     #7796ee;
  --brand-secondary-soft:rgba(119, 150, 238, .14);

  --tag-soft:       rgba(114, 192, 117, .14);
  --tag-text:       #72c075;   /* 7.84 */

  /* --on-accent needs no dark value (the accent it sits on is unchanged), but
     these two do: both their fills are pale here, so the label goes dark. */
  --on-accent-strong: #06232f;   /* 10.20 on --brand-accent-strong */
  --on-ink:           #0a1114;   /* 6.84 on --ink-muted, 16+ on --ink */

  /* Same blue-grey hue as the light scheme, lightness inverted. Saturation is
     held up deliberately at these depths: the hue washes out to charcoal below
     about 20% saturation, and the point is that the dark scheme reads as the
     same blue-grey family rather than as neutral grey. */
  --ink:            #edf1f3;   /* 15.22 on --surface */
  --ink-soft:       #c3cfd5;   /* 10.88 */
  --ink-muted:      #8b9ea7;   /*  6.22 */
  --surface:        #121c21;
  --surface-sunken: #1d292f;
  --surface-navy:   #0a1114;
  --page-bg:        #0a1114;
  --page-head-bg:   #1d292f;
  --border:         #2e3f48;
  --border-strong:  #435a65;

  /* 6.66 on --page-head-bg, 7.74 on --surface. */
  --feed:           #ff922b;
  /* Lifted off the dark ground the same way every other accent here is:
     6.66 and 7.65 on --surface-sunken #1d292f. */
  --warning:        #ff922b;
  --caution:        #e3b341;

  --footer-bg:     #0a1114;
  --footer-fg:     #a9b9c1;
  --footer-link:   #cfdbe0;
  --footer-head:   #edf1f3;
  --footer-muted:  #7d8e97;
  --footer-border: #24333a;

  --shadow-sm: 0 1px 2px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, .45), 0 16px 40px rgba(0, 0, 0, .5);
}

/* Amber is bright enough on dark that the pill button keeps dark label text,
   but a few surfaces need retuning rather than a token swap. */
/* No dark override for the label on an accent fill any more: --brand-accent is
   the same #50c2f7 in both schemes, so --on-accent is correct in both. The
   three rules that used to re-darken it for amber are gone. */
:root[data-theme="dark"] .btn--navy { background: var(--surface-sunken); color: var(--ink); border-color: var(--border-strong); }
:root[data-theme="dark"] .btn--navy:hover { background: var(--border); }
:root[data-theme="dark"] pre { border: 1px solid var(--border); }
:root[data-theme="dark"] .nav-backdrop { background: rgba(0, 0, 0, .6); }
:root[data-theme="dark"] .post-card-image img,
:root[data-theme="dark"] .author-card img,
:root[data-theme="dark"] .author-avatar,
:root[data-theme="dark"] .byline-avatar,
:root[data-theme="dark"] .featured-side__head img,
:root[data-theme="dark"] .post-single img { filter: brightness(.92); }
/* The placeholder tile is generated colour, not artwork, so it is retuned for
   dark rather than filtered -- which is also why the brightness rule above
   targets .post-card-image img and not the container: dimming a colour that is
   already chosen for a dark ground just muddies it. */
:root[data-theme="dark"] .thumb-ph {
  background:
    repeating-linear-gradient(135deg,
      transparent 0 11px, hsl(var(--ph-hue) 30% 34% / .4) 11px 22px),
    linear-gradient(155deg, hsl(var(--ph-hue) 30% 25%), hsl(var(--ph-hue) 32% 16%));
}
:root[data-theme="dark"] .thumb-ph__label { color: hsl(var(--ph-hue) 42% 80%); }
/* A light-background PNG logo needs no change, but a dark-ink one would vanish
   on the dark surface — set params.logoDark to supply an alternate file, or
   uncomment this to invert the single asset.
   :root[data-theme="dark"] .logo__img { filter: invert(1) hue-rotate(180deg); } */

/* --- WordPress inline image formatting (floats, resized, captions) ---------
   Emitted by the {{< img >}} shortcode, or kept as raw HTML where the float /
   dimensions / caption would not survive Markdown. Galleries are NOT here --
   they are the {{< gallery >}} shortcode below, for migrated and new posts
   alike. */
.prose .alignleft { float: left; margin: 0.3rem 1.4rem 1rem 0; }
.prose .alignright { float: right; margin: 0.3rem 0 1rem 1.4rem; }
.prose .aligncenter { display: block; margin-inline: auto; }
.prose figure.alignleft, .prose figure.alignright { max-width: 50%; }
.prose figure { margin: 1.5rem 0; }
.prose figure img, .prose img.alignleft, .prose img.alignright { height: auto; max-width: 100%; }
.prose figcaption, .prose .wp-element-caption { font-size: 0.85em; opacity: 0.72; text-align: center; margin-top: 0.4rem; }

/* Artwork drawn FOR a white page -- black linework on transparency -- loses its
   outlines on the dark scheme: Duke's cap on /who-we-are/ becomes an invisible
   black wedge and the illustration reads as a floating bird. Transparency is not
   something CSS can test for, so this is opt-in per image: add `on-light` to the
   {{< img >}} `class` and the image gets back the ground it was drawn against.
   Same reasoning as the per-sponsor `logoBackground` on the sponsor tiles.

   The padding and radius apply in BOTH schemes so switching theme doesn't move
   the surrounding text; only the fill is dark-mode-only, since on the light
   scheme the page already is the plate. */
.prose .on-light img { padding: .6rem; border-radius: 10px; }
:root[data-theme="dark"] .prose .on-light img { background: #fff; }

/* --- {{< gallery >}} shortcode (layouts/shortcodes/gallery.html) ------------
   Every gallery on the site, migrated or new. auto-fit means --gallery-cols is
   a maximum: cells reflow to fewer columns as the container narrows, so no
   media query is needed and a 3-column gallery becomes 1 column on a phone. */
.prose .gallery {
  --gallery-gap: 0.8rem;
  display: grid;
  gap: var(--gallery-gap);
  margin: 1.5rem 0;
  /* Each cell is at least its exact 1/cols share, so the grid never exceeds
     --gallery-cols columns; the 180px floor makes auto-fit drop to fewer
     columns on a narrow screen instead of squashing. */
  grid-template-columns: repeat(auto-fit, minmax(max(180px,
      calc((100% - (var(--gallery-cols) - 1) * var(--gallery-gap)) / var(--gallery-cols))), 1fr));
}
.prose .gallery__item { margin: 0; }
.prose .gallery__item img { width: 100%; height: auto; display: block; border-radius: 4px; }
/* One caption for the whole gallery, under the grid rather than in a cell. */
.prose .gallery__caption { margin-top: 0.6rem; }
.prose figure::after { content: ""; display: block; clear: both; }

/* ---------------------------------------------------------------- search --
   Pagefind-backed /search/ page (themes/foojay/layouts/search/single.html).
   `mark` is Pagefind's own match-highlighting inside excerpts. */

.search-page__form { max-width: 46rem; margin-bottom: .5rem; }
.search-page__form input {
  width: 100%; font: inherit; font-size: 1.1rem;
  padding: .8rem 1.1rem;
  background: var(--surface-sunken);
  border: 1px solid var(--border-strong); border-radius: 999px;
  color: var(--ink);
}
.search-page__form input:focus { border-color: var(--brand-accent); outline-offset: 0; }

.search-page__status { color: var(--ink-muted); font-size: .92rem; min-height: 1.4em; }

.search-page__results { display: grid; gap: 2.25rem; max-width: 62rem; }
.search-page__section h2 {
  display: flex; align-items: baseline; gap: .5rem;
  font-size: 1rem; text-transform: uppercase; letter-spacing: .1em;
  padding-bottom: .5rem; margin-bottom: 1.1rem;
  border-bottom: 1px solid var(--border);
}
.search-page__count { color: var(--ink-muted); font-weight: 600; font-size: .82rem; }
.search-page__group { display: grid; gap: 1.25rem; }
.search-result {
  display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 1.1rem;
  padding-bottom: 1.25rem; border-bottom: 1px solid var(--border);
}
/* Fixed height as well as width, and position: relative -- the box now always
   renders (a result with no image gets the .thumb-ph tile instead), so it can
   no longer take its height from the <img> inside it. */
.search-result__image {
  display: block; width: 120px; height: 84px; position: relative;
  border-radius: var(--radius-sm); overflow: hidden;
}
.search-result__image img {
  width: 120px; height: 84px; object-fit: cover;
  position: relative; z-index: 1; background: var(--surface-sunken);
}
.search-result .thumb-ph { padding: .5rem; }
.search-result .thumb-ph__label { font-size: .68rem; letter-spacing: .06em; }
.search-result h3 { margin: 0 0 .3rem; font-size: 1.1rem; }
/* The date and the byline on one row, with the "·" between them coming from
   the same `> * + *::before` idiom .post-meta and .post-card-meta use -- so
   the separator appears only when there IS a byline, and the date alone still
   renders as one plain line. `flex-wrap` because a podcast episode can credit
   a dozen people and the result body is only ~500px wide.

   The row is a <div> and not a <p> for a specificity reason: `.search-result p`
   below is (0,1,1) and later in this file, so it would beat this single class
   on both counts and render the line in the excerpt's colour and size. */
.search-result__meta {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 0;
  margin: 0 0 .4rem;
  color: var(--ink-muted); font-size: .85rem;
}
.search-result__meta > * + *::before {
  content: "·"; margin: 0 .5rem; color: var(--border-strong);
}
/* tabular-nums on the date only: it aligns digits down a list of results, and
   applying it to a byline would space the numerals in a name like "Java 21". */
.search-result__date { font-variant-numeric: tabular-nums; }
.search-result p { margin: 0; color: var(--ink-soft); font-size: .95rem; }
.search-result mark { background: var(--brand-accent-soft); color: var(--ink); border-radius: 2px; }
/* A <button> needs the UA's grey ground cleared -- .btn--ghost sets only the
   border and the text colour, which is enough for the <a>s it was written for.
   justify-self keeps it at its own width instead of stretching across the
   grid column the results sit in. */
.search-page__more { margin-top: 1.25rem; justify-self: start; background: none; }

@media (max-width: 560px) {
  .search-result { grid-template-columns: minmax(0, 1fr); }
  .search-result__image { display: none; }
}

/* --- Click-to-enlarge lightbox (js/lightbox.js) --- */
/* THE HEADSHOT BESIDE A NAMED EXPERT'S PULL-QUOTE. WordPress served these as
   its 300x300 thumbnail with nothing capping them, so a byline portrait
   rendered at four times the 72px the author cards use -- it read as a hero
   image interrupting the article rather than as the face of the person being
   quoted. Round, to match every other face on the site (.author-card img,
   .author-avatar).
   THE FLOAT IS NOT HERE: the markup pairs this class with `alignleft` on the
   {{< img >}} shortcode's <figure>, so the float and its margins come from
   .prose .alignleft above -- one definition of what a left-floated content
   image does, whatever is inside it. Which is also why the size lands on the
   IMG rather than the figure: the class sits on the figure, and a floated
   figure shrink-wraps to what it holds.
   `height` needs both classes to outrank .prose figure img's `height: auto`;
   the width/height attributes in the markup carry the same numbers, so the box
   is reserved before the image decodes and the size is still right if this
   rule is ever missed. One post uses this today
   (content/posts/2023/02/03/what-should-i-know-about-garbage-collection-...),
   and a survey of content/ found the pattern -- a square portrait between a
   bold headline and a bold "Name, Role" line -- in no other. */
.prose figure.quote-portrait img {
  width: 96px; height: 96px; border-radius: 50%; object-fit: cover;
}

.prose img.is-zoomable, img.post-hero.is-zoomable { cursor: zoom-in; }
.lightbox { position: fixed; inset: 0; z-index: 1000; display: none; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.85); padding: 2rem; }
.lightbox.is-open { display: flex; }
.lightbox__img { max-width: 96vw; max-height: 92vh; width: auto; height: auto; border-radius: 4px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); cursor: zoom-out; }
.lightbox__close { position: absolute; top: 0.75rem; right: 1.25rem; background: none; border: 0; color: #fff; font-size: 2.4rem; line-height: 1; cursor: pointer; padding: 0.25rem 0.5rem; }
.lightbox__close:hover { opacity: 0.8; }
/* Step through the page's images. Sized for a thumb on a phone, and kept off
   the image itself so a wide picture is never covered by an arrow. */
.lightbox__nav { position: absolute; top: 50%; transform: translateY(-50%); z-index: 1; background: rgba(0, 0, 0, 0.35); border: 0; color: #fff; font-size: 2.6rem; line-height: 1; cursor: pointer; padding: 0.4rem 0.9rem; border-radius: 4px; }
.lightbox__nav:hover { background: rgba(0, 0, 0, 0.6); }
.lightbox__nav--prev { left: 0.4rem; }
.lightbox__nav--next { right: 0.4rem; }
.lightbox__nav[hidden] { display: none; }
body.lightbox-open { overflow: hidden; }

/* --- Post header: circular hero thumbnail floated left of the title.
   Floated (not flex) so a long title wraps to the right AND continues below the
   image, instead of leaving empty space under it. --- */
.post-header { margin-bottom: 1.75rem; }
.post-header::after { content: ""; display: block; clear: both; } /* contain the float */
/* BALANCED, so a title does not end on one orphan word. "Come for Java. Get a
   Whole Lot More." broke as "...Get a Whole / Lot More." at every window width
   from 660px to 1550px; balanced it breaks at the full stop instead, which is
   where a reader would break it.

   The inherited `text-wrap: pretty` on body does not cover this. Pretty only
   pulls a word down to lengthen a short last line when it can do so without
   changing any earlier break -- and it cannot here, because the FLOATED HERO
   shortens the first line: every line above the last is already as full as it
   goes. Balance is allowed to re-break the whole block, so it can trade the
   first line's width for a last line that carries its weight.

   Measured on the longest titles in the archive (three and four lines, 100 to
   127 characters) before turning it on: balance either left them exactly as
   they were or moved a word down onto the last line. Chrome stops balancing
   past six lines, and no title in the archive reaches that even on a phone.

   Same reason and same property as .post-card--lead .post-card-title, which
   has carried this since the card grid was built -- a post's title and that
   post's own card were breaking by different rules. */
.post-header h1 { margin-top: 0; text-wrap: balance; }
.post-single .post-header .post-hero {
  float: left; width: 120px; height: auto; margin: .15rem 1.25rem .5rem 0;
  border-radius: var(--radius);
}
@media (max-width: 560px) {
  .post-single .post-header .post-hero { width: 88px; margin-right: 1rem; }
}

/* --- Pedia (Java Terms Explained) glossary --- */
.pedia-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1rem; margin-top: 1.5rem; }
.pedia-card { display: block; padding: 1.1rem 1.25rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); text-decoration: none; color: inherit; transition: border-color .15s ease, transform .15s ease; }
.pedia-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.pedia-card h3 { margin: 0 0 .4rem; font-size: 1.05rem; }
.pedia-card p { margin: 0; font-size: .9rem; color: var(--ink-muted); }
.pedia-back { margin: 0 0 1.25rem; }
.pedia-back a { font-weight: 600; font-size: .95rem; }
.pedia-back--foot { margin: 2.25rem 0 0; }

/* --- Author list quick filter --- */
.list-filter { margin: 0 0 1.5rem; }
.list-filter__input {
  width: 100%; max-width: 420px; padding: .6rem .9rem;
  font-family: var(--font-body); font-size: 1rem;
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--border-strong); border-radius: var(--radius);
}
/* The border tint is the resting-to-focused cue; the OUTLINE is the focus
   indicator, and deleting it (which this rule used to do) leaves a keyboard
   user with a 1px colour change worth 2:1 -- WCAG 2.4.7 wants an indicator,
   1.4.11 wants it at 3:1. The global :focus-visible ring above supplies both. */
.list-filter__input:focus { border-color: var(--brand-accent); }
.author-grid .author-card[hidden] { display: none !important; }
/* A filtered-out row needs this: [hidden] is display:none in the UA
   stylesheet, but `display: table-row` from the table's own layout wins over
   it, so a hidden <tr> stays visible without the override. */
.jugs-table tbody tr[hidden] { display: none !important; }
.champions-table tbody tr[hidden] { display: none !important; }
.sitemap-table tbody tr[hidden] { display: none !important; }
.list-filter__empty { color: var(--ink-muted); margin: 0; }
/* The generic filter box (static/js/table-filter.js) puts a live match count
   beside its input. Scoped to [data-filter-wrap] so the four older filters,
   which have an input and nothing else, keep their block layout. */
.list-filter[data-filter-wrap] { display: flex; align-items: center; flex-wrap: wrap; gap: .75rem; }
.list-filter__count { color: var(--ink-muted); font-size: .9rem; font-variant-numeric: tabular-nums; }
.term-grid .term-card[hidden] { display: none !important; }

/* --- Taxonomy index (/today/category/) --- */
/* A card per category rather than the chip row the sidebar and home page use:
   at 123 entries a ragged chip flow is hard to scan, and the count wants a
   column of its own. */
.term-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .75rem; }
.term-card {
  display: flex; align-items: center; justify-content: space-between; gap: .75rem;
  padding: .7rem .95rem;
  background: var(--surface-sunken); border: 1px solid var(--border); border-radius: var(--radius);
  color: var(--ink); font-family: var(--font-display); font-weight: 600; font-size: .95rem;
}
.term-card:hover { border-color: var(--brand-accent); color: var(--brand-accent-strong); text-decoration: none; }
.term-card__count {
  flex: none; font-size: .85rem; font-weight: 700; color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}

/* --- Team page (meet-the-team) member cards --- */
/* Photo is floated (not flex) so the member's name can be a real Markdown ##
   heading -- that's what puts it in the page's table of contents -- while
   still sitting beside the avatar. */
.team-member {
  padding: 1.75rem 0; border-top: 1px solid var(--border);
}
.team-member::after { content: ""; display: block; clear: both; }
.prose .team-member:first-of-type { border-top: 0; padding-top: .5rem; }
.prose .team-member__photo {
  float: left; width: 120px; height: 120px;
  margin: .35rem 1.5rem .5rem 0; border-radius: 50%; object-fit: cover;
}
.prose .team-member h2 { margin: 0 0 .2rem; }
.team-member > :last-child { margin-bottom: 0; }
.team-member__role { color: var(--brand-secondary); font-weight: 600; margin: 0 0 .75rem; }

/* The "All the Authors!" panel closing /meet-the-team/. Layout ONLY -- the
   tinted ground, border and radius come from .highlight-panel, which AGENTS.md
   makes the single definition of "this block is the one to look at", so this
   class must not restate any of them. Same split .post-card--lead uses.

   It needs padding because .highlight-panel deliberately has none: it is a
   treatment applied to blocks that already own their spacing, and inside .prose
   there is nothing else to supply it. The clamp matches the lead card's.

   No top border, unlike .team-member: the panel's own border already separates
   it, and a rule immediately above a bordered box reads as a mistake. */
.prose .team-authors {
  padding: clamp(1.1rem, 2.5vw, 1.75rem);
  margin-top: 2rem;
}
.prose .team-authors > :first-child { margin-top: 0; }
.prose .team-authors > :last-child { margin-bottom: 0; }
/* h2 inside .prose gets margin-top: 2rem from .post-single h2; the rule above
   only clears it for the FIRST child, so the heading needs it in its own right
   when the panel opens with it. Class-on-class to outrank `.post-single h2`,
   which is 0-1-1. */
.prose .team-authors h2 { margin-top: 0; }
@media (max-width: 560px) {
  .prose .team-member__photo { width: 96px; height: 96px; margin-right: 1rem; }
}

/* --- "Where to find us" page: two-column grid of boxed items --- */
.where-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 1.5rem;
  margin: 1.5rem 0;
}
.where-card {
  display: flex;
  flex-direction: column;
  padding: 1.5rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  box-shadow: var(--shadow-sm);
}
.where-card__img {
  display: block; width: auto; max-width: 160px; height: auto;
  margin: 0 0 1rem;
}
/* Card title is a real Markdown ## heading (so it shows in the page's table of
   contents), styled down to card size. */
.prose .where-card h2 { margin: 0 0 .5rem; font-size: 1.15rem; line-height: 1.3; }
.where-card > p { margin: 0 0 .75rem; }
/* Last paragraph (the link row) is pushed to the bottom so cards stay tidy even
   when the intro text is a different length. */
.where-card > p:last-child { margin: auto 0 0; padding-top: .75rem; }
/* Links in the bottom row render as small pill buttons. */
.where-card > p:last-child a {
  display: inline-block;
  margin: .3rem .4rem 0 0;
  padding: .32rem .8rem;
  font-size: .9rem;
  font-weight: 600;
  text-decoration: none;
  color: var(--brand-secondary);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: 999px;
}
.where-card > p:last-child a:hover {
  color: var(--brand-accent-strong);
  border-color: var(--border-strong);
}
@media (max-width: 640px) {
  .where-grid { grid-template-columns: 1fr; }
}

/* --- Sponsors (/our-sponsors/ and /sponsor/<slug>/) --------------------- */
/* Tier accents are their own tokens rather than inline colours: the tier
   badge, the card's top rule and the section heading all have to agree, and
   the dark scheme below only has to restate these three. */
:root {
  /* foojay.io's own sponsor-hero blue -- see .sponsors-hero. */
  --sponsors-hero-bg: #2f56ca;
  --tier-gold:   #c8912a;
  --tier-silver: #7d8894;
  --tier-bronze: #a4682f;
  /* DELIBERATELY NOT A METAL. Community Partner is not a rung on the
     gold/silver/bronze ladder -- it is the tier that pays nothing -- so it gets
     a colour from outside the family rather than a fourth, duller metal that
     would read as "below bronze". Teal also clears AA on white badge text
     (4.88) where the three metals do not, which is a gap worth not widening. */
  --tier-community-partner: #2f7d74;
}

/* Hero card. The blue, the 18px radius and the soft shadow are taken from
   foojay.io's own .sponsors__hero (#2F56CA) -- the banner artwork is drawn to
   sit on that blue, so it only reads correctly against it. Kept as a fixed
   colour in both schemes rather than a theme token for the same reason: the
   image can't follow a light/dark swap. */
.sponsors-hero {
  margin-bottom: 2.5rem;
  padding: clamp(1.75rem, 4vw, 3.25rem);
  border-radius: 18px;
  background: var(--sponsors-hero-bg);
  box-shadow: 0 18px 40px rgba(53, 98, 229, .18);
  color: #fff;
}
.prose .sponsors-hero h2 { margin-top: 0; color: #fff; }
.sponsors-hero p { color: rgba(255, 255, 255, .92); }
/* Any prose link inside the card (not the CTA button, which has its own fill). */
.sponsors-hero a:not(.sponsors-button) { color: #fff; text-decoration: underline; }
/* Text left, banner right. minmax(0,…) on the text column stops long prose from
   forcing the grid wider than its container. */
.sponsors-hero--split {
  display: grid;
  /* The banner is cropped to its artwork (see the note in _index.md), so the
     image column only needs the 235px the artwork actually occupies -- the
     text takes everything else. Before the crop, more than half the image
     column was flat #3057ca that happened to match the card, so widening the
     column made the layout emptier, not the artwork bigger. */
  grid-template-columns: minmax(0, 1fr) minmax(0, 235px);
  gap: clamp(1.5rem, 4vw, 3rem);
  align-items: center;
}
.sponsors-hero__text > :last-child { margin-bottom: 0; }
/* Capped at natural width so it never upscales into blur. */
.sponsors-hero__image img { width: 100%; max-width: 235px; height: auto; margin-inline: auto; }
@media (max-width: 720px) {
  /* Single column, and the banner drops below the text -- it's decorative, so
     it shouldn't push the intro and the CTA below the fold on a phone. */
  .sponsors-hero--split { grid-template-columns: 1fr; }
  .sponsors-hero__image { order: 2; }
}

.sponsors-button {
  display: inline-block;
  margin: .35rem .5rem .35rem 0;
  padding: .6rem 1.2rem;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: .95rem;
  text-decoration: none;
  color: var(--on-accent);
  background: var(--brand-accent);
  border: 1px solid var(--brand-accent);
  border-radius: 999px;
}
.sponsors-button:hover { background: var(--brand-accent-strong); border-color: var(--brand-accent-strong); color: var(--on-accent-strong); }
.sponsors-button--outline {
  color: var(--brand-secondary);
  background: transparent;
  border-color: var(--border-strong);
}
.sponsors-button--outline:hover { color: var(--brand-accent-strong); background: transparent; border-color: var(--brand-accent); }

.sponsors-tier { margin: 2.5rem 0; }
.sponsors-tier__head { display: flex; align-items: center; gap: 1rem; }
.prose .sponsors-tier__head h2 { margin: 0; }
/* The rule between heading and count is drawn by the flex gap filler so it
   stretches to whatever space is left, at any heading length. */
.sponsors-tier__head::after {
  content: ""; flex: 1 1 auto; height: 1px; background: var(--border); order: 1;
}
.sponsors-tier__count { order: 2; color: var(--ink-muted); font-size: .9rem; white-space: nowrap; }
.sponsors-tier__blurb { color: var(--ink-soft); font-size: .95rem; margin: .6rem 0 1.25rem; }
/* Tier marker: a disc in the heading's own colour, replacing the 🥈/🥉 medal
   emoji — those render with a literal "2" and "3" in most fonts, and the
   number-free medal emoji are all gold-coloured, so they can't carry a
   silver/bronze distinction. currentColor means the per-tier rules below are
   the only place a tier colour is named. */
.sponsors-tier__head h2::before {
  content: "";
  display: inline-block;
  width: .58em; height: .58em;
  margin-right: .5em;
  border-radius: 50%;
  background: currentColor;
  vertical-align: .02em;
}
.sponsors-tier--gold   .sponsors-tier__head h2 { color: var(--tier-gold); }
.sponsors-tier--silver .sponsors-tier__head h2 { color: var(--tier-silver); }
.sponsors-tier--bronze .sponsors-tier__head h2 { color: var(--tier-bronze); }
.sponsors-tier--community-partner .sponsors-tier__head h2 { color: var(--tier-community-partner); }

.sponsors-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 1.5rem;
}

.sponsor-card {
  display: flex; flex-direction: column;
  padding: 1.5rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-top: 3px solid var(--border-strong);
  border-radius: 14px;
  box-shadow: var(--shadow-sm);
}
.sponsor-card--gold   { border-top-color: var(--tier-gold); }
.sponsor-card--silver { border-top-color: var(--tier-silver); }
.sponsor-card--bronze { border-top-color: var(--tier-bronze); }
.sponsor-card--community-partner { border-top-color: var(--tier-community-partner); }
.sponsor-card__link { text-decoration: none; color: inherit; }
.prose .sponsor-card__title, .sponsor-card__title {
  margin: .9rem 0 0; font-size: 1.15rem; line-height: 1.3;
}
.sponsor-card__tagline { margin: .4rem 0 0; color: var(--ink-soft); font-size: .95rem; }
.sponsor-card__meta { display: flex; align-items: center; gap: .6rem; margin-top: .9rem; }
.sponsor-card__count { color: var(--ink-muted); font-size: .85rem; }
/* Pushed to the bottom so cards line up however long the tagline runs. */
.sponsor-card__actions {
  display: flex; flex-wrap: wrap; gap: .9rem;
  margin: auto 0 0; padding-top: .9rem;
  font-size: .9rem; font-weight: 600;
}

/* Logos are supplied by the sponsors and come as a mix of dark-on-transparent
   and light-on-transparent artwork, so the tile paints the per-sponsor
   `logoBackground` from frontmatter. object-fit: contain keeps very wide
   wordmarks and square icons on the same visual footing. */
.sponsor-logo {
  display: flex; align-items: center; justify-content: center;
  width: 100%; height: 84px;
  padding: .9rem;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}
.sponsor-logo img { max-width: 100%; max-height: 100%; object-fit: contain; }
.sponsor-logo__fallback { font-family: var(--font-display); font-size: 2rem; font-weight: 800; color: var(--ink-muted); }
.sponsor-logo--large { width: 160px; height: 120px; flex: 0 0 auto; }

.sponsor-badge {
  display: inline-block;
  padding: .2rem .65rem;
  font-family: var(--font-display);
  font-size: .78rem; font-weight: 700; letter-spacing: .02em;
  color: var(--on-ink); background: var(--ink-muted);
  border-radius: 999px;
}
.sponsor-badge--gold   { background: var(--tier-gold); }
.sponsor-badge--silver { background: var(--tier-silver); }
.sponsor-badge--bronze { background: var(--tier-bronze); }
.sponsor-badge--community-partner { background: var(--tier-community-partner); }

/* --- sponsor profile page --- */
.sponsor-profile {
  padding: 1.75rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 14px;
  box-shadow: var(--shadow-sm);
}
.sponsor-profile__head { display: flex; gap: 1.5rem; align-items: center; }
.sponsor-profile__intro h1 { margin: .5rem 0 0; font-size: clamp(1.6rem, 3.5vw, 2.2rem); }
.sponsor-profile__tagline { margin: .4rem 0 0; color: var(--ink-soft); font-size: 1.05rem; }
.sponsor-profile__links {
  display: flex; flex-wrap: wrap; gap: .5rem;
  margin-top: 1.25rem; padding-top: 1.25rem;
  border-top: 1px solid var(--border);
}
.sponsor-profile__links a {
  padding: .32rem .8rem;
  font-size: .9rem; font-weight: 600; text-decoration: none;
  color: var(--brand-secondary);
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: 999px;
}
.sponsor-profile__links a:hover { color: var(--brand-accent-strong); border-color: var(--border-strong); }

.sponsor-stats { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1.5rem 0; }
.sponsor-stat {
  flex: 1 1 120px;
  padding: 1rem;
  text-align: center;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
}
.sponsor-stat strong { display: block; font-family: var(--font-display); font-size: 1.75rem; line-height: 1.1; }
.sponsor-stat span { color: var(--ink-muted); font-size: .85rem; }

.sponsor-section { margin: 2.5rem 0; }
/* scroll-margin so an "On this page" jump doesn't park the heading under the
   sticky header -- same reason .prose h2/h3 carry it. Applied to the heading
   wherever it sits, since the articles heading is nested in .section-head. */
.sponsor-section h2 { font-size: 1.35rem; margin: 0 0 1rem; scroll-margin-top: calc(var(--header-h) + 1rem); }

.sponsor-topics { display: flex; flex-wrap: wrap; gap: .5rem; }
.sponsor-topic {
  padding: .3rem .8rem;
  font-size: .9rem; font-weight: 600; text-decoration: none;
  color: var(--brand-secondary);
  background: var(--brand-secondary-soft);
  border-radius: 999px;
}
.sponsor-topic:hover { color: var(--brand-accent-strong); }

.sponsor-authors-note { margin-top: 1.25rem; color: var(--ink-muted); font-size: .9rem; }
/* An `authors:` slug with no matching author page — surfaced rather than
   dropped, so a typo in a sponsor's list is visible instead of silent. */
.sponsor-authors-note__missing { color: var(--tier-bronze); border-bottom: 1px dotted currentColor; cursor: help; }

.section-head__count { color: var(--ink-muted); font-size: .9rem; }

.sponsors-cta {
  margin: 3rem 0 0;
  padding: 2rem;
  text-align: center;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: 14px;
}
.sponsors-cta h2 { margin: 0 0 .5rem; font-size: 1.35rem; }
.prose .sponsors-cta h2 { margin-top: 0; }
.sponsors-cta p { color: var(--ink-soft); }
/* The derived reach figures: number over label, in a centred row. It was a
   bullet list first, and five one-line fragments in a centred card read as a
   shopping list -- the figure is the thing a sponsor weighs, so the figure is
   what gets the size. Still a <ul> in the markup, so it is still a list of five
   to a screen reader.
   auto-fit rather than a fixed column count: four items sit in one row on a
   desktop and fold to 2x2 on a phone with nothing to declare, and a figure that
   drops out (no data/views.json locally) re-centres the rest instead of leaving
   a hole. Both selectors are class-on-class because .prose ul and .prose li + li
   are (0,1,1) and beat a single class -- the specificity trap that has already
   bitten the series nav and the sitemap tables. */
.prose .sponsors-cta__stats {
  display: grid;
  /* ONE ROW OF FOUR ON A DESKTOP, and the arithmetic has to allow it: auto-fit
     fits floor((width + gap) / (min + gap)) columns, so the previous
     8.5rem min inside a 34rem box worked out at THREE and dropped the fourth
     figure onto a line of its own. 7rem min in a 40rem box gives 5 slots for 4
     items, so all four sit in one row; at 390px the same rule folds them to
     2x2 with nothing declared. */
  grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
  gap: 1rem 1.25rem;
  max-width: 40rem;
  margin: 1.4rem auto 1.6rem;
  padding: 0;
  list-style: none;
}
.prose .sponsors-cta__stats li {
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .1rem;
}
.prose .sponsors-cta__stats strong {
  font-family: var(--font-display);
  font-size: 1.6rem;
  font-weight: 700;
  line-height: 1.1;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
.prose .sponsors-cta__stats span {
  font-size: .82rem;
  color: var(--ink-muted);
  text-align: center;
  text-wrap: balance;
}

.sponsors-cta__links { margin-bottom: 0; }

@media (max-width: 560px) {
  .sponsor-profile__head { flex-direction: column; align-items: flex-start; }
  .sponsor-logo--large { width: 100%; }
}

/* Tier accents in the dark scheme: the light-mode values are metallic mid-tones
   that go muddy on a dark surface, so they're lifted rather than reused. */
:root[data-theme="dark"] {
  --tier-gold:   #e0b354;
  --tier-silver: #a7b3c0;
  --tier-bronze: #cd8b4e;
  --tier-community-partner: #7fd0c4;
}

/* ---------------------------------------------------------------------------
   Advisory board: /board/ and the per-member pages under it.

   Deliberately close to the sponsor styling (same card rhythm, same logo-tile
   treatment) without reusing the .sponsor-* classes: those carry tier accents
   and article counts a board member has neither of, so sharing them would mean
   a tier colour or a badge leaking onto a page that has no tiers. */

.board-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 1.5rem;
  margin-top: 1.5rem;
}
.board-card {
  display: flex; flex-direction: column;
  padding: 1.5rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-top: 3px solid var(--brand-accent);
  border-radius: 14px;
  box-shadow: var(--shadow-sm);
}
.board-card__link { text-decoration: none; color: inherit; }
.board-card__title { margin: .9rem 0 0; font-size: 1.15rem; line-height: 1.3; }
.board-card__desc { margin: .5rem 0 0; color: var(--ink-soft); font-size: .95rem; }
/* Pushed to the bottom so the cards line up however long a description runs. */
.board-card__actions {
  display: flex; flex-wrap: wrap; gap: .9rem;
  margin: auto 0 0; padding-top: .9rem;
  font-size: .9rem; font-weight: 600;
}

/* Logos are the members' own artwork, a mix of dark-on-transparent and
   light-on-transparent, so the tile paints the optional `logoBackground` from
   frontmatter. object-fit: contain keeps a very wide wordmark and a square
   icon on the same visual footing. */
.board-logo {
  display: flex; align-items: center; justify-content: center;
  width: 100%; height: 84px;
  padding: .9rem;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}
.board-logo img { max-width: 100%; max-height: 100%; object-fit: contain; }
.board-logo__fallback { font-family: var(--font-display); font-size: 2rem; font-weight: 800; color: var(--ink-muted); }
.board-logo--large { width: 180px; height: 130px; flex: 0 0 auto; }
.board-logo--mini { width: 52px; height: 52px; padding: .35rem; flex: 0 0 auto; border-radius: 8px; }

/* Same reading measure as .page-single: a member page is mostly prose
   (the About text and the quote), and the mini-list at the foot wraps. */
.board-single { max-width: var(--measure); }

.board-back { margin: 0 0 1.25rem; }
.board-back a { font-weight: 600; font-size: .95rem; }
.board-back--foot { margin: 2.5rem 0 0; }

.board-profile {
  padding-bottom: 1.5rem;
  border-bottom: 1px solid var(--border);
}
.board-profile__head { display: flex; gap: 1.5rem; align-items: center; }
.board-profile__intro h1 { margin: .5rem 0 0; font-size: clamp(1.6rem, 3.5vw, 2.2rem); }
.board-profile__views { margin-top: .5rem; }
.board-profile__links {
  display: flex; flex-wrap: wrap; gap: .6rem;
  margin-top: 1.25rem;
}
.board-profile__links a {
  padding: .4rem .85rem;
  font-size: .9rem; font-weight: 600;
  border: 1px solid var(--border); border-radius: 999px;
  text-decoration: none;
}
.board-profile__links a:hover { color: var(--brand-accent-strong); border-color: var(--border-strong); }

.board-badge {
  display: inline-block;
  padding: .2rem .65rem;
  font-family: var(--font-display);
  font-size: .78rem; font-weight: 700; letter-spacing: .02em;
  color: var(--on-ink); background: var(--ink-muted);
  border-radius: 999px;
}

.board-section { margin: 2.5rem 0; }
.board-section h2 { font-size: 1.35rem; margin: 0 0 1rem; }
.prose.board-section h2 { margin-top: 0; }

/* The member's statement about Foojay. A <figure>, so the attribution is a
   real <figcaption> rather than a paragraph that happens to sit underneath. */
.board-quote { margin: 0; }
.board-quote blockquote {
  margin: 0;
  padding: 1.25rem 1.5rem;
  background: var(--surface-sunken);
  border-left: 3px solid var(--brand-accent);
  border-radius: 0 10px 10px 0;
  font-size: 1.05rem; line-height: 1.6;
}
.board-quote blockquote p { margin: 0; }
.board-quote blockquote p + p { margin-top: .9rem; }
.board-quote figcaption { margin-top: .75rem; color: var(--ink-muted); font-size: .9rem; }

.board-sponsor-note { color: var(--ink-soft); }

.board-mini-list { display: flex; flex-wrap: wrap; gap: 1rem; }
.board-mini {
  display: flex; align-items: center; gap: .6rem;
  padding: .5rem .85rem .5rem .5rem;
  text-decoration: none; color: inherit;
  border: 1px solid var(--border); border-radius: 999px;
  font-size: .9rem; font-weight: 600;
}
.board-mini:hover { border-color: var(--border-strong); }

@media (max-width: 560px) {
  .board-profile__head { flex-direction: column; align-items: flex-start; }
  .board-logo--large { width: 100%; }
}

/* ---------------------------------------------------------------------------
   Sitemap: /sitemap/ -- the HTML sitemap linked from the footer.

   A long page of nothing but links, so the styling's whole job is scanning:
   multi-column where the entries are short and independent (pedia, sponsors,
   authors), single column where order carries meaning (the page tree's
   indentation), and a table where each entry has FIELDS to compare -- the
   articles, which carry a date, an author and a read count as well as a
   title. */

.sitemap__section {
  margin-top: 2.5rem;
  /* So a jump link doesn't park the heading under the sticky site header --
     same reason .prose h2 and .sponsor-section h2 carry one. */
  scroll-margin-top: calc(var(--header-h) + 1rem);
}
.sitemap__section > h2 {
  padding-bottom: .5rem;
  border-bottom: 1px solid var(--border);
}

.sitemap__list { list-style: none; margin: 1rem 0 0; padding: 0; }
.sitemap__list--cols { columns: 4 200px; column-gap: 2rem; }

.sitemap__item {
  /* break-inside stops a multi-column layout splitting a wrapped title. */
  break-inside: avoid;
  padding: .18rem 0;
  line-height: 1.45;
}
.sitemap__item a { text-decoration: none; }
.sitemap__item a:hover { text-decoration: underline; }

/* The page tree: depth comes from the file path, not from nested <ul>s. */
.sitemap__item--d1 { padding-left: 1.25rem; }
.sitemap__item--d2 { padding-left: 2.5rem; }
.sitemap__item--d1, .sitemap__item--d2 { color: var(--ink-muted); }

/* The Articles table (themes/foojay/layouts/sitemap/single.html): 2000+ rows
   of date / author / views / title, sortable by any of the four. Styled from
   scratch rather than via `.prose table`, because this page is a scanning
   surface and not body copy -- it deliberately isn't inside .prose. */
.sitemap-table-wrap { margin-top: 1rem; }
.sitemap-table { width: 100%; border-collapse: collapse; font-size: .95rem; }
.sitemap-table th, .sitemap-table td {
  text-align: left; padding: .45rem .75rem;
  border-bottom: 1px solid var(--border);
  vertical-align: baseline;
}
/* Sticky, because the table is 2000 rows: without it, scrolling into 2019
   leaves the reader with four unlabelled columns and no way back to the sort
   buttons short of going to the top. Needs an OPAQUE background (the rows
   scroll under it) and one that follows the theme, hence the token rather than
   white. Turned off below, where the table scrolls sideways instead -- an
   overflow container becomes the sticky positioning context, so `top` would be
   measured against the table itself and the header would never stick. */
.sitemap-table thead th {
  position: sticky; top: var(--header-h); z-index: 5;
  background: var(--page-bg);
  border-bottom: 1px solid var(--border-strong);
  font-family: var(--font-display); font-size: .78rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: .08em;
  color: var(--ink-muted); white-space: nowrap;
}
.sitemap-table thead th[aria-sort="ascending"],
.sitemap-table thead th[aria-sort="descending"] { color: var(--ink); }
.sitemap-table tbody tr:hover { background: var(--surface-sunken); }

.sitemap-table__date { white-space: nowrap; color: var(--ink-muted); font-variant-numeric: tabular-nums; }
.sitemap-table__author { color: var(--ink-muted); }
/* Every numeric column in either table -- reads, and an author's article
   count. `td.` / `th.`, not the bare class: `.sitemap-table td` above is a
   class PLUS a type selector and so outranks a single class -- the same
   specificity trap the .prose rules have. Written as a single class this
   silently kept text-align: left and the digits didn't line up. Right-aligned
   so magnitudes are comparable down the column, with extra room on the right
   so the numbers don't crowd whatever sits against them. */
.sitemap-table th.sitemap-table__num,
.sitemap-table td.sitemap-table__num {
  text-align: right; padding-right: 1.6rem; white-space: nowrap;
  font-variant-numeric: tabular-nums; color: var(--ink-muted);
}

/* The authors table's links column reuses partials/author-social.html -- the
   same pills, labels and RSS entry the profile page and the "Written by"
   callout show, so there is one definition of an author's links. Only the
   scale changes: eight full-size pills per row over 344 rows is a wall. */
.sitemap-table .author-social { gap: .3rem; }
.sitemap-table .author-social a { font-size: .76rem; padding: .08rem .5rem; }
.sitemap-table__name a { color: var(--ink); font-weight: 600; text-decoration: none; }
.sitemap-table__name a:hover { text-decoration: underline; }
/* A post the counter has never seen. Present but quiet -- it is the absence of
   a number, not a zero. */
.sitemap-table__none { opacity: .45; }
.sitemap-table__title a { color: var(--ink); font-weight: 600; text-decoration: none; }
.sitemap-table__title a:hover { text-decoration: underline; }

/* The sort control. Created by static/js/sortable-table.js, so none of this
   renders without JavaScript -- and the heading it wraps is then plain text
   rather than a button that does nothing. Inherits the <th>'s own typography
   so a sortable column looks identical to an unsortable one apart from the
   arrow. */
.sort-btn {
  display: inline-flex; align-items: center; gap: .35rem;
  margin: 0; padding: 0; border: 0; background: none;
  font: inherit; letter-spacing: inherit; text-transform: inherit; color: inherit;
  cursor: pointer;
}
.sort-btn:hover { color: var(--brand-accent-strong); }
/* The neutral glyph is the affordance: it says "this column sorts" before
   anything is clicked, which a bare label does not. */
th[data-sort-type] .sort-btn::after { content: "\2195"; opacity: .4; }
th[aria-sort="ascending"] .sort-btn::after { content: "\2191"; opacity: 1; }
th[aria-sort="descending"] .sort-btn::after { content: "\2193"; opacity: 1; }

@media (max-width: 700px) {
  .sitemap__list--cols { columns: 1; }
}

/* Four columns don't fit a phone, and squeezing them turns the title into a
   two-word-per-line ribbon. Scroll sideways instead, which keeps every column
   readable -- see the sticky note above for why the header stops sticking. */
@media (max-width: 60rem) {
  /* tabindex="0" in the template, for the same reason .table-scroll carries it:
     without it this box scrolls for a mouse and not for a keyboard. */
  .sitemap-table-wrap { overflow-x: auto; overscroll-behavior-x: contain; }
  .sitemap-table { min-width: 44rem; }
  .sitemap-table thead th { position: static; }
}

/* ---------------------------------------------------------------------------
   Other tutorials (/java-quick-start/other-tutorials/).

   Deliberately the same card rhythm as the advisory board without reusing the
   .board-* classes -- those carry the board's accent border and a "Read more"
   into an internal profile, neither of which an outbound tutorial link has.
   The art tile is taller than a board logo because these are portraits and
   course screenshots rather than wordmarks. */

.tutorial-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 1.5rem;
  margin-top: 1.5rem;
}

.tutorial-card {
  display: flex; flex-direction: column;
  padding: 1.5rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-top: 3px solid var(--brand-accent);
  border-radius: 14px;
  box-shadow: var(--shadow-sm);
}
.tutorial-card__link { text-decoration: none; color: inherit; }

.tutorial-card__art {
  display: flex; align-items: center; justify-content: center;
  width: 100%; height: 140px;
  padding: .9rem;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}
.tutorial-card__art img { max-width: 100%; max-height: 100%; object-fit: contain; }

.tutorial-card__title { margin: .9rem 0 0; font-size: 1.15rem; line-height: 1.3; }
.tutorial-card__by { margin: .25rem 0 0; color: var(--ink-muted); font-size: .9rem; }
.tutorial-card__desc { margin: .6rem 0 0; color: var(--ink-soft); font-size: .95rem; }
/* Pushed to the bottom so the links line up however long a description runs. */
.tutorial-card__actions {
  display: flex; flex-wrap: wrap; gap: .9rem;
  margin: auto 0 0; padding-top: .9rem;
  font-size: .9rem; font-weight: 600;
}

/* ---------------------------------------------------------------------------
   Multi-page series navigation (partials/series-progress.html + series-nav.html).

   Used by the Java Quick Start tutorial steps, and by any folder of ordered
   pages that gives its pages a `weight`. Two halves answering two different
   questions: the bar at the top says where you are, the cards at the bottom say
   where to go next. The old markdown carried the same two links in both
   places. */

/* The tick links carry their step's title for a screen reader; sighted readers
   get it from the title attribute on hover. No such utility existed yet. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; border: 0;
}

.series-progress {
  margin: 0 0 2rem;
  padding-bottom: 1.25rem;
  border-bottom: 1px solid var(--border);
}
.series-progress__label {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: .5rem;
  margin: 0 0 .6rem;
  font-size: .9rem;
}
.series-progress__label a { font-weight: 600; text-decoration: none; }
.series-progress__label a:hover { text-decoration: underline; }
.series-progress__step { color: var(--ink-muted); }
.series-progress__step::before { content: "·"; margin-right: .5rem; }

/* One tick per step, each a link to it -- a progress bar you can also navigate
   with. Ticks flex to share the width, so 11 steps or 4 both fill the row. */
.series-progress__bar {
  display: flex; gap: 4px;
  list-style: none; margin: 0; padding: 0;
}
.series-progress__tick { flex: 1 1 0; }
.series-progress__tick a {
  display: block; height: 6px;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  border-radius: 999px;
}
.series-progress__tick.is-done a { background: var(--brand-accent); border-color: var(--brand-accent); }
.series-progress__tick.is-current a { box-shadow: 0 0 0 3px var(--brand-accent-soft); }
.series-progress__tick a:hover { border-color: var(--border-strong); }

/* Two fixed columns rather than auto-fit: the first and last steps have only
   one card, and auto-fit collapses to a single column for them -- so "Next"
   stretched the full width on step 1 and sat on the wrong side. The empty
   .series-nav__spacer holds the missing cell open so prev is always left and
   next always right, whichever one is absent. */
.series-nav {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
  margin-top: 2.5rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--border);
}
.series-nav__card {
  display: flex; flex-direction: column; gap: .3rem;
  padding: 1rem 1.25rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  text-decoration: none; color: inherit;
}
.series-nav__card:hover { border-color: var(--brand-accent); }
/* The "next" card is the one a reader wants at the end of a step, so it sits
   on the right and reads right-aligned even when "previous" is absent. */
.series-nav__card--next { text-align: right; grid-column: 2; }
.series-nav__dir { font-size: .8rem; font-weight: 700; color: var(--brand-accent); text-transform: uppercase; letter-spacing: .04em; }
.series-nav__title { font-weight: 600; line-height: 1.35; }
.series-nav__spacer { display: block; }

/* Both navs render INSIDE .prose (they are part of the article, so they scroll
   with it), and .prose styles lists and links generically. Three of its rules
   reach in here and have to be beaten on specificity -- a single class is 0-1-0
   and `.prose li + li` is 0-1-1, so these are written class-on-class rather
   than bumped with !important:
     .prose ul, .prose ol { padding-left: 1.4rem }  - indents the whole bar
     .prose li + li { margin-top: .35rem }          - pushed every tick EXCEPT
                                                      the first one down, so the
                                                      first step sat visibly
                                                      higher than the other ten
     .prose a { ... }                               - underlines the nav cards */
.series-progress .series-progress__bar { padding-left: 0; margin: 0; }
.series-progress .series-progress__tick + .series-progress__tick { margin-top: 0; }
.series-nav .series-nav__card { text-decoration: none; }

@media (max-width: 560px) {
  .series-nav { grid-template-columns: 1fr; }
  .series-nav__card--next { grid-column: 1; text-align: left; }
  .series-nav__spacer { display: none; }
}

/* ---------------------------------------------------------------------------
   Banner adverts (content/ads/, partials/ad-slide.html).

   Two placements, one card: the home page carousel below the most recent post
   (.ad--wide) and one banner in the sidebar above the sponsor tiers
   (.ad--side). The ground colour and the text colour come in as --ad-bg and
   --ad-ink from the template -- --ad-ink is DERIVED from --ad-bg by
   partials/ad-ink.html, so the pair is always readable and there is no second
   colour stored per banner.

   Everything inside the card is `color: inherit` off --ad-ink rather than a
   palette token: the ground is the ADVERTISER'S colour, not one of ours, so
   --ink-soft or --brand-accent-strong on top of it is a contrast pair nothing
   has checked. This is the one block on the site that deliberately opts out of
   the theme's ink ramp, and it is also why it does not change between light and
   dark: an advertiser's banner is the same artwork in both.
   --------------------------------------------------------------------------- */
.ad {
  background: var(--ad-bg, var(--surface-sunken));
  color: var(--ad-ink, var(--ink));
  border-radius: var(--radius);
  overflow: hidden;
  display: flex;
  min-width: 0;
}
.ad__img { flex: none; display: flex; align-items: center; justify-content: center; }
.ad__img img { display: block; width: 100%; height: 100%; object-fit: contain; }
.ad__body { display: flex; flex-direction: column; min-width: 0; }
.ad__title {
  font-family: var(--font-display); font-weight: 700; margin: 0;
  color: inherit; text-wrap: balance;
}
.ad__desc { margin: 0; color: inherit; opacity: .85; }
.ad__foot { margin-top: auto; display: flex; align-items: center; flex-wrap: wrap; gap: .5rem .9rem; }

/* The button is the site's accent, not the advertiser's ground: it has to read
   as a button on any colour a sponsor sends, and --brand-accent is the one
   brand fill that does not invert between the schemes (8.57 on the dark
   surface, and --on-accent is correct on it in both). */
/* EVERY BUTTON RULE IS SCOPED UNDER .ad, AND THAT IS SPECIFICITY, NOT TIDINESS.

   A banner in the sidebar renders inside <ul class="ad-carousel__track"><li>,
   so `.sidebar li a` (0,1,2) matches the buttons and outranks a lone `.ad__btn`
   (0,1,0). Two visible faults came from that one collision, and both looked like
   separate bugs:

     display: block   killed the flex row, so the arrow dropped onto a second
                      line under its own label.
     color: --ink-soft  replaced the button's own label colour. In dark mode
                      --ink-soft is #c3cfd5, a near-white, on the light blue
                      --brand-accent fill: about 1.5:1, i.e. an invisible label.

   `.ad .ad__btn` is (0,2,0) and wins on class count regardless of source order.
   Same trap and same fix as `.featured-side__social` further up, and the same
   one the `.prose` rules carry -- a single class does not beat a class plus two
   type selectors. Scope any new banner rule the same way. */
.ad .ad__btn {
  display: inline-flex; align-items: center; gap: .4rem;
  background: var(--brand-accent); color: var(--on-accent);
  font-family: var(--font-display); font-weight: 700; font-size: .9rem;
  padding: .5rem .9rem; border-radius: 999px; text-decoration: none;
  /* nowrap on both axes: the label must not break, and the arrow must not be
     pushed onto a line of its own when the button is squeezed. */
  white-space: nowrap; flex-wrap: nowrap;
}
.ad .ad__btn:hover { background: var(--brand-accent-strong); color: var(--on-accent-strong); text-decoration: none; }
/* The optional second button. Outlined in the banner's OWN ink rather than a
   palette token, for the same reason the copy is: the ground is the
   advertiser's colour, so currentColor is the only border that is guaranteed to
   read on it. Keeping the fill for the primary button is what stops two equal
   buttons competing -- this one is the softer of the two by construction. */
.ad .ad__btn--ghost {
  background: none; color: inherit;
  border: 1px solid currentColor;
}
.ad .ad__btn--ghost:hover { background: rgba(127, 127, 127, .16); color: inherit; }
.ad .ad__btn-arrow { flex: none; }
.ad__label {
  display: inline-flex; align-items: center; gap: .3rem; margin: 0;
  font-size: .72rem; text-transform: uppercase; letter-spacing: .07em;
  color: inherit; opacity: .75; white-space: nowrap;
}
.ad__label svg { flex: none; }

/* --- Home page carousel (partials/ads-carousel.html) --------------------- */

/* The band sits between the lead card and the post grid in .content-col, which
   is a plain block with no gap of its own -- so without this the first row of
   cards butted straight up against the banner. The lead card above supplies the
   space on top with its own margin-bottom; this is the same clamp, so the gap
   above and below the banner matches and the column keeps one rhythm. */
.ad-band { margin-bottom: clamp(1.5rem, 3vw, 2.25rem); }

/* One slide at a time, scroll-snapped. This IS the carousel: with JavaScript
   off it swipes and trackpad-scrolls, and ad-carousel.js only adds the arrows,
   the dots and the autoplay on top. */
.ad-carousel { position: relative; }
/* .ad-carousel .ad-carousel__track, not the bare class: in the sidebar this <ul>
   is matched by `.sidebar ul` (0,1,1), which sets gap: .1rem -- and a lone class
   (0,1,0) loses to it, so the gap survived and left a sliver of the next banner
   beside the snapped one. */
.ad-carousel .ad-carousel__track {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-auto-flow: column; grid-auto-columns: 100%;
  overflow-x: auto; overscroll-behavior-x: contain;
  /* Explicitly hidden: `overflow-x: auto` alone computes overflow-y to auto too,
     which is what would add a vertical scrollbar once the height below starts
     clipping a taller neighbouring slide. */
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  /* The row is one banner wide, so a horizontal scrollbar under it is chrome for
     a control the arrows already provide. */
  scrollbar-width: none;
  /* Each slide keeps its NATURAL height instead of being stretched to the
     tallest one. A grid row is as tall as its tallest item, so without this
     every banner was drawn at the height of the longest copy in the set -- and
     the arrows, centred on the container, floated over the empty space below a
     short one. ad-carousel.js then sets an explicit height per slide (see
     `fit()`), and this transition is what keeps that from being a jump.

     With JavaScript off there is no explicit height and the container falls
     back to the tallest slide, which is the correct degradation: every banner is
     fully visible, just with more room under the short ones. */
  align-items: start;
  transition: height .28s ease;
  /* Explicitly 0, because in the sidebar this <ul> is also matched by
     `.sidebar ul` (0,1,1), which sets gap: .1rem -- enough to leave a sliver of
     the next banner showing beside the snapped one. */
  gap: 0;
}
@media (prefers-reduced-motion: reduce) {
  .ad-carousel .ad-carousel__track { transition: none; }
}
.ad-carousel .ad-carousel__track::-webkit-scrollbar { display: none; }
.ad-carousel__slide { scroll-snap-align: start; min-width: 0; }

/* Extra inline padding leaves the overlaid arrows their own gutter, so a 2rem
   button at the left edge never sits on top of the creative or the CTA. */
.ad--wide {
  align-items: center; gap: clamp(1rem, 2.5vw, 2rem);
  padding: clamp(1.1rem, 2.2vw, 1.6rem);
  padding-inline: clamp(2.9rem, 3.5vw, 3.4rem);
}
.ad--wide .ad__img { width: clamp(76px, 12vw, 132px); }
.ad--wide .ad__body { gap: .5rem; }
.ad--wide .ad__title { font-size: clamp(1.05rem, 1.9vw, 1.5rem); line-height: 1.25; }
.ad--wide .ad__desc { font-size: .93rem; line-height: 1.5; }
.ad--wide .ad__foot { padding-top: .3rem; }

/* The arrows sit OVER the banner at its left and right edges, not in a row
   below it. Below the card they read as navigation for the whole section rather
   than for the banner, and they cost a strip of vertical space on both
   placements -- which the sidebar column does not have to give. They are
   `hidden` in the markup and revealed by the script.

   A translucent white ground rather than a palette token: they overlay the
   ADVERTISER'S colour, which can be anything, so a fixed --surface pill would
   float on some banners and vanish into others. The dark glyph plus the light
   ground survives both, and the backdrop-blur keeps the arrow legible over a
   busy creative. */
.ad-carousel { position: relative; }
.ad-carousel__arrow {
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
  display: inline-flex; align-items: center; justify-content: center;
  width: 2rem; height: 2rem; padding: 0;
  border: 0; border-radius: 999px; cursor: pointer;
  background: rgba(255, 255, 255, .82); color: #06232f;
  -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
  box-shadow: var(--shadow-sm);
}
.ad-carousel__arrow:hover { background: #fff; }
.ad-carousel__arrow--prev { left: .5rem; }
.ad-carousel__arrow--next { right: .5rem; }

/* Smaller arrows in the 300px column, and tucked tighter to the edge. */
/* Smaller arrows in the ~300px column, and the card's side padding is DERIVED
   from them rather than typed as a number: the gutter the copy needs is exactly
   "how far in the arrow reaches, plus breathing room", so the two must move
   together. They had drifted apart -- a 1.6rem arrow inset .25rem reaches
   1.85rem in, against 1.9rem of padding, leaving 0.05rem of clearance and copy
   that looked pressed against the buttons. Resize an arrow here and the text
   steps back on its own. */
.ad-carousel--side {
  --ad-arrow-size: 1.6rem;
  --ad-arrow-inset: .25rem;
  --ad-arrow-gap: .55rem;
}
.ad-carousel--side .ad-carousel__arrow {
  width: var(--ad-arrow-size, 1.6rem); height: var(--ad-arrow-size, 1.6rem);
}
.ad-carousel--side .ad-carousel__arrow--prev { left: var(--ad-arrow-inset, .25rem); }
.ad-carousel--side .ad-carousel__arrow--next { right: var(--ad-arrow-inset, .25rem); }

/* The pause control (WCAG 2.2.2 -- see partials/ad-track.html). Same pill as
   the arrows and for the same reason: it overlays the advertiser's colour.
   Bottom-left rather than centred, so it is clearly the whole band's control
   and not a third way to step through the slides. 2rem square in both
   placements, matching the arrows' 2rem, which is also the smallest a target
   should be. */
.ad-carousel__pause {
  position: absolute; z-index: 2;
  left: .5rem; bottom: .5rem;
  display: inline-flex; align-items: center; justify-content: center;
  width: 2rem; height: 2rem; padding: 0;
  border: 0; border-radius: 999px; cursor: pointer;
  background: rgba(255, 255, 255, .82); color: #06232f;
  -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px);
  box-shadow: var(--shadow-sm);
}
.ad-carousel__pause:hover { background: #fff; }
.ad-carousel--side .ad-carousel__pause {
  left: var(--ad-arrow-inset, .25rem); bottom: var(--ad-arrow-inset, .25rem);
  width: var(--ad-arrow-size, 1.6rem); height: var(--ad-arrow-size, 1.6rem);
}
/* Which glyph shows follows the state the script writes, so there is nothing
   to toggle from JavaScript beyond the one attribute (and the label). */
.ad-carousel__pause .ad-carousel__icon-play { display: none; }
.ad-carousel__pause[data-paused="true"] .ad-carousel__icon-play { display: block; }
.ad-carousel__pause[data-paused="true"] .ad-carousel__icon-pause { display: none; }


/* --- Sidebar slot (partials/ads-widget.html) ----------------------------- */

/* No .widget chrome: the card supplies its own ground and padding, and a
   heading over it would repeat what the "Sponsored Content" label already
   says. */
/* .sidebar .widget-ad, not .widget-ad: `.sidebar .widget` is (0,2,0) and a lone
   class is (0,1,0), so the banner was being drawn inside the sunken, bordered,
   1.15rem-padded widget box on top of its own ground -- a frame around a card
   that already is one. Third instance of the same collision in this block; see
   the note on .ad .ad__btn. */
.sidebar .widget-ad { padding: 0; background: none; border: 0; }
.ad--side {
  flex-direction: column; gap: .6rem; padding: 1rem;
  /* The custom properties are set on .ad-carousel--side above and inherit down
     to here, so this stays correct if the arrows are ever resized. */
  padding-inline: calc(var(--ad-arrow-inset, .25rem)
                     + var(--ad-arrow-size, 1.6rem)
                     + var(--ad-arrow-gap, .55rem));
}
.ad--side .ad__img { width: 100%; max-height: 140px; align-self: center; }
.ad--side .ad__img img { max-height: 140px; width: auto; max-width: 100%; }
.ad--side .ad__title { font-size: 1rem; line-height: 1.3; }
.ad--side .ad__desc {
  font-size: .85rem; line-height: 1.45;
  /* The sidebar is a narrow column that also carries the TOC and the sponsor
     tiers; a 240-character pitch would push the sponsors off the screen. */
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
/* In a ~300px column the buttons and the label cannot share a row: the button
   is a flex line of label + arrow, and once the row had to wrap the arrow
   dropped onto a second line on its own, under its own label. Stacking the foot
   makes each button a full-width block with the arrow inline beside its text,
   which is also a bigger tap target than a shrink-wrapped pill. */
/* margin-top overrides .ad__foot's `margin-top: auto`, which does nothing here
   anyway: the side card's height follows its content, so there is no free space
   for an auto margin to absorb and the buttons sat tight under the description.
   The wide variant keeps `auto`, where it does push the foot to the bottom of a
   two-column card. */
.ad--side .ad__foot {
  flex-direction: column; align-items: stretch; gap: .45rem;
  margin-top: .55rem;
}
.ad--side .ad__btn {
  font-size: .82rem; padding: .45rem .8rem;
  justify-content: center;
}
.ad--side .ad__label { justify-content: center; margin-top: .1rem; }

/* On a phone the wide banner stacks: the creative and two columns of text at
   360px leaves the headline four words to a line. */
@media (max-width: 34rem) {
  .ad--wide { flex-direction: column; align-items: flex-start; }
  .ad--wide .ad__img { width: auto; max-width: 55%; align-self: center; }
}

/* --------------------------------------------------- edit this page ----
   The "Found a mistake?" line at the foot of an article
   (partials/page-source.html).

   Muted and rule-separated: it is an offer, not part of what the author wrote,
   and it sits between the article and whatever the template puts after it
   (the author callout, the back link). The class-on-class selector for the
   link is deliberate -- .prose a is 0-1-1 and outranks a bare .page-source a,
   so on the templates whose article carries .prose the colour would otherwise
   lose. The underline stays: it is a link in running text, which is exactly
   what the .prose rule exists for. */
.page-source {
  margin-top: 2.75rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
  font-size: .92rem;
  color: var(--ink-muted);
}
.page-source a, .prose .page-source a { color: var(--brand-accent-strong); }

/* ------------------------------------------------- episode transcript ----
   The machine transcript under a podcast episode (posts/single.html, written
   by scripts/fetch/PodcastTranscripts.java).

   It is an ordinary section of the article -- .prose, an <h2> that the "On this
   page" panel links to -- rather than a collapsed <details>, because a reader
   comes to an episode page FOR the transcript and find-in-page does not look
   inside a closed <details>. So there is little to style: a rule off the rest
   of the article, the machine-generated note under the heading, and the
   timestamps. */
.transcript {
  margin-top: 3rem;
  padding-top: .5rem;
  border-top: 1px solid var(--border);
}
/* The h2 already carries .prose h2's margin-top, which on top of the section's
   own padding leaves the rule floating a long way above the heading. */
.transcript > h2:first-child { margin-top: 1.5rem; }
.transcript__note { font-size: .9rem; color: var(--ink-muted); margin-top: -.35rem; }
/* The timestamp opening each paragraph. Tabular figures so a column of them
   does not wobble, and the accent's readable sibling because at .8rem this is
   text and not a fill. */
.transcript p > strong:first-child {
  font-family: var(--font-mono); font-size: .8rem; font-weight: 600;
  color: var(--brand-accent-strong); font-variant-numeric: tabular-nums;
  margin-right: .15rem;
}

/* A notice on a migrated article whose subject was the old WordPress site --
   see themes/foojay/layouts/shortcodes/legacy-notice.html for which posts get
   one and why it is a shortcode.

   It borrows .highlight-panel's ground rather than introducing a colour that
   exists for one block only: --brand-accent-soft is the logo blue's own tint,
   and it retunes itself for dark, where the token is a translucent blue over
   the page instead of a pale solid. --ink on it measures 9.4:1.

   The left border rather than a full one, and no icon: this is an editorial
   aside, not a warning the reader has to act on. A red or amber box would read
   as "this article is wrong", which is not the claim -- the claim is that it
   was written about a website that has since changed. */
.prose .legacy-notice {
  margin: 1.5rem 0;
  padding: .85rem 1.1rem;
  background: var(--brand-accent-soft);
  border-left: 3px solid var(--brand-accent);
  border-radius: 0 var(--radius) var(--radius) 0;
  color: var(--ink);
}
/* Class-on-class: `.prose p` is 0-1-1 and would otherwise win on the margin,
   leaving the single paragraph pushed away from its own box. */
.prose .legacy-notice p { margin: 0; font-size: .95rem; }

/* --- /jvm-weekly/ : the monthly Foojay roundup in JVM Weekly ---------------
   layouts/jvm-weekly/single.html, from data/jvm-weekly.yaml.

   One card per edition rather than a grid: an edition is not a card-sized
   thing -- it carries a lead article with its own summary and byline plus a
   list of everything else it covered, which is several lines whatever width it
   is given. Stacked full-width, the lead article gets a real thumbnail and the
   "also covered" list stays readable instead of being clipped to two lines.

   The accent top border is .tutorial-card's, deliberately: both are pages
   whose entries send the reader somewhere, and a second treatment for the same
   idea is a second thing to keep in step. */
.review-edition {
  margin: 0 0 1.75rem;
  padding: 1.5rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-top: 3px solid var(--brand-accent);
  border-radius: 14px;
  box-shadow: var(--shadow-sm);
}
.review-edition__head {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem;
  color: var(--ink-muted); font-size: .9rem;
}
.review-edition__vol {
  padding: .1rem .5rem;
  background: var(--surface-sunken);
  border-radius: 999px;
  font-size: .8rem; font-weight: 600;
}
.review-edition__title { margin: .5rem 0 0; font-size: 1.35rem; line-height: 1.3; }
.review-edition__subtitle { margin: .5rem 0 0; color: var(--ink-soft); }

/* The lead article. Two columns above 40rem, stacked below -- the thumbnail is
   a fixed 220px column rather than a percentage so it cannot shrink to a strip
   next to a long title. */
.review-main {
  display: flex; gap: 1.25rem;
  margin: 1.25rem 0 0;
  padding: 1.25rem;
  background: var(--surface-sunken);
  border-radius: var(--radius);
}
.review-main__thumb { flex: 0 0 220px; }
/* THE LEAD THUMBNAIL IS NOT IN A GRID HERE, SO IT DOES NOT HAVE TO BE CROPPED.
   post-thumb.html's box is `object-fit: cover` at 3:2 because a card GRID has
   to be one shape -- a row of cards each keeping its own ratio is a ragged row,
   which is the whole reason that partial exists. This page shows one thumbnail
   per edition, beside its text, so there is no rhythm to hold and the crop buys
   nothing.

   What it cost was measured, not guessed: the 15 leading articles carry heroes
   from 0.65:1 (a portrait logo) to 5.54:1 (a wordmark) and only 3 are within
   10% of 3:2. Five lose more than 40% of the image and the worst loses 73%,
   which is a sponsor's wordmark reduced to two unreadable letters.

   `contain` letterboxes instead of cropping, and the bars are invisible: the
   <img> paints its own opaque --surface-sunken (see .post-card-image img) and
   so does .review-main, so the artwork simply floats in the panel. The box
   keeps its 3:2, so the row height does not jump from edition to edition, and
   the derived fallback tile is untouched -- it still shows exactly when the
   <img> removes itself in onerror. */
.review-main__thumb .post-card-image img { object-fit: contain; }
/* And the dark-mode dim comes OFF here, which the cover version never needed.
   `filter` applies to an element's own background as well as its content, so
   brightness(.92) on a letterboxed image darkens the bars too -- painting a
   visibly darker rectangle (#1b262b) inside the #1d292f panel, which is the one
   artefact `contain` could introduce. The filter is there to stop bright card
   artwork glaring out of a dark grid; on this page the image sits on a sunken
   panel, at full brightness, exactly like the white JVM Weekly tile in the page
   head above it. */
:root[data-theme="dark"] .review-main__thumb .post-card-image img { filter: none; }
.review-main__body { flex: 1 1 auto; min-width: 0; }
.review-main__label {
  margin: 0; color: var(--ink-muted);
  font-size: .75rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
}
.review-main__title { margin: .3rem 0 0; font-size: 1.1rem; line-height: 1.35; }
.review-main__meta {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: .6rem;
  margin: .4rem 0 0; color: var(--ink-muted); font-size: .85rem;
}
.review-main__summary { margin: .6rem 0 0; color: var(--ink-soft); font-size: .95rem; }
.review-main__read { margin: .7rem 0 0; font-size: .9rem; font-weight: 600; }

.review-also { margin: 1.25rem 0 0; }
.review-also__label {
  margin: 0 0 .5rem; color: var(--ink-muted);
  font-size: .75rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
}
.review-also__list { margin: 0; padding: 0; list-style: none; }
.review-also__list li {
  padding: .45rem 0;
  border-top: 1px solid var(--border);
  font-size: .95rem;
}
.review-also__by { color: var(--ink-muted); font-size: .85rem; }
/* A separator, not a word: "Title — Author" reads as one phrase without it. */
.review-also__by::before { content: " — "; }

.review-edition__out { margin: 1.25rem 0 0; font-size: .9rem; font-weight: 600; }

.review-empty, .review-source {
  margin: 1.5rem 0 0; color: var(--ink-muted); font-size: .9rem;
}

/* Stacked below 40rem. The thumbnail keeps a card's aspect ratio rather than
   growing to full width, which at 390px would be a 200px-tall image above four
   lines of text. */
@media (max-width: 40rem) {
  .review-main { flex-direction: column; gap: .9rem; }
  .review-main__thumb { flex: 0 0 auto; max-width: 260px; }
  .review-edition { padding: 1.15rem; }
  .review-edition__title { font-size: 1.15rem; }
}

/* The JVM Weekly mark beside the /jvm-weekly/ page title. A WHITE tile in both
   schemes, not a token: the artwork is black-on-transparent (Duke in red
   glasses), so on the dark theme it would be a pair of floating glasses over a
   near-black ground. --surface-sunken is pale in light and dark-navy in dark,
   which is exactly the trap partials/board-logo.html's `logoBackground`
   escape hatch exists for; here there is one logo, so the ground is stated
   once rather than made configurable. */
.jvmw-head { display: flex; align-items: flex-start; gap: 1.25rem; }
.jvmw-head__text { min-width: 0; }
.jvmw-head__logo {
  flex: 0 0 auto;
  width: 88px; height: 88px;
  padding: .6rem;
  background: #fff;
  border: 1px solid var(--border);
  border-radius: 14px;
  object-fit: contain;
}
/* Stays a ROW on a phone rather than stacking, which is what it did first: the
   mark belongs beside the title, and above it the page opened with a lone logo
   and pushed the heading down the fold. It only gets smaller. */
@media (max-width: 40rem) {
  .jvmw-head { gap: .85rem; }
  .jvmw-head__logo { width: 56px; height: 56px; padding: .4rem; border-radius: 10px; }
}
