/* mathieuboudreau.ca
   Palette and type tokens are fixed. Do not adjust a color without
   re-running: python3 tools/verify.py */

:root {
  --bg: #FFFFFF;
  --ink: #111114;
  --ink-muted: #5A5A62;
  /* McGill red. 4.38:1 on white — clears WCAG AA for rules and UI (3:1),
     but NOT for text (4.5:1), so it is never used to colour words. */
  --accent: #ED1B2F;
  /* Same hue, fractionally darker, 4.79:1 — this is the one that carries
     text: links, labels, anything read rather than merely seen. */
  --accent-text: #E11A2D;
  /* 1.29:1 against white. Decorative only — never text, never the sole
     indicator of anything. */
  --rule-faint: #E2E2E6;

  --font-serif: "STIX Two Text", Georgia, "Times New Roman", serif;
  --font-sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont,
    "Segoe UI", Helvetica, Arial, sans-serif;

  --measure: 68ch;
  --page: 60rem;
}

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

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

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-serif);
  font-size: 1.0625rem;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  /* Long DOIs, URLs and email addresses must not push the page sideways on
     a narrow screen. */
  overflow-wrap: break-word;
}

/* ---- Fonts -------------------------------------------------------------
   Self-hosted: no third-party request. STIX Two Text was commissioned by
   ACS, AIP, AMS, APS, Elsevier and IEEE for scientific typesetting. IBM
   Plex Sans carries chrome only: nav, labels, metadata. */

@font-face {
  font-family: "STIX Two Text";
  src: url("assets/fonts/stixtwotext-latin.woff2") format("woff2");
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "STIX Two Text";
  src: url("assets/fonts/stixtwotext-italic-latin.woff2") format("woff2");
  font-weight: 400 700;
  font-style: italic;
  font-display: swap;
}

/* IBM Plex Sans is a variable font: one file covers the whole weight axis.
   Do not re-add a second static-weight file — Google Fonts returns the
   identical physical file, so a second block only doubles the download. */
@font-face {
  font-family: "IBM Plex Sans";
  src: url("assets/fonts/ibmplexsans-latin.woff2") format("woff2");
  font-weight: 100 700;
  font-style: normal;
  font-display: swap;
}

/* ---- Layout ------------------------------------------------------------ */

.wrap {
  width: 100%;
  max-width: var(--page);
  margin: 0 auto;
  padding: 0 clamp(1.25rem, 5vw, 2.5rem);
}

/* ---- Skip link ---------------------------------------------------------- */

.skip {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 20;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: 0.875rem;
  padding: 0.75rem 1rem;
  border: 1px solid var(--accent);
}

.skip:focus { left: 0.5rem; top: 0.5rem; }

/* ---- Site header / nav -------------------------------------------------- */

.site-header {
  border-bottom: 2px solid var(--accent);
  background: var(--bg);
}

.site-header__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem 2rem;
  padding-top: 1.1rem;
  padding-bottom: 1.1rem;
}

.wordmark {
  font-family: var(--font-serif);
  /* Scales with viewport so it stays legible on a laptop without crowding
     the nav on a phone. */
  font-size: clamp(1.1875rem, 1.6vw, 1.5rem);
  font-weight: 600;
  color: var(--ink);
  text-decoration: none;
  letter-spacing: 0.01em;
}

.wordmark:hover,
.wordmark:focus-visible { text-decoration: underline; }

.site-nav {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(1rem, 3.5vw, 2.25rem);
  font-family: var(--font-sans);
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0.07em;
  text-transform: uppercase;
}

.site-nav a {
  color: var(--ink);
  text-decoration: none;
  position: relative;
}

/* Sits just under the text, wherever the box happens to end — so a taller
   touch target on phones cannot strand the rule below the word. */
.site-nav a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  top: calc(50% + 0.68em);
  height: 2px;
  background: var(--accent);
  opacity: 0;
}

.site-nav a:hover::after,
.site-nav a:focus-visible::after { opacity: 1; }

.site-nav a:hover,
.site-nav a:focus-visible { color: var(--accent-text); }

/* Current page is marked by weight and a rule, not by colour alone. */
.site-nav a[aria-current="page"] { font-weight: 600; }

/* Home left the nav — the wordmark already links there, and a seventh item
   pushed the nav past the header's width, wrapping it onto its own line and
   growing every page's header from 75px to 109px.

   aria-current stays on the wordmark on the home page, so assistive tech is
   told where it is, but nothing is drawn: an underline under a masthead reads
   as a link someone forgot to style, not as a state. */
.wordmark[aria-current="page"] { text-decoration: none; }
.site-nav a[aria-current="page"]::after { opacity: 1; }

/* ---- Intro -------------------------------------------------------------- */

.intro { padding: clamp(2.5rem, 7vw, 4.5rem) 0 clamp(1.5rem, 4vw, 2.5rem); }

