@font-face {
  font-family: "Libertinus Serif";
  src: url("/fonts/LibertinusSerif-Regular.woff2") format("woff2");
  font-weight: normal;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Libertinus Serif";
  src: url("/fonts/LibertinusSerif-Bold.woff2") format("woff2");
  font-weight: bold;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Libertinus Serif";
  src: url("/fonts/LibertinusSerif-Italic.woff2") format("woff2");
  font-weight: normal;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: "Libertinus Serif";
  src: url("/fonts/LibertinusSerif-BoldItalic.woff2") format("woff2");
  font-weight: bold;
  font-style: italic;
  font-display: swap;
}
:root {
  --bg: #fdfdfb;
  --fg: #1a1a1a;
  --muted: #767676;
  --accent: #2456c4;
  --rule: #e4e4de;
}
/* Light is the default regardless of the OS setting; dark is opt-in via the
   toggle, which sets `data-theme` on <html> and remembers it in localStorage. */
:root[data-theme="dark"] {
  --bg: #131316;
  --fg: #dcdcd7;
  --muted: #8f8f8f;
  --accent: #7da4f2;
  --rule: #2c2c31;
}
body {
  margin: 0 auto;
  padding: 10vh 1.25rem 4rem;
  max-width: 40rem;
  background: var(--bg);
  color: var(--fg);
  font: 1.05rem/1.6 "Libertinus Serif", Georgia, serif;
}
/* Links: no rule until you point at one. */
a {
  color: var(--accent);
  text-decoration: none;
}
a:hover, a:focus-visible {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.16em;
}
/* External links — `[text](url){target="_blank"}` — get a trailing ↗. It's an
   inline SVG rather than the ↗ glyph: Libertinus draws that one long and
   hairline-thin, and this way the weight is a number we control. Painted with
   `background-color` through a mask so it inherits the link colour. Sized in
   `em` so it tracks the surrounding type, and offset with `margin-left`
   rather than a space so it can never wrap onto a line of its own. */
a[target="_blank"]::after {
  content: "";
  display: inline-block;
  width: 0.42em;
  height: 0.42em;
  margin-left: 0.17em;
  vertical-align: 0.26em;
  background-color: currentColor;
  --arrow: no-repeat center/contain url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cg fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.6 9.4 8.6 3.4'/%3E%3Cpath d='M4.6 3.4h4v4'/%3E%3C/g%3E%3C/svg%3E");
  -webkit-mask: var(--arrow);
  mask: var(--arrow);
}
/* Theme toggle: pinned top-right, drawn with the same mask trick as the arrow
   so it takes the muted colour and brightens to the text colour on hover.
   Shows a moon in light mode (what you'd switch to) and a sun in dark. */
.theme-toggle {
  position: fixed;
  top: 1rem;
  right: 1rem;
  width: 2rem;
  height: 2rem;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--muted);
  cursor: pointer;
}
.theme-toggle:hover, .theme-toggle:focus-visible { color: var(--fg); }
.theme-toggle:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }
.theme-toggle::before {
  content: "";
  display: block;
  width: 1.1rem;
  height: 1.1rem;
  margin: auto;
  background-color: currentColor;
  --moon: no-repeat center/contain url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z'/%3E%3C/svg%3E");
  --sun: no-repeat center/contain url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cg fill='none' stroke='%23000' stroke-width='2.2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4'/%3E%3Cpath d='M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M5.3 18.7l1.8-1.8M16.9 7.1l1.8-1.8'/%3E%3C/g%3E%3C/svg%3E");
  -webkit-mask: var(--moon);
  mask: var(--moon);
}
[data-theme="dark"] .theme-toggle::before {
  -webkit-mask: var(--sun);
  mask: var(--sun);
}
h1, h2, h3 { line-height: 1.25; text-align: left; }
h1 { font-size: 1.5rem; }
h2 { font-size: 1.15rem; margin-top: 2.25rem; }
/* Subtitle: `[text]{.subtitle}` on its own line right under a heading.
   Follows the heading's alignment so the two stay visually joined. Only a
   paragraph that opens with the subtitle is pulled up; an entry with a
   `<br>` subtitle after it (projects on the home page) keeps its spacing. */
p:has(> .subtitle:first-child) {
  margin-top: -0.85rem;
  text-align: left;
}
.subtitle {
  color: var(--muted);
  font-size: 0.9em;
}
/* Entry lists under a `###` section (projects, writing on the home page):
   each entry is its own paragraph, so tighten the gap between them. The
   run-on `~ p` carries the spacing through to the end of the section, and
   the heading's own margin still sets the gap above the first entry. */
h3 + p,
h3 + p ~ p {
  margin-top: 0.55rem;
  margin-bottom: 0.55rem;
}
/* An entry with a `<br> [role]{.subtitle}` line under it: tighten the two
   lines together like a table cell, and indent the subtitle a touch. */
p:has(> br + .subtitle) { line-height: 1.3; }
p > br + .subtitle { padding-left: 0.75em; }
/* Muted: `[text]{.muted}` inline, mid-sentence. Colour only — no size or
   layout change, so it greys a phrase without disturbing the line it sits in. */
.muted { color: var(--muted); }
code, pre {
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  font-size: 0.88em;
  background: var(--rule);
  border-radius: 3px;
}
code { padding: 0.1em 0.3em; }
pre { padding: 0.75rem 1rem; overflow-x: auto; }
pre code { background: none; padding: 0; }
img { max-width: 100%; }
blockquote { margin: 0; padding-left: 1rem; border-left: 3px solid var(--rule); color: var(--muted); }
/* Tables: no rules, no striping — a header row reads like an `###` heading
   with its column stacked underneath. Per-table override: `|:---|` in the
   markdown emits inline alignment that wins over the centring below. */
