/* ============================================================
   THE PAGE
   Two layouts over one set of markup:
     split — projects and articles mirrored around a centre rule
     feed  — everything merged, newest first, single column
   The switch only changes [data-layout] on .board; JS moves the
   entries between lists. No duplicated markup anywhere.
   ============================================================ */

.wrap {
  width: 100%;
  max-width: var(--max-width);
  margin-inline: auto;
  padding-inline: var(--grid-margin);
}

/* ---------- masthead ---------- */

.masthead {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--sp-3);
  padding-block-start: var(--sp-3);
}

.name {
  font-size: var(--fs-small);
  font-style: italic;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  line-height: 1.4;
  color: var(--fg);
  transition: color var(--dur-fast) ease;
}

.name:hover,
.name:focus-visible {
  color: var(--fg-muted);
}

/* The marks sit on a baseline rather than a box edge, because they
   are set in two different families and only the baseline is common
   to both. */
.marks {
  flex: 0 0 auto;
  display: flex;
  align-items: baseline;
  gap: var(--sp-3);
}

/* A single glyph rather than a word, so it carries the whole link on
   its own: it needs the size to read as a mark, and the label voice's
   tracking would sit as dead space to its right, pushing the last one
   out of line with the page edge. */
/* text-transform has to be cleared — the same trap as the layout
   switch below: these carry the .label voice for its colour, and
   uppercasing turns μ into Μ, which is just an M. */
.mark__glyph {
  font-family: var(--font-greek);
  font-size: var(--fs-body-large);
  letter-spacing: 0;
  line-height: 1;
  text-transform: none;
  transition: color var(--dur-fast) ease;
}

a.mark:hover .mark__glyph,
a.mark:focus-visible .mark__glyph {
  color: var(--fg);
}

/* Current page: the link is inert, so mark it rather than hide it. */
.mark[aria-current="page"] .mark__glyph {
  color: var(--fg);
}

/* ---------- layout switch ---------- */

.switcher {
  display: flex;
  justify-content: center;
  margin-block-start: var(--sp-6);
}

.switch {
  font: inherit;
  color: var(--fg-faint);
  background: none;
  border: 0;
  /* The glyph is small; the padding is what makes the button big
     enough to hit. */
  padding: var(--sp-2) var(--sp-3);
  cursor: pointer;
  transition: color var(--dur-fast) ease;
}

/* τ and ε carry the whole control, so they get weight and size the
   word never needed — and text-transform has to be cleared, or
   uppercasing turns τ into Τ and ε into Ε. */
.switch__symbol {
  display: block;
  font-family: var(--font-greek);
  font-size: var(--fs-body-large);
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0;
  text-transform: none;
}

.switch:hover,
.switch:focus-visible {
  color: var(--fg);
}

/* ---------- the board ---------- */

.board {
  position: relative;
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr));
  column-gap: var(--grid-gutter);
  row-gap: var(--sp-6);
  /* Margin, not padding, so the centre rule below spans the columns
     rather than the empty space above them. */
  margin-block: var(--sp-6) var(--sp-7);
}

/* The centre rule sits at exactly 50% of the board, which is why it's
   a pseudo-element and not a border on a column: a border lands on a
   grid line, and grid lines fall half a gutter off centre. */
.board::before {
  content: "";
  position: absolute;
  left: 50%;
  top: calc(var(--sp-3) * -1);
  bottom: calc(var(--sp-3) * -1);
  width: var(--stroke);
  background: var(--rule);
  transform: translateX(-50%);
  display: none;
}

.group {
  grid-column: 1 / -1;
}

.group__heading {
  margin-block-end: var(--sp-5);
}

/* Stacked (narrow split, and the no-JS fallback): a horizontal rule
   between sections stands in for the centre spine. */
.board[data-layout="split"] .group + .group {
  border-block-start: var(--stroke) solid var(--rule);
  padding-block-start: var(--sp-5);
}

@media (min-width: 600px) {
  .board[data-layout="split"] {
    row-gap: 0;
  }

  .board[data-layout="split"]::before {
    display: block;
  }

  .board[data-layout="split"] .group--projects {
    grid-column: 1 / span 4;
    /* Mirrored: the left column reads toward the spine... */
    text-align: right;
  }

  .board[data-layout="split"] .group--articles {
    grid-column: 5 / span 4;
    /* ...and the right column away from it. */
    text-align: left;
  }

  .board[data-layout="split"] .group + .group {
    border-block-start: 0;
    padding-block-start: 0;
  }
}

@media (min-width: 1024px) {
  .board[data-layout="split"] .group--projects {
    grid-column: 1 / span 6;
  }
  .board[data-layout="split"] .group--articles {
    grid-column: 7 / span 6;
  }
}