.intro__grid {
  display: grid;
  gap: clamp(1.75rem, 5vw, 3.25rem);
  align-items: start;
}

/* Two columns only when a portrait is actually present. Without this the
   lone text column would inherit the narrow portrait track and strand the
   copy against a wall of empty space. */
@media (min-width: 820px) {
  .intro__grid:has(.portrait) { grid-template-columns: 17rem minmax(0, 1fr); }
}

/* Fallback for engines without :has() — same outcome, reached differently. */
.intro__body:only-child { grid-column: 1 / -1; }

/* The CV lead runs the full column: there is no portrait beside it to make
   room for, and a measure-width paragraph under a full-width heading reads as
   a stray column. */
.intro__grid:not(:has(.portrait)):not(.intro__grid--mark) .intro__body p {
  max-width: none;
}

.portrait { margin: 0; }

/* The viewer follows the bio in the DOM, so a phone reads portrait, name,
   bio, viewer — rather than meeting a canvas and three sliders before the
   name. It was nested inside the <figure> before, which put it second on a
   phone and, because it straddled the fold, had every mobile visitor fetch
   1.5MB of scan before reading a word.

   Desktop puts it back under the portrait by explicit grid placement rather
   than by DOM position. The float and side margin are from its CV-era rule
   and have to be undone here, or it sits 2rem out of line with the portrait
   above it. */
.intro__grid .volume {
  float: none;
  width: 100%;
  /* Tightened so the last slider lands level with the social icons opposite:
     the two columns then finish on the same line. */
  margin: 0.625rem 0 0;
  position: relative;
}

/* A still of the same scan, shown only when the live viewer cannot run. Every
   failure message says "a still image is shown instead", and until now that
   was not true: there was no image and no rule to reveal one. */
.volume__fallback { display: none; }

.intro__grid .volume--unsupported .volume__fallback { display: block; }

.intro__grid .volume--unsupported .volume__fallback img {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--rule-faint);
}

/* These have to out-specify `.intro__grid .volume canvas`, which sets the live
   canvas up; a bare `.volume--unsupported canvas` loses to it and leaves an
   empty box sitting under the still. */
.intro__grid .volume.volume--unsupported canvas { display: none; }

.intro__grid .volume.volume--unsupported .volume__controls { display: none; }

/* When the still is what's on show, the message is permanent — so it goes back
   into the flow beneath it rather than sitting on top of the picture. */
.intro__grid .volume.volume--unsupported .volume__status {
  position: static;
  margin: 0.6rem 0 0;
  text-align: left;
}

/* The status line is empty except while the scan is loading or if it fails,
   but it was holding 18px of layout open the whole time. Taking it out of flow
   frees that space without making the block jump when the message clears. */
.intro__grid .volume .volume__status {
  position: absolute;
  top: 0.5rem;
  left: 0;
  right: 0;
  margin: 0;
  text-align: center;
  pointer-events: none;
}

@media (min-width: 820px) {
  .intro__grid:has(.portrait) { grid-template-rows: auto auto; align-items: start; }
  .intro__grid:has(.portrait) > .portrait { grid-column: 1; grid-row: 1; }
  .intro__grid:has(.portrait) > .intro__body { grid-column: 2; grid-row: 1 / span 2; }
  .intro__grid:has(.portrait) > .volume { grid-column: 1; grid-row: 2; }
}

.intro__grid .volume canvas {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1 / 1;
}

.portrait img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 2px;
}

.portrait figcaption {
  font-family: var(--font-sans);
  font-size: 0.75rem;
  color: var(--ink-muted);
  margin-top: 0.6rem;
}

.intro h1 {
  font-family: var(--font-serif);
  /* Capped at 3rem so "Mathieu Boudreau, PhD" holds one line beside the
     portrait at desktop widths; balance keeps the break sane below that. */
  font-size: clamp(2rem, 5vw, 3rem);
  text-wrap: balance;
  font-weight: 600;
  line-height: 1.1;
  letter-spacing: -0.01em;
  margin: 0;
}

.intro__role {
  font-family: var(--font-sans);
  font-size: 0.875rem;
  font-weight: 500;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent-text);
  margin: 0.7rem 0 1.5rem;
}

.intro__body p {
  max-width: var(--measure);
  margin: 0 0 1.05em;
}

.intro__body p:last-child { margin-bottom: 0; }

/* ---- Profile links ------------------------------------------------------ */

.profile-links {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  /* Wider now that each icon carries a label: the gap has to clear the word,
     not the 24px glyph. */
  gap: 0.6rem 3.25rem;
  margin: 1.75rem 0 0;
  padding: 0;
  font-family: var(--font-sans);
  font-size: 0.875rem;
}

/* Not `display: inline-block` — that beat .profile-links__icon on specificity
   and quietly stopped the anchor being a flex column, leaving every glyph
   offset from the word beneath it by half the label's overhang. */