table {
  width: 100%;
  margin: 2rem 0 0;
  border-collapse: collapse;
  text-align: left;
}
th, td {
  border: 0;
  padding: 0.25rem 0.75rem;
  vertical-align: top;
}
/* Left-aligned cells, so drop the first column's left padding and the table
   lines up with the headings and body copy instead of sitting indented. */
th:first-child, td:first-child { padding-left: 0; }
/* Cells run tighter than body copy so an entry and its `.subtitle` date
   read as one block. */
td { line-height: 1.3; }
th {
  font-size: 1.17em;
  font-weight: bold;
  line-height: 1.25;
  padding-bottom: 0.15rem;
}
caption {
  caption-side: bottom;
  padding-top: 0.6rem;
  color: var(--muted);
  font-size: 0.9em;
}
/* Intro block at the top of the home page: a `:::: intro` fence holding two
   `:::` columns, each a `###` heading and its paragraphs. Laid out like the
   two-column table it replaced, but the columns don't share rows, so a long
   intro on the left can't stretch the gaps between resume entries. */
.intro {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.5rem 1.5rem;
  margin-top: 2rem;
}
.intro h3 { margin: 0 0 0.4rem; }
.intro p { margin: 0 0 0.5rem; line-height: 1.3; }
.intro p > br + .subtitle { padding-left: 0; }
@media (max-width: 34rem) {
  .intro { grid-template-columns: 1fr; row-gap: 1.25rem; }
}
/* Image grid: wrap images in a `::: grid` fence, one image per paragraph.
   Columns fit themselves to the viewport; tiles are square-cropped. */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
  gap: 0.5rem;
  margin: 1.75rem 0;
}
.grid figure { margin: 0; }
.grid img {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 2px;
}
/* Alt text stays on the <img> for screen readers; the visible caption pandoc
   generates alongside it is hidden. */
.grid figcaption { display: none; }
.grid p { margin: 0; }
/* Album list — generated into albums.md by gen-albums.py. Two columns: cover,
   title, artist and score down the left; the tracklist down the right. Sorted
   best-first by the generator, so no rank numbers. */
/* Sized to its contents rather than the page, then centred as a unit — the
   cover column and the tracklist column travel together. */
.album {
  display: grid;
  grid-template-columns: 8.25rem minmax(0, 22em);
  gap: 0 1.15rem;
  margin: 1.9rem auto;
  width: fit-content;
  max-width: 100%;
  align-items: start;
  font-size: 0.75em;
}
.album .meta p { margin: 0.1rem 0; text-align: left; }
.album .cover {
  display: block;
  width: 100%;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: 2px;
  margin-bottom: 0.4rem;
}
/* Score: the generator hands over `--h` (0 red … 128 green) plus the lightness
   that holds a fixed contrast against each background — `--l` for light,
   `--ld` for dark. Fixed lightness instead would go muddy through the yellows
   and fall to 1.7:1 around a score of 50. */
.score {
  font-size: 1.55em;
  font-weight: bold;
  line-height: 1.2;
  color: hsl(var(--h, 0) 70% var(--l, 34%));
}
[data-theme="dark"] .score { color: hsl(var(--h, 0) 60% var(--ld, 62%)); }
/* Tracklist: one song per row, score at the right edge. Capped well short of
   the column so the score sits near the title instead of stranded across the
   page. */
.album .tracks ul { list-style: none; margin: 0; padding: 0; }
.album .tracks li {
  display: flex;
  justify-content: space-between;
  gap: 0.9em;
  line-height: 1.45;
  padding: 0.08em 0.45em;
  border-radius: 2px;
}
/* Faint zebra striping so a row's score stays tied to its title across the gap. */
.album .tracks li:nth-child(odd) { background: var(--rule); }
.album .ts { color: var(--muted); font-variant-numeric: tabular-nums; flex: none; }
/* A long title is clipped with an ellipsis, and scrolls on hover. `.n` is the
   clipping box and the query container; `.t` is the text that moves inside it.
   100cqw is the box, 100% is the text's own width, so the travel is exactly
   the overflow — and min() with 0 means a title that already fits never
   budges. */
.album .n {
  display: block;
  /* `flex: 1` is load-bearing: inline-size containment stops this box sizing
     itself from its own text, so without a flex basis it collapses to zero. */
  flex: 1;
  min-width: 0;
  overflow: hidden;
  container-type: inline-size;
  white-space: nowrap;
  /* Fades the cut edge rather than chopping mid-letter. `text-overflow` can't
     help here — it doesn't ellipsize an inline-block child. Self-adjusting: a
     title that fits has nothing under the fade, so nothing looks different. */
  --fade: linear-gradient(to right, #000 calc(100% - 1.6em), transparent);
  -webkit-mask-image: var(--fade);
  mask-image: var(--fade);
}
.album .n .t { display: inline-block; }
.album li:hover .n .t { animation: marquee 4.5s ease-in-out infinite alternate; }
@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(min(0px, calc(100cqw - 100%))); }
}
/* Someone who asked the OS for less motion gets the ellipsis, not a crawl. */
@media (prefers-reduced-motion: reduce) {
  .album li:hover .n { text-overflow: ellipsis; }
  .album li:hover .n .t { animation: none; }
}
/* Phone: stack, and cap the cover so it doesn't become a full-width poster. */
@media (max-width: 34rem) {
  .album { grid-template-columns: 1fr; gap: 0.6rem; }
  .album .cover { max-width: 9rem; }
}