/* Feed: one centred column, always left-aligned. */
.group--feed {
  grid-column: 1 / -1;
}

@media (min-width: 600px) {
  .group--feed {
    grid-column: 2 / span 6;
  }
}

@media (min-width: 1024px) {
  .group--feed {
    grid-column: 4 / span 6;
  }
}

/* ---------- entries ---------- */

.entries {
  display: flex;
  flex-direction: column;
  gap: var(--sp-5);
}

.entry__meta {
  display: flex;
  gap: var(--sp-2);
  margin-block-end: var(--sp-1);
}

.entry__kind {
  display: none;
}

/* The kind is only worth stating once the streams are merged — in
   split mode the column heading already says it. */
.board[data-layout="feed"] .entry__kind {
  display: inline;
  color: var(--fg-muted);
}

.entry__title {
  display: inline-block;
  font-size: var(--fs-body-large);
  line-height: 1.3;
  font-weight: 400;
  transition: color var(--dur-fast) ease;
}

.entry__title:hover {
  color: var(--fg-muted);
}

/* Underline as a block after the text, so it clears the descenders
   and animates with transform alone. */
.entry__title::after {
  content: "";
  display: block;
  height: var(--stroke);
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-base) var(--ease-emphasized);
}

.entry__title:hover::after,
.entry__title:focus-visible::after {
  transform: scaleX(1);
}

.entry__desc {
  margin-block-start: var(--sp-1);
  color: var(--fg-muted);
  max-width: 44ch;
}

@media (min-width: 600px) {
  /* In the right-aligned column the measure has to be pinned to the
     right edge and the underline has to grow from the right, or both
     drift away from the spine. */
  .board[data-layout="split"] .group--projects .entry__desc {
    margin-inline-start: auto;
  }
  .board[data-layout="split"] .group--projects .entry__meta {
    justify-content: flex-end;
  }
  .board[data-layout="split"] .group--projects .entry__title::after {
    transform-origin: right;
  }
}

/* ---------- about page ---------- */

.about {
  margin-block: var(--sp-6) var(--sp-7);
}

.about__body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  max-width: 62ch;
  margin-block-start: var(--sp-5);
}

/* Underlined links: see the shared rule in the pieces section below. */

.about__portrait {
  width: min(260px, 100%);
  aspect-ratio: 4 / 5;
  margin-block-start: var(--sp-5);
}

/* ---------- music page ----------
   The one page on the site whose content isn't known at build time:
   js/music.js fills it from /api/spotify. Every state it can be in —
   loading, empty, broken — is a line of quiet text in the same slot,
   so the page never jumps as the answer arrives.

   This page also drops the .label voice that headings use elsewhere.
   Uppercase and letter-spacing read as a filing system, and a page
   about what someone happens to be listening to shouldn't. Hierarchy
   here comes from colour and size alone. */

.music {
  margin-block: var(--sp-6) var(--sp-7);
}

/* The listening column and the suggestion box are one composition,
   centred as a pair rather than pushed to either edge. Text stays
   left-aligned inside them — a list of tracks set centred would be
   unreadable. */
.music__grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr));
  column-gap: var(--grid-gutter);
  row-gap: var(--sp-6);
}

.music__main,
.recos {
  grid-column: 1 / -1;
}

@media (min-width: 600px) {
  .music__main {
    grid-column: 1 / span 5;
  }
  .recos {
    grid-column: 6 / span 3;
  }
}

@media (min-width: 1024px) {
  /* 2..7 and 9..11: the pair spans columns 2 to 11 of 12, so the
     composition sits centred with equal air either side. */
  .music__main {
    grid-column: 2 / span 6;
  }
  .recos {
    grid-column: 9 / span 3;
  }
}

.music__title {
  font-size: var(--fs-body-large);
  font-weight: 400;
  line-height: 1.3;
}

.music__section {
  margin-block-start: var(--sp-6);
}

.music__heading {
  font-size: var(--fs-body);
  font-weight: 400;
  color: var(--fg-muted);
  margin-block-end: var(--sp-3);
}

.music__note {
  color: var(--fg-faint);
}

/* --- now playing --- */

.now {
  display: flex;
  align-items: flex-start;
  gap: var(--sp-3);
}

.now__art {
  flex: 0 0 auto;
  width: min(132px, 32%);
  aspect-ratio: 1;
}

/* Takes the rest of the row rather than shrinking to its longest
   line — otherwise the progress rule below is only as wide as the
   track happens to be titled. min-width: 0 so a long title wraps
   instead of pushing the art off. */
.now__text {
  flex: 1 1 auto;
  min-width: 0;
}

.now__state,
.track__time {
  font-size: var(--fs-x-small);
  color: var(--fg-faint);
}

.now__title {
  display: inline-block;
  font-size: var(--fs-body-large);
  line-height: 1.3;
  margin-block-start: var(--sp-1);
}