.profile-links a { padding: 0.15rem 0; }

/* ---- Sections ----------------------------------------------------------- */

.section { padding: clamp(2.25rem, 6vw, 3.5rem) 0; }

/* The first section after the intro, like any section after another: the
   intro already pays for the space below it, and stacking the section's own
   top padding on top left 96px of air under a one-line lead. */
.section + .section,
.intro + .section { padding-top: 0; }

.section__head {
  border-top: 2px solid var(--accent);
  padding-top: 0.9rem;
  margin-bottom: 1.5rem;
}

.section__head h2 {
  font-family: var(--font-serif);
  font-size: 1.5rem;
  font-weight: 600;
  margin: 0;
}

.section__head p {
  font-family: var(--font-sans);
  font-size: 0.8125rem;
  color: var(--ink-muted);
  margin: 0.35rem 0 0;
  max-width: var(--measure);
}

/* ---- Entry lists -------------------------------------------------------- */

.entries {
  list-style: none;
  margin: 0;
  padding: 0;
  max-width: var(--measure);
}

.entry {
  padding: 0.85rem 0;
  border-bottom: 1px solid var(--rule-faint);
}

.entry:last-child { border-bottom: none; }

.entry__title {
  display: block;
  font-weight: 600;
}

.entry__meta {
  display: block;
  font-family: var(--font-sans);
  font-size: 0.8125rem;
  line-height: 1.55;
  color: var(--ink-muted);
  margin-top: 0.2rem;
}

/* ---- Links -------------------------------------------------------------
   Permanent underline: color is never the only signal. */

/* Links read in body colour with a red underline, so red stays structural
   rather than colouring half the prose. The underline is permanent, so the
   non-colour cue is intact either way. */
a {
  color: var(--ink);
  text-decoration: underline;
  text-decoration-color: var(--accent);
  text-decoration-thickness: 1px;
  text-underline-offset: 0.15em;
}

a:hover,
a:focus-visible {
  color: var(--accent-text);
  text-decoration-thickness: 2px;
}

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

/* ---- Footer ------------------------------------------------------------- */

/* Closes the page against the header's rule, and now carries something: an
   address in selectable text, an ORCID, and a date. The date is the point —
   an undated academic site reads as possibly abandoned. */
.site-footer {
  border-top: 2px solid var(--accent);
  margin-top: clamp(2.5rem, 6vw, 4rem);
  padding: 1.4rem 0 3rem;
}

.site-footer__inner {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 0.4rem 2rem;
  font-family: var(--font-sans);
  font-size: 0.8125rem;
  color: var(--ink-muted);
}

.site-footer p { margin: 0; }

.site-footer__contact { display: flex; flex-wrap: wrap; gap: 0.35rem 1.5rem; }

.site-footer a { color: var(--accent-text); }

/* The address stays in the body colour: it is the one thing here a reader
   copies rather than clicks, and it should read as text. */
.site-footer a.site-footer__email,
.site-footer a.site-footer__email:hover { color: var(--ink); }

@media (pointer: coarse) {
  .site-footer a { min-height: 44px; display: inline-flex; align-items: center; }
}

/* ---- Outreach ----------------------------------------------------------- */

/* A magazine is recognised by its cover, not its volume number, so the
   archive is a grid of them. auto-fill with a 10rem floor lands on four
   columns at full width and collapses to two on a phone without a query. */
.covers {
  list-style: none;
  margin: 1.5rem 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
  gap: 1.75rem 1.5rem;
}

.cover a {
  display: block;
  text-decoration: none;
  color: var(--ink);
}

.cover img {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--rule-faint);
  /* The covers are photographic and busy; a hairline keeps the pale ones
     from bleeding into the page. */
}

.cover a:hover img,
.cover a:focus-visible img { border-color: var(--accent); }

.cover__label {
  display: block;
  margin-top: 0.5rem;
  font-family: var(--font-sans);
  font-size: 0.75rem;
  letter-spacing: 0.02em;
}

.cover__year {
  display: block;
  color: var(--ink-muted);
  font-size: 0.6875rem;
}

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

/* Interview rows: the social card the post was shared with, or the cover of
   the volume it ran in. Same right-hand alignment as the software logos. */
.entry__thumb {
  flex: 0 0 auto;
  width: 5.5rem;
  order: 2;
}

.entry__thumb img {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--rule-faint);
}

/* Covers are portrait where the social cards are landscape; at the same width
   they tower over the row, so give them a narrower column. */
.entry__thumb--cover { width: 4.25rem; }

.entries:has(.entry__thumb),
.entries:has(.video) { max-width: none; }

.entry:has(.entry__thumb),
.entry:has(.video) {
  display: flex;
  align-items: flex-start;
  gap: 1.5rem;
}

/* Click-to-load video. The button is the poster frame; pressing it swaps in
   the iframe, so YouTube is contacted only on request. */