.now__artists {
  color: var(--fg-muted);
}

.now__album {
  font-size: var(--fs-x-small);
  color: var(--fg-faint);
  margin-block-start: var(--sp-1);
}

/* Progress. A rule that fills rather than a bar that grows, in
   keeping with everything else on the page being a hairline. */
.now__bar {
  max-width: 320px;
  height: var(--stroke);
  background: var(--rule);
  margin-block-start: var(--sp-3);
}

/* A step up from the track's colour, not the full foreground: the
   rule has to be readable as a proportion without becoming the
   loudest thing on the page. */
.now__bar span {
  display: block;
  height: 100%;
  background: var(--fg-muted);
  transition: width var(--dur-base) linear;
}

/* --- recently played --- */

.tracks {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

.track {
  display: flex;
  align-items: flex-start;
  gap: var(--sp-3);
}

.track__art {
  flex: 0 0 auto;
  width: 44px;
  aspect-ratio: 1;
}

/* min-width: 0 so a long title wraps instead of stretching the row. */
.track__text {
  min-width: 0;
}

.track__title {
  display: inline-block;
  line-height: 1.3;
  transition: color var(--dur-fast) ease;
}

.track__title:hover {
  color: var(--fg-muted);
}

/* Same underline as an index entry: a block after the text, so it
   clears the descenders and animates with transform alone. */
.track__title::after {
  content: "";
  display: block;
  height: var(--stroke);
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-base) var(--ease-emphasized);
}

.track__title:hover::after,
.track__title:focus-visible::after {
  transform: scaleX(1);
}

.track__artists {
  color: var(--fg-muted);
}

/* ---------- the suggestion box ----------
   A panel rather than a bare form: it is the one thing on the site
   asking the reader for something, so it gets a rule down its side
   to say it's a different kind of block, and nothing louder. */

.recos {
  border-inline-start: var(--stroke) solid var(--rule);
  padding-inline-start: var(--sp-3);
}

/* The kaomoji sits in the heading but isn't of it: Newsreader carries
   none of these glyphs, so each falls back to whatever face the system
   has for Malayalam and the symbol blocks. Given the mixed metrics, it
   gets its own spacing rather than relying on a space character, and
   line-height: 1 so a tall fallback glyph can't stretch the heading. */
.recos__face {
  margin-inline-start: var(--sp-2);
  line-height: 1;
  white-space: nowrap;
}

.recos__form {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  margin-block-start: var(--sp-3);
}

/* Anchors the suggestion list, which is positioned against it. */
.field {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
}

.field__label {
  font-size: var(--fs-x-small);
  color: var(--fg-faint);
}

/* A rule under the text rather than a box around it — the same
   hairline the rest of the page is built from. */
.field__input {
  font: inherit;
  font-size: var(--fs-small);
  color: var(--fg);
  background: none;
  border: 0;
  border-block-end: var(--stroke) solid var(--rule);
  border-radius: 0;
  padding: var(--sp-1) 0;
  width: 100%;
  transition: border-color var(--dur-fast) ease;
}

.field__input:hover,
.field__input:focus {
  border-color: var(--fg-muted);
}

/* --- the typeahead --- */

.suggestions {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 2;
  /* Its own surface: it sits over the page, so unlike everything else
     here it can't be transparent. */
  background: var(--bg);
  border: var(--stroke) solid var(--rule);
  border-radius: var(--corner-small);
  max-height: 15em;
  overflow-y: auto;
}

.suggestion {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-1) var(--sp-2);
  cursor: pointer;
}

.suggestion:hover,
.suggestion[aria-selected="true"] {
  background: var(--surface);
}

.suggestion__art {
  flex: 0 0 auto;
  width: 32px;
  aspect-ratio: 1;
}

/* min-width: 0 lets the two lines below truncate instead of widening
   the list past the field it hangs from. */
.suggestion__text {
  min-width: 0;
}