.video {
  flex: 0 0 auto;
  order: 2;
  width: 13rem;
  aspect-ratio: 16 / 9;
  position: relative;
}

.video__play {
  display: block;
  width: 100%;
  height: 100%;
  padding: 0;
  border: 1px solid var(--rule-faint);
  background: none;
  cursor: pointer;
  position: relative;
}

.video__play img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* A play triangle drawn in CSS rather than fetched as an asset. */
.video__glyph {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 2.75rem;
  height: 2.75rem;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: rgba(17, 17, 20, 0.72);
}

.video__glyph::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 54%;
  transform: translate(-50%, -50%);
  border-style: solid;
  border-width: 0.5rem 0 0.5rem 0.85rem;
  border-color: transparent transparent transparent #FFFFFF;
}

.video__play:hover .video__glyph,
.video__play:focus-visible .video__glyph { background: var(--accent); }

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

.video__frame {
  width: 100%;
  height: 100%;
  border: 1px solid var(--rule-faint);
}

/* Once the player is in, the thumbnail column is far too small to watch in.
   The iframe only exists after a click, so :has() catches exactly that
   moment: the row unstacks and the video takes the full measure. No script
   involvement — the same click that loads the player triggers this. */
/* Stays a flex container, turned to a column: `order` is what puts the video
   after the text it belongs to, and display:block would drop that, floating
   the player above its own heading. */
.entry:has(.video__frame) {
  flex-direction: column;
  align-items: stretch;
}

.entry:has(.video__frame) .video {
  width: 100%;
  max-width: 44rem;
  margin-top: 1.1rem;
}

.video noscript {
  display: block;
  margin-top: 0.4rem;
  font-family: var(--font-sans);
  font-size: 0.75rem;
}

@media (min-width: 900px) {
  .entry__thumb { width: 7rem; }
  .entry__thumb--cover { width: 5rem; }
  .video { width: 15rem; }
}

@media (max-width: 640px) {
  .entry__thumb { width: 4.5rem; }
  .entry:has(.entry__thumb),
  .entry:has(.video) { gap: 1rem; }
  .video { width: 9rem; }
}

/* ---- Touch targets ------------------------------------------------------ */

@media (pointer: coarse) {
  .site-nav a,
  .profile-links a {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }
}

@media (max-width: 640px) {
  .site-header__inner { align-items: flex-start; }
  .site-nav {
    /* Wraps. This was nowrap with space-between, which fitted the five items
       the nav had before Outreach was added; the sixth pushed the row past
       320px and clipped it, taking the whole page into horizontal scroll. */
    flex-wrap: wrap;
    width: 100%;
    justify-content: flex-start;
    gap: 0.45rem 0.9rem;
    font-size: 0.6875rem;
    letter-spacing: 0.04em;
  }
  .site-nav a { white-space: nowrap; }
}

@media (max-width: 380px) {
  .site-nav { font-size: 0.625rem; letter-spacing: 0.02em; }
}

/* The masthead, on screens with room for it.
   The wordmark was 23px against six uppercase nav items spanning 564px, so
   the thing that should own the header was outweighed by the thing that
   serves it. At 1.95rem its right edge lands on the portrait's, giving the
   left of the home page a spine from masthead to photo to viewer.

   The header takes the intro's two columns so the nav starts where the text
   column starts instead of wherever right-alignment happened to leave it, and
   space-between pins the first and last items to those edges. Gaps are then
   derived rather than chosen, so renaming a nav item retunes them by itself.

   white-space:nowrap is load-bearing: at 1.95rem the wordmark measures
   exactly its column, and without it wraps to two lines. */
@media (min-width: 1024px) {
  .wordmark {
    font-size: 1.95rem;
    white-space: nowrap;
  }

  .site-header__inner {
    display: grid;
    grid-template-columns: 17rem minmax(0, 1fr);
    gap: clamp(1.75rem, 5vw, 3.25rem);
    align-items: center;
  }

  .site-nav {
    gap: 0;
    justify-content: space-between;
    flex-wrap: nowrap;
  }
}

/* Wrapping is greedy: six nav items filled the first line with five and left
   "Outreach" alone below, and the five profile links stranded "Email" the same
   way. A grid with a fixed column count splits them evenly instead — three and
   three, five across — and space-between spreads them to the text edges rather
   than bunching them left. */
@media (max-width: 560px) {
  .site-nav {
    display: grid;
    grid-template-columns: repeat(3, max-content);
    justify-content: space-between;
    row-gap: 0.55rem;
    column-gap: 0.75rem;
  }

  .profile-links {
    display: grid;
    grid-template-columns: repeat(5, max-content);
    justify-content: space-between;
    column-gap: 0.5rem;
  }
}

/* ---- Collapsible publication groups -------------------------------------
   Native <details>, so keyboard and screen-reader disclosure behaviour come
   for free and the page still works with JavaScript disabled. */

.group { border-top: 2px solid var(--accent); }

.group > summary {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  padding: 0.9rem 0;
  cursor: pointer;
  list-style: none;
}

/* Safari draws its own marker; we supply our own below. */
.group > summary::-webkit-details-marker { display: none; }
.group > summary::marker { content: ""; }

.group > summary:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.group > summary h2 {
  font-family: var(--font-serif);
  font-size: 1.5rem;
  font-weight: 600;
  margin: 0;
  flex: 1 1 auto;
}

.group__count {
  font-family: var(--font-sans);
  font-size: 0.8125rem;
  color: var(--ink-muted);
  flex: 0 0 auto;
}

/* Disclosure caret, drawn in CSS — shape carries the state, not colour, so
   it survives a greyscale or colour-blind reading. */
.group > summary::after {
  content: "";
  flex: 0 0 auto;
  width: 0.5rem;
  height: 0.5rem;
  margin-left: 0.25rem;
  border-right: 2px solid var(--accent);
  border-bottom: 2px solid var(--accent);
  transform: rotate(45deg) translate(-0.1rem, -0.1rem);
  transition: transform 120ms ease;
}

.group[open] > summary::after {
  transform: rotate(-135deg) translate(-0.15rem, -0.15rem);
}

@media (prefers-reduced-motion: reduce) {
  .group > summary::after { transition: none; }
}

.group > .entries { padding-bottom: 0.75rem; }



/* ---- Software entries with logos ----------------------------------------
   Logos arrive at wildly different aspect ratios (a 4:1 wordmark next to a
   3:2 mark), so they sit in a fixed box and are contained rather than
   cropped — every one keeps its own proportions and none dominates. */

.entry__logo {
  /* One custom property drives both the box and the image cap. A percentage
     max-height on the image does not reliably resolve against a flex parent,
     which let wide logos overflow the row and paint over its bottom rule. */
  --logo-h: 4rem;
  flex: 0 0 auto;
  width: 8.5rem;
  height: var(--logo-h);
  display: flex;
  align-items: center;
  justify-content: flex-end;
  /* Visual order only — the DOM keeps the logo before the text so its alt
     text is not read out after the description it belongs to. */
  order: 2;
}

.entry__logo img {
  max-width: 100%;
  max-height: var(--logo-h);
  width: auto;
  height: auto;
  object-fit: contain;
}

/* Rows carrying a logo span the full column so the mark sits at the right
   edge; the prose inside still stops at a readable measure. */
.entries:has(.entry__logo) { max-width: none; }

.entry:has(.entry__logo) {
  display: flex;
  align-items: flex-start;
  gap: 2.5rem;
}

.entry__body {
  flex: 1 1 auto;
  min-width: 0;
  max-width: var(--measure);
}

/* Desktop has the room, and these marks carry the page — the source files
   are 640px tall, so they scale up without softening. The height cap is set
   above what any of these aspect ratios needs, so width governs every logo:
   they all span the same column and share a left edge, instead of the taller
   marks stopping short and leaving a ragged gap beside the prose. */
@media (min-width: 900px) {
  .entry__logo {
    --logo-h: 11rem;
    width: 16rem;
  }
  .entry:has(.entry__logo) { gap: 3rem; }
}

/* On a phone the logo floats top-right and the description wraps around it,
   rather than stacking and pushing the text down the page. */
@media (max-width: 640px) {
  .entry:has(.entry__logo) { display: block; }

  .entry__logo {
    --logo-h: 5rem;
    float: right;
    width: 9rem;
    height: auto;
    max-height: var(--logo-h);
    margin: 0.15rem 0 0.7rem 1rem;
    order: 0;
  }

  .entry__body { max-width: none; }

  /* Contain the float so the row's bottom rule cannot cut through a logo
     that is taller than its text. */
  .entry:has(.entry__logo)::after {
    content: "";
    display: block;
    clear: both;
  }
}

/* ---- Profile icons -------------------------------------------------------
   Each link is a glyph above its name. The marks stay monochrome — five brand
   palettes would overwhelm a page built on one accent — and the label is what
   makes them legible, which matters most for Scholar: a mortarboard reads as
   Google Scholar only when it is Google blue.

   This used to be a fixed 2.75rem square with a -0.6rem pull. Both had to go
   once a word sat under the glyph: the square stopped the anchor being as
   wide as its label, and the negative margin offset every icon from the word
   it named by exactly that amount. */

.profile-links__icon {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  gap: 0.4rem;
  min-width: 2.75rem;
  color: var(--ink);
  text-align: center;
  text-decoration: none;
  border-radius: 2px;
}

.profile-links__icon svg { fill: currentColor; display: block; }

.profile-links__icon:hover,
.profile-links__icon:focus-visible { color: var(--accent-text); }

.profile-links { align-items: flex-start; }