.suggestion__title,
.suggestion__artists {
  display: block;
  font-size: var(--fs-x-small);
  line-height: 1.4;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.suggestion__artists {
  color: var(--fg-muted);
}

/* --- sending --- */

.recos__send {
  font: inherit;
  font-size: var(--fs-small);
  color: var(--fg-muted);
  background: none;
  border: var(--stroke) solid var(--rule);
  border-radius: var(--corner-small);
  padding: var(--sp-1) var(--sp-3);
  align-self: start;
  cursor: pointer;
  transition:
    color var(--dur-fast) ease,
    border-color var(--dur-fast) ease;
}

.recos__send:hover:not(:disabled),
.recos__send:focus-visible:not(:disabled) {
  color: var(--fg);
  border-color: var(--fg-muted);
}

.recos__send:disabled {
  color: var(--fg-faint);
  cursor: default;
}

/* Empty until there is something to say, so it takes no space when
   there isn't. */
.recos__status:empty {
  display: none;
}

.recos__status,
.recos__thanks {
  font-size: var(--fs-small);
  color: var(--fg-muted);
}

/* The honeypot. Off-screen rather than display:none — a bot that
   skips hidden fields would skip this one too, and then it catches
   nothing. */
.honey {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}
/* ---------- article & project pages ----------
   One block serves both: an article is a piece with prose, a project
   is a piece with links and images. Same head, same measure, so the
   two kinds read as one site rather than two templates.

   The title sits at entry size, not larger. The page has one heading
   with a date above it — nothing here needs a size the index doesn't
   already have. */

.piece {
  margin-block: var(--sp-6) var(--sp-7);
}

.piece__meta {
  display: flex;
  gap: var(--sp-2);
  margin-block-end: var(--sp-1);
}

.piece__title {
  font-size: var(--fs-body-large);
  line-height: 1.3;
  font-weight: 400;
  /* Titles want a tighter measure than prose does. */
  max-width: 28ch;
}

.piece__body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  max-width: 62ch;
  margin-block-start: var(--sp-5);
}

/* --- prose written in Notion ---
   .piece__body is a flex column with a gap, so these set only what
   the gap can't: the extra air above a heading, and the indents. */

.piece__heading {
  margin-block-start: var(--sp-3);
}

.piece__list {
  /* base.css strips list markers site-wide, which is right for the
     index and wrong inside an article. */
  list-style: disc;
  padding-inline-start: var(--sp-3);
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
}

ol.piece__list {
  list-style: decimal;
}

.piece__quote {
  border-inline-start: var(--stroke) solid var(--rule);
  padding-inline-start: var(--sp-3);
  color: var(--fg-muted);
  font-style: italic;
}

.piece__code {
  font-family: var(--font-mono);
  font-size: var(--fs-x-small);
  line-height: 1.6;
  background: var(--surface);
  border-radius: var(--corner-small);
  padding: var(--sp-3);
  /* Long lines scroll inside the block rather than widening the page. */
  overflow-x: auto;
}

.piece__rule {
  width: 100%;
  height: var(--stroke);
  border: 0;
  background: var(--rule);
}

/* A link into a child page. It is a way further in rather than a
   sentence, so the arrow sits on the paragraph and not on the link —
   otherwise .piece__body a would underline it along with the title. */
.piece__subpage {
  display: flex;
  gap: var(--sp-2);
}

.piece__subpage::before {
  content: "→";
  color: var(--fg-faint);
}

/* The way back up, in the slot a piece uses for its kind and date. */
.piece__parent::before {
  content: "← ";
  color: var(--fg-faint);
}

/* Draft text. Delete the class once the piece is written, and it goes
   back to reading as ordinary body copy. */
.piece__draft {
  color: var(--fg-faint);
  border-inline-start: var(--stroke) solid var(--rule);
  padding-inline-start: var(--sp-3);
}

/* Links out — repo, live site, whatever the project points at. */
.links {
  display: flex;
  flex-direction: column;
  align-items: start;
  gap: var(--sp-2);
  margin-block-start: var(--sp-5);
}

.about__body a,
.piece__body a,
.links a {
  border-block-end: var(--stroke) solid var(--rule);
  transition: border-color var(--dur-fast) ease;
}

.about__body a:hover,
.piece__body a:hover,
.links a:hover {
  border-color: currentColor;
}

/* Notion's columns. They wrap to a stack once a column would be
   narrower than it can usefully be — two columns inside a 62ch
   measure is already tight, and three is tighter still. */
.piece__columns {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--sp-3);
}

.piece__column {
  /* Equal share by default; a column carrying Notion's width_ratio
     overrides the grow value inline. min-width: 0 so a wide child
     (an image, a code block) can't push the row past the measure. */
  flex: 1 1 200px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

/* Images. auto-fit rather than a fixed column count, so one image
   fills the measure and four settle into a grid without a breakpoint
   for each case. */
.gallery {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--sp-3);
  margin-block-start: var(--sp-6);
}

.gallery__item {
  margin: 0;
}

.gallery__frame {
  display: block;
  width: 100%;
}

/* Only the empty placeholder gets a forced ratio — imposing one on a
   real <img> would squash it. */
span.gallery__frame {
  aspect-ratio: 4 / 3;
}

.gallery__caption {
  margin-block-start: var(--sp-2);
}

/* ---------- placeholder frame ----------
   Swap for a real <img>. The tint is only here so an empty slot
   reads as intentional until you do. */

.frame {
  border: var(--stroke) solid var(--rule);
  border-radius: var(--corner-small);
  background-image: linear-gradient(135deg, var(--surface), var(--rule));
}