/* ---- Justified body copy on phones --------------------------------------
   Narrow measures and justification fight each other: without hyphenation
   the browser stretches word spacing and opens rivers of white down the
   column. hyphens:auto lets it break words instead, which needs the lang
   attribute on <html> — it is set. */

@media (max-width: 640px) {
  .intro__body p,
  .section__head p,
  .entry__meta,
  main .wrap > p {
    -webkit-hyphens: auto;
    hyphens: auto;
  }
}

/* The metrics line is the longest single run of uppercase on the site and
   was wrapping a lone figure onto a second line on a phone. */
@media (max-width: 640px) {
  #metrics {
    font-size: 0.75rem;
    letter-spacing: 0.04em;
  }
}

@media (max-width: 380px) {
  #metrics {
    font-size: 0.6875rem;
    letter-spacing: 0.02em;
  }
}

/* ---- Showcase ------------------------------------------------------------ */

.section__lead {
  max-width: var(--measure);
  margin: 0.6rem 0 0;
}

.showcase {
  margin: 0 0 2.5rem;
  max-width: 52rem;
}

.showcase:last-child { margin-bottom: 0.5rem; }

.showcase img,
.showcase__video {
  display: block;
  width: 100%;
  height: auto;
  background: var(--bg);
}

.showcase__video { cursor: pointer; }

.showcase figcaption {
  margin-top: 0.7rem;
  max-width: var(--measure);
  font-family: var(--font-sans);
  font-size: 0.8125rem;
  line-height: 1.55;
  color: var(--ink-muted);
}

.showcase abbr { text-decoration: none; }


/* ---- Fixes ---------------------------------------------------------------
   1. `.section + .section` (0,2,0) outranks `.section--group` (0,1,0), so the
      padding reset never took and collapsed rows sat 56px apart. Match the
      specificity instead of raising it arbitrarily. */
.section.section--group,
.section + .section.section--group {
  padding-top: 0;
  padding-bottom: 0;
}

.section.section--group:last-of-type {
  padding-bottom: clamp(2rem, 5vw, 3rem);
}

/* 2. Ragged right, with hyphenation kept on.

      This was justified. CSS gives no control over the word-spacing limits
      that make justification work in print, so the browser stretched the
      first line of each paragraph to reach the margin — visibly loose on the
      home page. Hyphenation alone gets the even colour justification was
      reaching for, without the rivers. */
.intro__body p,
.section__lead,
main .wrap > p {
  -webkit-hyphens: auto;
  hyphens: auto;
}

/* 3. The row aligns to the text edge; there is no negative margin to undo
      any more, and the gap is set once where the list is defined. */
.profile-links { margin-left: 0; }


/* ---- Slide links ---------------------------------------------------------
   Posters open in a new tab so Chrome's PDF viewer gets the whole window
   instead of being crammed into a frame. */

.slide-link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  margin-top: 0.2rem;
  font-family: var(--font-sans);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 2px solid var(--accent);
}

.slide-link:hover,
.slide-link:focus-visible { color: var(--accent-text); }

/* Announced by screen readers, invisible on screen — sighted users get the
   new-tab behaviour, everyone else gets told about it. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* ---- Volume viewer -------------------------------------------------------
   A WebGL2 raycast of Mathieu's own MPRAGE. Floated so the bio wraps around
   it on desktop; full width on phones, because a viewer squeezed beside text
   on a small screen is unusable — and because rotating needs touch-action:
   none, which would otherwise trap a finger trying to scroll past it. */

.volume {
  float: right;
  width: 20rem;
  margin: 0 0 1.25rem 2rem;
}

.volume canvas {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  touch-action: none;
  cursor: grab;
  background: var(--bg);
  border: 1px solid var(--rule-faint);
}

.volume canvas:active { cursor: grabbing; }

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

.volume__status {
  margin: 0.4rem 0 0;
  font-family: var(--font-sans);
  font-size: 0.75rem;
  color: var(--ink-muted);
  min-height: 1em;
}

.volume__controls {
  display: grid;
  gap: 0.3rem;
}

.volume__controls label {
  display: grid;
  grid-template-columns: 6.5rem 1fr;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-sans);
  font-size: 0.6875rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

.volume__controls input[type="range"] {
  width: 100%;
  accent-color: var(--accent);
  min-height: 24px;
}

.volume__caption {
  margin: 0.55rem 0 0.9rem;
  font-family: var(--font-sans);
  font-size: 0.75rem;
  line-height: 1.5;
  color: var(--ink-muted);
  text-align: left;
}

@media (max-width: 640px) {
  .volume {
    float: none;
    width: 100%;
    max-width: 22rem;
    margin: 0 0 1.5rem;
  }
}

/* Brain-mask toggle. Starts on: the brain is what makes this worth showing. */
.volume__mask {
  margin-top: 0.6rem;
  font-family: var(--font-sans);
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  background: none;
  border: 1px solid var(--accent);
  border-radius: 2px;
  padding: 0.45rem 0.8rem;
  min-height: 44px;
  cursor: pointer;
}

.volume__mask[aria-pressed="true"] { border-width: 2px; }
.volume__mask:hover { color: var(--accent-text); }
.volume__mask:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---- MR Studio demo -------------------------------------------------------
   A spin echo drawn by MR Studio's own geometry code. The disclosure keeps it
   out of the way — nothing renders until someone opens it. */

.seqdemo-toggle { margin-top: 0.7rem; }

.seqdemo-toggle > summary {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  cursor: pointer;
  list-style: none;
  font-family: var(--font-sans);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink);
  border-bottom: 2px solid var(--accent);
}

.seqdemo-toggle > summary::-webkit-details-marker { display: none; }
.seqdemo-toggle > summary::marker { content: ""; }
.seqdemo-toggle > summary:hover { color: var(--accent-text); }
.seqdemo-toggle > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.seqdemo { margin-top: 1rem; max-width: 42rem; }

.seqdemo svg {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--rule-faint);
  background: var(--bg);
}

.seq-base   { stroke: var(--ink); stroke-width: 1; opacity: 0.25; }
.seq-wave   { fill: none; stroke: var(--ink); stroke-width: 1.6;
              stroke-linejoin: round; stroke-linecap: round; }
.seq-rf     { stroke: var(--accent); }
.seq-echo   { stroke: var(--accent); opacity: 0.85; }
.seq-aq     { opacity: 0.7; }
.seq-te     { stroke: var(--accent); stroke-width: 1; }
.seq-label  { font-family: var(--font-sans); font-size: 11px; fill: var(--ink-muted); }

.seqdemo__controls {
  display: grid;
  gap: 0.45rem;
  margin-top: 0.9rem;
}

.seqdemo__controls label {
  display: grid;
  grid-template-columns: 6rem 1fr auto;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--font-sans);
  font-size: 0.6875rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

.seqdemo__controls input[type="range"] { accent-color: var(--accent); min-height: 24px; }

.seqdemo__controls select {
  font-family: var(--font-sans);
  font-size: 0.75rem;
  padding: 0.3rem 0.4rem;
  border: 1px solid var(--rule-faint);
  background: var(--bg);
  color: var(--ink);
  min-height: 32px;
}

.seqdemo__controls output { font-variant-numeric: tabular-nums; }

.seqdemo__note {
  margin: 0.8rem 0 0;
  font-family: var(--font-sans);
  font-size: 0.75rem;
  line-height: 1.55;
  color: var(--ink-muted);
}

.seqdemo code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.92em;
}

@media (max-width: 640px) {
  .seqdemo__controls label { grid-template-columns: 5rem 1fr auto; }
}

/* The editor renders at the size the sequence was authored for (920x680 for
   the qMT export). Scale the finished SVG to the column rather than asking
   the canvas to lay out at a size its padding was not designed for. */
#seqdemo-mount svg {
  display: block;
  width: 100%;
  height: auto;
  max-width: 100%;
}

/* The demo spans the full row — text column plus logo column — rather than
   being trapped in the prose measure. The prose inside the same body keeps
   its readable width. */
.entry__body:has(.seqdemo-toggle) { max-width: none; }
.entry__body:has(.seqdemo-toggle) > .entry__title,
.entry__body:has(.seqdemo-toggle) > .entry__meta { max-width: var(--measure); }
.seqdemo { max-width: none; }

/* Grid rather than a wrapped flex row. Wrapping put the toggle on a second
   flex line, and the free space in the container was shared between the two
   lines — dropping the button ~50px below the figure it belongs to. Grid
   places it explicitly instead.

   The toggle spans both columns so the editor gets the full width when open,
   while the summary itself is pushed to the right, sitting directly under the
   figure. That way the button does not jump when it is clicked. */
.entry:has(.seqdemo-toggle) {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 14rem;
  column-gap: 3rem;
  row-gap: 0;
  align-items: start;
}

.entry:has(.seqdemo-toggle) > .entry__body { grid-column: 1; grid-row: 1; }
.entry:has(.seqdemo-toggle) > .entry__logo { grid-column: 2; grid-row: 1; }

.entry > .seqdemo-toggle {
  grid-column: 1 / -1;
  grid-row: 2;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
}

.seqdemo-toggle > .seqdemo { align-self: stretch; }

@media (max-width: 640px) {
  /* One column on a phone; the button returns to the flow under the text. */
  .entry:has(.seqdemo-toggle) { display: block; }
  .entry > .seqdemo-toggle { align-items: flex-start; }
}

/* Shown instead of the editor on screens too small to use it. */
.seqdemo__status--note {
  border-left: 2px solid var(--accent);
  padding: 0.15rem 0 0.15rem 0.8rem;
  max-width: var(--measure);
  line-height: 1.55;
}

/* ---- Institution marks -------------------------------------------------- */

/* Degrees and posts carry the mark of the place. Right-aligned in their own
   column, matching how the Software page sets project logos, and fitted to a
   box rather than a fixed height: these range from a tall crest to a wide
   wordmark, and forcing one dimension would make some tower over others. */
.entries:has(.inst) { max-width: none; }

.entry:has(.inst) {
  display: flex;
  align-items: flex-start;
  gap: 1.5rem;
}

.entry:has(.inst) .entry__body {
  flex: 1 1 auto;
  min-width: 0;
  max-width: var(--measure);
}

.inst {
  flex: 0 0 auto;
  /* Visual order only; the DOM keeps the mark first so it is never read out
     after the description it belongs to. */
  order: 2;
  width: 9rem;
  display: flex;
  align-items: center;
  justify-content: flex-end;
}

.inst img {
  display: block;
  max-width: 100%;
  max-height: 3rem;
  width: auto;
  height: auto;
}

/* A stacked lockup carries four lines of type where a wordmark carries one.
   Capped at the same height it turns to mush, so these get room to stay
   legible; the extra height is roughly what keeps their optical weight equal
   to the wide marks anyway. */
.inst--stacked img { max-height: 4.5rem; }

@media (max-width: 640px) {
  .inst { width: 6rem; }
  .inst img { max-height: 2.25rem; }
  .inst--stacked img { max-height: 3.25rem; }
  .entry:has(.inst) { gap: 1rem; }
}

/* Publications header links. Scholar and ORCID lived only on the home page,
   which is the one page nobody visits looking for them; the .bib is here
   because a citation list you cannot import is a citation list you retype. */
.pub-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.5rem;
  margin: 0.7rem 0 0;
  font-family: var(--font-sans);
  font-size: 0.8125rem;
}

/* Icon labels. Everywhere else on this site a single signal never carries
   meaning alone — links keep their underline so colour is not the only cue,
   the disclosure caret changes shape rather than tint. Five icon-only links
   broke that rule, and one of them is a mortarboard that is only legible as
   Google Scholar when it is Google blue. Naming them fixes the glyph without
   introducing a second brand palette. */
.profile-links__label {
  font-family: var(--font-sans);
  font-size: 0.625rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  /* "SCHOLAR" and "LINKEDIN" are wider than a 24px glyph, so without this
     they wrap mid-word and drag their icons out of line. */
  white-space: nowrap;
}

.profile-links__icon:hover .profile-links__label,
.profile-links__icon:focus-visible .profile-links__label { color: var(--accent-text); }

/* The MRM Highlights mark beside the Outreach lead. Same column arrangement
   as the portrait on the home page, mirrored: prose first, mark on the right,
   because here the text is the subject and the mark is the attribution. */
.intro__grid--mark { grid-template-columns: minmax(0, 1fr); }

.intro__mark { margin: 0; }

.intro__mark img {
  display: block;
  width: 100%;
  height: auto;
}

.intro__mark a { text-decoration: none; }

.intro__mark figcaption {
  margin-top: 0.6rem;
  max-width: 15rem;
  font-family: var(--font-sans);
  font-size: 0.6875rem;
  line-height: 1.4;
  color: var(--ink-muted);
}

.intro__mark a:hover figcaption,
.intro__mark a:focus-visible figcaption { color: var(--accent-text); }

@media (min-width: 720px) {
  .intro__grid--mark { grid-template-columns: minmax(0, 1fr) 15rem; gap: 3rem; }
  .intro__mark { justify-self: end; }
}

/* ---- Print ---------------------------------------------------------------
   The CV is the page most likely to meet a printer, and until now it printed
   as a stack of closed headings with a navigation bar on top. Collapsed
   groups are opened by assets/js/print.js on beforeprint: CSS cannot force a
   <details> open, because the UA hides its contents with content-visibility
   rather than display. */

@media print {
  .site-header,
  .skip,
  .volume__controls,
  .video,
  .seqdemo-toggle,
  .pub-links { display: none; }

  :root { --ink: #000; --ink-muted: #333; --accent: #000; --accent-text: #000; }

  body { font-size: 10.5pt; }

  a { text-decoration: none; }

  /* A printed link is a dead end unless it says where it goes. Only external
     ones: in-site hrefs are noise on paper. */
  main a[href^="http"]::after {
    content: " (" attr(href) ")";
    font-size: 0.85em;
    word-break: break-all;
  }

  .group > summary { break-after: avoid; }
  .entry, .cover, li { break-inside: avoid; }
  h1, h2 { break-after: avoid; }

  .section { padding: 0.6rem 0; }
  .intro { padding: 0 0 0.8rem; }

  /* The viewer is a live canvas; on paper it is a grey square. */
  #volume canvas, .volume__status { display: none; }

  .site-footer { border-top: 1px solid #000; }
}
