:root {
    /* Palette — from the "Rhea — portfolio" canvas */
    --color-bg: #faf9f6;
    --color-surface: #ffffff;
    --color-text: #16151d;
    --color-muted: #5b5966;
    --color-faint: #a9a7b3;
    --color-border: #e6e4ee;

    --color-accent: #5b4cf5;
    --color-accent-dark: #4a3dd4;
    --color-accent-soft: #bfb8f8;
    --color-accent-wash: #eceaf6;

    /* One hue per timeline entry, so the steps are told apart by colour and not
       only by position. Education is anchored to the first; the jobs cycle the
       rest. All four are dark enough to carry white text when a block fills.
       The JS puts the chosen one on --tl-hue per element; every timeline rule
       reads that, falling back to the accent. */
    --color-study: #0b6b61;
    --color-work-1: #5b4cf5;
    --color-work-2: #b3541e;
    --color-work-3: #a8386b;

    /* Type */
    --font-heading: "Bricolage Grotesque", "Helvetica Neue", Arial, sans-serif;
    --font-body: "DM Sans", system-ui, sans-serif;
    --font-mono: "DM Mono", ui-monospace, monospace;
    --font-script: "Dancing Script", "Brush Script MT", cursive;

    /* Radii */
    --radius-sm: 10px;
    --radius-card: 24px;
    --radius-pill: 999px;

    /* Where a section's heading sits once its sticky view has engaged: 100px,
       below the 64px nav pill with air under it. Every locked section lines its
       heading up on this, so the label does not jump as you move between them.

       Each section reaches it differently — the timeline's heading is sticky in
       its own right and subtracts its padding, Recently pads the top of its
       pinned box, Projects sticks a whole column — so the three cannot simply
       share a `top`. They share this instead. */
    --heading-top: 6.25rem;

    /* The air between a statement and the rule that closes it, where the two
       sit in a column together — Recently's pinned box and Projects' sticky
       side. The base .rule keeps its roomier 3rem for a rule that separates
       two stacked blocks instead, which is a different job. */
    --rule-gap: 1.75rem;
}

* {
  box-sizing: border-box;
}

body {
    background-color: var(--color-bg);
    margin: 0;
    font-family: var(--font-body);
    font-size: 15px;
    color: var(--color-text);
    padding-top: 5rem;
    -webkit-font-smoothing: antialiased;
}

h1,
h2,
h3 {
  font-family: var(--font-heading);
  letter-spacing: -0.025em;
  text-wrap: balance;
}

/* 84px at the design's 1440 board, scaling down on narrow screens */
h1 {
  font-size: clamp(2.75rem, 6vw, 5.25rem);
  font-weight: 800;
  line-height: 0.98;
  letter-spacing: -0.03em;
}

/* 56px at the design's 1440 board */
h2 {
  font-size: clamp(2rem, 4.5vw, 3.5rem);
  font-weight: 700;
  line-height: 1.02;
}

/* DM Mono eyebrow — the design's signature label style */
.eyebrow {
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  margin: 0 0 0.75rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--color-accent);
}

/* Every section now leads with a statement rather than a title, so the heading
   is this label. It has to undo the global h2 rule, which is both later in the
   file and sets a 3.5rem display face. */
h2.eyebrow {
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  font-weight: 400;
  line-height: 1.4;
  letter-spacing: 0.1em;
  text-wrap: wrap;
  margin: 0 0 1.25rem;
}

/* --- The statement ---
   From board 10 of the design canvas: the sentence that opens a section, with
   the facts that matter marked inside it. Shared by every section. */
.statement {
  margin: 0;
  max-width: 34ch;
  font-family: var(--font-heading);
  font-weight: 500;
  font-size: clamp(1.5rem, 2.6vw, 2.25rem);
  line-height: 1.38;
  letter-spacing: -0.02em;
  text-wrap: pretty;
}

/* A number or a date. Underlined rather than coloured, so the sentence keeps
   one voice. */
.statement-when {
  border-bottom: 2px solid var(--color-text);
  padding-bottom: 1px;
}

/* The one thing in a sentence set in full weight. */
.statement-what { font-weight: 700; }

/* A name — an employer, a technology, a place. The artboard gives each its own
   colour; the page has one accent, so they share it and read as a family. */
.statement-where {
  padding: 2px 10px;
  border-radius: var(--radius-pill);
  background-color: var(--color-accent-wash);
  color: var(--color-accent-dark);
  font-weight: 600;
  white-space: nowrap;
}

/* Punctuation after a pill is pushed away by the pill's own 10px of padding.
   This pulls it back to something like a normal letter gap. */
.statement-sep { margin-left: -8px; }

.rule {
  height: 1px;
  margin: 3rem 0 2.5rem;
  background-color: var(--color-border);
}

/* --- Section layout: a sticky statement beside the content ---
   The statement is short and the content is long, so the statement holds its
   place while the content scrolls past it. Stacked by default; the two columns
   only appear where there is width for them. */
.section-layout {
  display: flex;
  flex-direction: column;
}

.section-body { min-width: 0; }

@media (min-width: 901px) {
  .section-layout {
    flex-direction: row;
    gap: 64px;
    /* Load-bearing: a stretched flex child is already full height and has no
       room left to stick. Without this the side column never moves. */
    align-items: flex-start;
  }

  .section-side {
    position: sticky;
    top: var(--heading-top);
    flex: none;
    width: 400px;
  }

  .section-body { flex: 1 1 0; }

  /* The statement is sized for a full-width line; in a 400px column it has to
     come down several steps or it sets nine characters to the line. */
  .section-side .statement {
    max-width: none;
    font-size: clamp(1.125rem, 1.5vw, 1.5rem);
    line-height: 1.4;
  }

  /* Selected projects is the exception: it opens at the same size Recently
     does, so the two read as a pair rather than one announcing itself and the
     other muttering. That is the base .statement scale, put back — the column
     is still 400px, so it sets a shorter line than Recently's does and runs to
     a few more of them. #contact keeps the quieter size above. */
  #projects .section-side .statement {
    font-size: clamp(1.5rem, 2.6vw, 2.25rem);
    line-height: 1.38;
  }

  /* Beside the content the rule closes the column rather than separating two
     stacked blocks, so it loses its bottom margin. */
  .section-side .rule { margin: var(--rule-gap) 0 0; }
}

/* The hero's lead is prose, not a statement, so its mark sits at text size. */
.hero-lead .statement-where {
  padding: 1px 8px;
  font-weight: 600;
}

a {
  color: var(--color-accent);
}

#site-nav a.active {
  color: var(--color-accent-dark);
}

#site-nav a:hover {
  color: var(--color-accent-dark);
}

main,
header,
footer {
  max-width: 1440px;
  margin: 0 auto;
  padding: 0 clamp(1rem, 5.5vw, 5rem);
}

/* --- Header hero --- */
.hero {
  display: grid;
  justify-items: center;
  align-content: center;
  gap: 1.5rem;
  text-align: center;
  min-height: min(72vh, 44rem);
  padding: clamp(6rem, 14vh, 9rem) 0 clamp(3rem, 8vh, 5rem);
}

.hero h1 {
  margin-top: 0.5rem;
  margin-bottom: 0;
}

/* Name as an oversized script display, now sharing a line with the greeting */
.hero-name {
  font-family: var(--font-script);
  font-weight: 700;
  font-size: clamp(10rem, 20vw, 18rem);
  line-height: 0.95;
  letter-spacing: 0;
  color: var(--color-accent);
  padding-bottom: 0.12em;
  text-wrap: balance;
}

.hero-first {
  display: inline;
}

/* Geometric sans against the cursive. The spans sit flush in the markup, so
   the word space between them is this margin rather than whitespace. */
.hero-greeting {
  display: inline;
  margin-right: 0.18em;
  font-family: var(--font-heading);
  font-weight: 600;
  font-size: clamp(2.75rem, 5vw, 4.5rem);
  line-height: 1.15;
  letter-spacing: -0.02em;
  color: var(--color-text);
}

.hero-lead {
  font-size: clamp(1.0625rem, 1.6vw, 1.1875rem);
  line-height: 1.55;
  color: var(--color-muted);
  max-width: 44ch;
  margin-inline: auto;
}

section {
  padding: clamp(4rem, 8vw, 7.5rem) 0;
}

h2 {
  margin-top: 0;
  margin-bottom: 3.5rem;
}

p {
  line-height: 1.5;
}

/* #projects {
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem;
} */

.card {
  background-color: var(--color-surface);
  border: 1px solid var(--color-accent);
  border-radius: var(--radius-card);
  padding: 1rem;
  flex: 1 1 280px;
  display: flex;
  flex-direction: column;
}

/* Photo slot. Renders as a tinted panel until an <img> is dropped in, so the
   card keeps its shape whether or not an entry has a picture. */
.card-media {
  aspect-ratio: 16 / 10;
  margin: -1rem -1rem 1rem;
  border-radius: calc(var(--radius-card) - 1px) calc(var(--radius-card) - 1px) 0 0;
  background-color: var(--color-accent-wash);
  overflow: hidden;
}

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

.card h3 {
  margin-top: 0;
}

#nav-toggle {
  display: block;
}

#site-nav {
  position: fixed;
  top: 1.5rem;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 0.5rem;
  background-color: rgba(255, 255, 255, 0.65);
  -webkit-backdrop-filter: blur(14px) saturate(160%);
  backdrop-filter: blur(14px) saturate(160%);
  border-radius: 9999px;
  padding: 0.5rem 1rem;
  /* box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); */
  z-index: 10;
}

#site-nav a {
  position: relative;
  display: flex;
  align-items: center;
  padding: 0.5rem;
  transition: transform 0.1s ease;
}

#site-nav a:active { 
    transform: scale(0.88); 
    color: var(--color-accent-dark);
}

#site-nav a::after {
  content: attr(aria-label);
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-top: 0.5rem;
  background-color: var(--color-text);
  color: white;
  font-size: 0.75rem;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
}

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

/* #site-nav {
  display: none;
  flex-direction: column;
}

#site-nav.nav-open {
  display: flex;
}

@media (min-width: 600px) {
  #nav-toggle {
    display: none;
  }

  #site-nav {
    display: flex;
    flex-direction: row;
    gap: 1rem;
  }
} */

/* Additive on purpose. The old version set opacity: 0 here and relied on the
   observer to add .visible — which means anything it watches is invisible until
   JS says otherwise, and stays invisible forever if the observer never fires.
   For a section that IS the content, that is a bad trade for a fade. So the
   element is visible on its own and the class only animates it in. */
.reveal.visible {
  animation: reveal-rise 0.6s ease-out both;
}

@keyframes reveal-rise {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: none; }
}

/* The timeline now leans on this for every row, so it needs the guard it never
   had. Safe to switch off: the animation is `both`-filled and ends at full
   opacity with no transform, so without it everything simply starts there. */
@media (prefers-reduced-motion: reduce) {
  .reveal.visible { animation: none; }
}

.btn {
  display: inline-block;
  background-color: var(--color-accent);
  color: white;
  padding: 0.85rem 1.75rem;
  border-radius: var(--radius-pill);
  text-decoration: none;
  font-weight: 600;
}

.btn:hover {
  background-color: var(--color-accent-dark);
}

/* --- Timeline: a statement, the degree, then the jobs ---
   Board 10 from the design canvas, rendered in the page's own tokens rather
   than the artboard's cream-and-serif: the shape is the idea, the palette is
   the site's. */

/* --- The heading ---
   Sticky from 701px up, where the section is several screens long and a label
   that leaves partway through stops telling you what you are looking at — which
   is the one job it has. Below that it is an ordinary heading; see the media
   query.

   How long it holds for is decided by its containing block, and the heading
   sits directly in #timeline so that is the whole section — all four views, the
   experience rail included.

   That is longer than the rail itself: the rail's frame gives up sticking
   partway through its stage and scrolls out over most of a viewport, while this
   would stay pinned to the very end and hang over an empty screen. So the
   transform is handed to the JS from the moment the rail starts to leave — see
   createExitFollow — which cancels the stickiness exactly and lets the two go
   off the top as one view.

*/
.tl-heading {
  margin: 0 0 1.25rem;
}

/* Sticky only where there is room for it, in BOTH directions. Width alone was
   the wrong test: a 1400px window that is 650px tall cuts the locked views off
   just as badly as a phone does, and a label parked under the nav pill costs
   that screen the same fifth of its height. The heading earns its place by
   orienting you across several screens of runway — so where there is no runway,
   it is only in the way, and it goes back to being an ordinary heading.

   Same pair of thresholds the locking uses, deliberately: a sticky header over
   content that is not locked would be the odd one out on a page where nothing
   else sticks below 701px.

   Opaque background and a z-index because everything scrolls underneath it:
   the three stages, then the rail. */
@media (min-width: 701px) and (min-height: 700px) {
  .tl-heading {
    position: sticky;
    /* Less its own padding, so it is the heading's text that lands on
       --heading-top and not its border box — which is what the eye lines up
       against the other sections. */
    top: calc(var(--heading-top) - 0.75rem);
    z-index: 5;           /* later siblings would otherwise paint over it */
    padding: 0.75rem 0;
    background-color: var(--color-bg);
  }
}

/* --- The stages ---
   Three screens, each one held still and then pushed up out of frame: the
   sentence, the degree in full, and the line that hands off to the jobs. A
   stage is a runway; the inner box sticks inside it, and the JS pushes
   .tl-stage-content rather than letting it scroll away and get sliced off
   against the opaque heading. The rail afterwards is the destination and is
   not staged.

   Unpinned (narrow, or motion turned down) a stage is just a block with room
   around it. Three full screens to scroll past is a poor trade on a phone, and
   with nothing sticking there is nothing to hold still. */
.tl-stage {
  padding-block: 1.5rem 3.5rem;
}

/* Set by the driver on a stage whose content rendered empty. !important is not
   wanted here, but the rule does have to outrank the `display: block` the
   pinned query gives .tl-stage — hence the two classes. */
.tl-stage.is-empty {
  display: none;
}

/* The opening sentence still earns most of a screen to itself, stacked or not:
   it is the one thing here that is only a sentence. */
.tl-stage--opening {
  min-height: calc(100vh - 12rem);
  min-height: calc(100dvh - 12rem);
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-block: 0;
}

/* --- Where the locking applies ---
   min-height is as load-bearing as min-width here. Every locked view is held in
   a frame of 100dvh less the 8.5rem that clears the nav pill and the heading,
   and the tallest of them — the rail, at three rows — has to fit inside it. A
   lock that holds something you cannot fully see is worse than no lock: it stops
   the page moving while hiding the part you were scrolling towards.

   700px, because that is where a laptop actually lands. A 13" screen gives a
   browser about 760px of viewport and a 1440x900 one about 790px, so a threshold
   set to the content's natural height (~735px) would have switched the whole
   feature off on the machines it was designed on. The content is compacted to
   fit instead — see the nested max-height block — which is the right way round:
   make it fit the screens people have, and only give up on the ones where it
   genuinely cannot. */
@media (min-width: 701px) and (min-height: 700px) and (prefers-reduced-motion: no-preference) {
  .tl-stage {
    /* A screen to read it on, then the runway. HOLD in the driver splits this:
       at 0.42 that is roughly 273px locked still at full opacity, then ~377px
       of pushing it out. The two move together — raising HOLD on its own buys
       a longer lock by leaving the exit less room, which speeds the exit up, so
       the runway grew with it to keep the exit at the length it had. */
    height: calc(100vh + 650px);
    height: calc(100dvh + 650px);
    min-height: 0;
    padding-block: 0;
    display: block;
  }

  .tl-stage-inner {
    position: sticky;
    top: 0;
    height: 100vh;
    height: 100dvh;
    padding-top: 8.5rem;   /* clears the nav pill and the sticky heading */
    display: flex;
    flex-direction: column;
    justify-content: center;
  }

  /* Transform and opacity are written on every frame of the scroll, so they
     are worth promoting to their own layer. Only inside this query: unpinned
     nothing animates and the hint would be a lie. */
  .tl-stage-content {
    will-change: transform, opacity;
  }

  /* --- Landing on the rail ---
     Left alone, releasing a scroll anywhere in the hand-off leaves the rail
     halfway up the screen with its first row cut by the fold. Snapping settles
     it instead.

     `proximity`, not `mandatory`: mandatory snaps from anywhere, which fights
     reading a page that is mostly ordinary prose — you could not stop partway
     down the rail without being pulled off it. Proximity only acts when the
     scroll already ends near a snap point, so it corrects a near miss and
     otherwise stays out of the way.

     On the scroll container, which is <html> here, not <body>. */
  html {
    scroll-snap-type: y proximity;

    /* Where the top of the scrollport effectively is: below the fixed nav pill
       AND the sticky heading under it. Without this the rail snaps flush to the
       top of the window and its first row lands behind both. It also fixes the
       nav's own anchor jumps, which had the same problem. */
    scroll-padding-top: 9rem;
  }

  .tl-stage {
    scroll-snap-align: start;
  }

  /* --- The rail's own lock ---
     Same runway idea as a push stage, but shorter and with no driver attached:
     the sticky inner holds the rail in frame for this long, then the stage runs
     out and the rail scrolls away by itself.

     `scroll-snap-stop: always` because this is the one the section is
     travelling towards: a fast flick cannot skip straight past it into
     #projects, which is the difference between settling near the rail and
     locking on to it. */
  .tl-stage--hold {
    height: calc(100vh + 500px);
    height: calc(100dvh + 500px);
    scroll-snap-stop: always;
  }

  /* The driver never writes to this one, so promoting it would be a hint the
     page does not keep. */
  .tl-stage--hold .tl-stage-content {
    will-change: auto;
  }

  /* --- Fitting a laptop ---
     At its natural spacing the rail runs ~597px and the frame on a 700px screen
     offers 564px, so it would crop by a row's worth. The space between rows is
     the cheapest thing to give up: it is air, not content, and losing 16px of it
     three times over is invisible next to losing the bottom of the last job.

     Only inside the locking query, and only on the screens that need it — a tall
     screen keeps the roomier spacing. Nothing here is hidden or truncated; the
     rail is the same rail, sitting closer together. */
  @media (max-height: 860px) {
    .tl-stage--hold .tl-entry { padding-bottom: 32px; }
    .tl-stage--hold .tl-entry-desc { margin-top: 0.625rem; }
    .tl-stage--hold .tl-entry::before { top: calc(var(--tl-logo) + 8px); bottom: 8px; }
  }

  /* Tighter still where the frame is under ~600px. */
  @media (max-height: 780px) {
    .tl-stage--hold .tl-entry { padding-bottom: 24px; }
    .tl-stage--hold .tl-entry-desc { margin-top: 0.5rem; }
    .tl-stage--hold .tl-entry-meta { margin-top: 0.625rem; }
  }
}

/* Full width here rather than in a 400px column, so it comes back up in size.
   Every statement in a stage, not just the opening one: the bridge is the same
   kind of sentence doing the same job a screen later, so it is set at the same
   size rather than at a size of its own. */
.tl-stage .statement {
  /* 26ch was set for a two-line sentence. These are paragraphs now: at 26ch they
     run to thirteen lines, about 750px, and the frame holding them is the
     viewport less 8.5rem — so they fit a 900px screen and crop on everything
     shorter. A wider measure is the cheap fix, since it costs lines rather than
     type size. 36ch brings them to nine. */
  max-width: 36ch;
  font-size: clamp(1.75rem, 3.2vw, 2.75rem);
  line-height: 1.32;
}

/* --- The shape of the section ---
   The degree is the frame: every job here sits inside its 2024 -> 2026 window.
   So education leads as a block of its own, a bridging line hands off from it,
   and the jobs run down a vertical rail underneath.

   No cards. What was a wall of tinted, bordered boxes is now carried by two
   things: the logo column, which gives the section its left edge and its
   rhythm, and a hairline rail, which gives it direction. Everything that used
   to be said by a box being filled in is said by type and space instead. */
.tl-body {
  margin-top: 0.5rem;
}

/* The rail ends the section, which is what lets the heading leave with it: any
   bottom padding here would be scroll spent carrying the label off the top
   after the rail had already gone. The air before #projects is that section's
   own top padding. */
#timeline {
  padding-bottom: 0;
}

/* --- The degree, leading ---
   Two columns: the page's largest logo, and everything the degree has to say.
   `align-items: start` so the logo sits level with the label rather than
   centring itself against a body that runs on for another 300px. */
.tl-study {
  --tl-logo: 104px;
  display: grid;
  grid-template-columns: var(--tl-logo) minmax(0, 1fr);
  gap: 28px 32px;
  align-items: start;
}

/* Stacked, the coursework and the capstone fall under the description in the
   same column rather than beside it. */
.tl-study-aside { grid-column: 2; }

/* Wide enough for a third column, which is the whole point of the degree having
   a screen of its own: the aside moves up beside the description instead of
   running on below it, and the block fits one viewport. */
@media (min-width: 901px) {
  .tl-study {
    grid-template-columns: var(--tl-logo) minmax(0, 1fr) minmax(0, 1fr);
    gap: 32px 52px;
  }

  .tl-study-aside {
    grid-column: 3;
    grid-row: 1;
  }

  /* Beside the description rather than under it, the first block starts level
     with the label. */
  .tl-study-aside .tl-extra-block:first-child { margin-top: 0; }
}

.tl-study-role {
  margin: 0;
  max-width: 34ch;
  font-family: var(--font-heading);
  font-size: clamp(1.5rem, 2.4vw, 2rem);
  font-weight: 700;
  line-height: 1.18;
  letter-spacing: -0.025em;
}

/* --- The marks ---
   A logo on the page rather than in a bubble: a white ground and a hairline
   edge, sized by --tl-logo so the rail below can be centred on the same number.

   `contain`, never `cover`. cover crops to fill, which is right for a
   photograph and wrong for a mark that must not be cut into. The placeholders
   in images/ are square, so the two look identical until the day a real file
   with some other aspect ratio lands — and then cover silently slices it. */
.tl-study-logo,
.tl-entry-logo {
  width: var(--tl-logo);
  height: var(--tl-logo);
  flex: none;
  display: grid;
  place-items: center;
  padding: 12px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-card);
  background-color: var(--color-surface);

  /* Only ever reached by the initial that stands in for a missing logo: an
     <img> cannot inherit currentColor, so a real mark keeps its own colours. */
  font-family: var(--font-heading);
  font-weight: 700;
  color: var(--tl-hue, var(--color-accent));
}

.tl-study-logo { font-size: 2.5rem; }
.tl-entry-logo { font-size: 1.75rem; }

.tl-study-logo img,
.tl-entry-logo img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
}

/* --- The bridge ---
   A hand-off, not a headline. It takes the statement's face and its marks but
   not its size, so it cannot compete with the opening sentence one screen up. */
/* Size and measure come from .tl-stage .statement above — the same as the
   opening sentence. All this adds is the margin: it is centred in a stage of
   its own, so it carries none. */
.tl-lead {
  margin: 0;
}

/* --- The rail ---
   One row per job, oldest first. The logo size lives in a custom property
   precisely so the line can be centred on the logo column by arithmetic rather
   than by eye: change --tl-logo and the rail follows it. */
.tl-rail {
  --tl-logo: 72px;
  /* The stack's column. Wide enough for a pill like "Kubernetes" on its own
     line, narrow enough that it never competes with the description for the
     row. Only read inside the 901px query, where a third column exists. */
  --tl-aside: 200px;
  list-style: none;
  margin: 0;
  padding: 0;
}

.tl-entry {
  position: relative;
  display: grid;
  /* minmax(0, 1fr) is load-bearing: a bare 1fr refuses to shrink below its
     content's intrinsic width, and these titles are long enough to push the
     row out sideways and take the page's horizontal scrollbar with it. */
  grid-template-columns: var(--tl-logo) minmax(0, 1fr);
  gap: 28px;
  padding-bottom: 3rem;
}

.tl-entry:last-child { padding-bottom: 0; }

/* The line, drawn per row rather than once down the list: it survives a job
   being added or removed with no other change, and each segment can carry its
   own entry's hue if this ever wants to shift colour down the run.

   It spans the gap BELOW this row's logo, not the full height of the row. An
   absolutely positioned pseudo-element paints above a static sibling, so a
   full-height line would draw straight over the logo instead of behind it —
   and there is no z-index worth spending to fix what the geometry can. */
.tl-entry::before {
  content: "";
  position: absolute;
  top: calc(var(--tl-logo) + 10px);
  bottom: 10px;
  left: calc(var(--tl-logo) / 2);
  width: 1px;
  transform: translateX(-50%);
  background-color: var(--color-border);
}

/* Nothing below the last logo to connect to. */
.tl-rail > :last-child::before { display: none; }

/* Stacked, the pills fall under the description in the body's own column —
   the same default .tl-study-aside takes. */
.tl-entry-aside { grid-column: 2; }

/* At the bottom of the three-column range the row has ~296px left for the
   description once the logo, two gaps and a 200px stack are taken out. The
   stack is the cheaper of the two to give up: a pill list reflows, a sentence
   just gets harder to read. */
@media (max-width: 900px) {
  .tl-rail { --tl-aside: 150px; }
}

/* Wide enough for a third column: the stack moves up beside the description
   instead of running on below it, which is the whole point of asking for it on
   the right.

   701px is not a taste decision and must not be tuned independently: it is the
   same min-width the lock uses. Inside the lock the rail is held in a frame of
   100dvh less 8.5rem — 564px on a 700px screen against a rail that already runs
   ~597px natural — so anything that adds height there gets cropped against the
   heading. Beside the description the stack adds none; stacked underneath it
   adds a label and a pill row to every row, about 60px each.

   Set this above the lock's 701px and you reopen a band that is pinned AND
   stacked, which crops the last job. Below 701px nothing is pinned, the page
   scrolls normally, and the stacked aside is free to take the height. */
@media (min-width: 701px) {
  .tl-entry {
    grid-template-columns: var(--tl-logo) minmax(0, 1fr) var(--tl-aside);
  }

  .tl-entry-aside {
    grid-column: 3;
    grid-row: 1;
  }

  /* Beside the description rather than under it, the block starts level with
     the row's label instead of keeping the stacked rhythm.

     The .tl-entry prefix is doing nothing for the match and everything for the
     cascade: the stacked `.tl-entry .tl-extra-block` rule further down the file
     has the same two-class specificity, and a media query adds none, so without
     the third class that later rule would win at every width. */
  .tl-entry .tl-entry-aside .tl-extra-block { margin-top: 0; }
}

/* The mono label. This is the rule the card wall never had: .tl-block-kind
   carried only a live-state colour override, so the label had been rendering at
   body size by accident. */
.tl-entry-kind {
  margin: 0 0 0.5rem;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--tl-hue, var(--color-accent));
}

.tl-entry-role {
  margin: 0;
  max-width: 32ch;
  font-family: var(--font-heading);
  font-size: clamp(1.25rem, 1.8vw, 1.5rem);
  font-weight: 700;
  line-height: 1.2;
  letter-spacing: -0.025em;
}

/* --- The facts, in the statement's voice ---
   A flex row with a gap, deliberately: every part is its own item, so the gap
   does the spacing and there is no punctuation sitting against a pill's padding
   to rescue with .statement-sep. */
.tl-entry-meta {
  margin: 0.875rem 0 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 0.75rem;
  font-size: 0.9375rem;
  color: var(--color-muted);
}

.tl-entry-org {
  font-weight: 600;
  color: var(--color-text);
}

/* The statement's marks are sized for a 2.25rem display line. At text size the
   2px underline reads as a rule and the pill as a button, so both come down —
   the same adjustment .hero-lead makes for the same reason. */
.tl-entry-meta .statement-when {
  border-bottom-width: 1px;
  border-bottom-color: var(--color-muted);
}

.tl-entry-meta .statement-where {
  padding: 1px 8px;
  font-size: 0.875rem;
}

.tl-entry-desc {
  margin: 0.875rem 0 0;
  max-width: 64ch;
  color: var(--color-muted);
  line-height: 1.6;
  font-size: 0.9375rem;
}

/* --- Notable classes and the capstone, inside the degree's block --- */
.tl-extra-block { margin-top: 1.75rem; }

/* Down the rail the same block sits under a short description rather than in a
   full-screen column, so it wants the description's own rhythm instead of the
   degree block's air. */
.tl-entry .tl-extra-block { margin-top: 0.875rem; }

.tl-extra-label {
  margin: 0 0 0.75rem;
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--color-muted);
}

.tl-pills {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.tl-pills li {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  line-height: 26px;
  height: 26px;
  padding: 0 12px;
  border-radius: var(--radius-pill);
  background-color: color-mix(in srgb, var(--tl-hue, var(--color-accent)) 12%, var(--color-bg));
  color: var(--color-muted);
}

.tl-capstone {
  margin: 0;
  max-width: 62ch;
  font-family: var(--font-heading);
  font-size: 1.125rem;
  font-weight: 700;
  line-height: 1.3;
  letter-spacing: -0.02em;
}

.tl-capstone-desc {
  margin: 0.5rem 0 0;
  max-width: 62ch;
  color: var(--color-muted);
  line-height: 1.6;
  font-size: 0.9375rem;
}

@media (max-width: 700px) {
  .tl-study { --tl-logo: 76px; gap: 22px; }

  .tl-rail { --tl-logo: 56px; }
  .tl-entry { gap: 18px; padding-bottom: 2.25rem; }
  .tl-entry::before { top: calc(var(--tl-logo) + 8px); bottom: 8px; }
}

@media (max-width: 480px) {
  /* A 56px logo and its gap leave under 250px for a title that runs to nine
     words. Stack instead: the logo keeps its size, the text gets the whole
     width, and the rail has no column left to run down. */
  .tl-study,
  .tl-entry {
    grid-template-columns: minmax(0, 1fr);
    gap: 14px;
  }

  .tl-study-aside,
  .tl-entry-aside { grid-column: 1; }

  .tl-entry::before { display: none; }
}

@media (max-width: 600px) {
  .rule { margin: 2.25rem 0 2rem; }
}

/* --- Selected projects: Pinterest-style masonry wall --- */
.projects-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 24px;
}

.projects-col {
  display: flex;
  flex-direction: column;
  gap: 24px;
}

.pcard {
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding: 12px 12px 20px;
  border-radius: var(--radius-card);
  background-color: var(--color-surface);
  color: var(--color-text);
  text-decoration: none;
  transition: transform .25s ease, box-shadow .25s ease;
}

.pcard:hover {
  color: var(--color-text);
  transform: translateY(-4px);
  box-shadow: 0 22px 44px -22px rgba(22, 21, 29, 0.32);
}

.pcard-cover {
  border-radius: 16px;
  overflow: hidden;
  background-color: var(--color-accent-wash);
}

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

/* Cover tints, cycled per card — straight from the artboard. */
.pcard-cover--0 { background-color: #eceaf6; }
.pcard-cover--1 { background-color: #16151d; }
.pcard-cover--2 { background-color: #fbe7e0; }
.pcard-cover--3 { background-color: #faf0cc; }
.pcard-cover--4 { background-color: #e1f3ea; }
.pcard-cover--5 { background-color: #f4f2fa; }

.pcard-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 0 8px;
}

.pcard-body h3 {
  margin: 0;
  font-size: 1.125rem;
  line-height: 1.25;
  letter-spacing: -0.01em;
}

.pcard-body p {
  margin: 0;
  font-size: 0.875rem;
  color: var(--color-muted);
  /* display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden; */
}

.pcard-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  padding-top: 4px;
}

.pcard-tags span {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  line-height: 26px;
  height: 26px;
  padding: 0 10px;
  border-radius: var(--radius-pill);
  background-color: var(--color-accent-wash);
  color: var(--color-muted);
}

/* Narrow: `display: contents` dissolves the column wrappers so the cards become
   direct children of the grid and fall back into true source order. The column
   count already matches the wide layout, so only the wrappers need undoing. */
@media (max-width: 900px) {
  .projects-col { display: contents; }
  .pcard-cover { height: 220px !important; }
}

@media (max-width: 600px) {
  .projects-grid { grid-template-columns: 1fr; }
}

.hero-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 1.75rem;
  margin-top: 0.5rem;
}

.contact-links {
  display: flex;
  gap: 1.5rem;
  list-style: none;
  padding: 0;
}

.contact-links a {
  color: var(--color-text);
}

.contact-links a:hover {
  color: var(--color-accent);
}

.timeline {
  display: flex;
  gap: 2rem;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  border-left: none;
  border-top: 2px solid var(--color-accent);
  padding-top: 2rem;
}

.timeline-item {
  flex: 0 0 18rem;
  scroll-snap-align: start;
}

.timeline-item::before {
  content: "";
  position: absolute;
  left: -7px;
  top: 0.3rem;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background-color: var(--color-accent);
  border: 2px solid var(--color-bg);
}

.timeline-item:last-child {
  padding-bottom: 0;
}

@media (min-width: 600px) {
  .timeline {
    border-left: none;
    position: relative;
  }

  .timeline::before {
    content: "";
    position: absolute;
    left: 50%;
    transform: translateX(-1px);
    width: 2px;
    height: 100%;
    background-color: var(--color-accent);
  }

  .timeline-item {
    width: 50%;
    padding: 0 2rem 2rem;
  }

  .timeline-item:nth-child(odd) {
    text-align: right;
  }

  .timeline-item:nth-child(even) {
    margin-left: 50%;
    text-align: left;
  }

  .timeline-item:nth-child(odd)::before {
    left: auto;
    right: -7px;
  }

  .timeline-item:nth-child(even)::before {
    left: -7px;
  }
}

.card-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 1.5rem;
}

.card-date {
  font-size: 0.9rem;
  color: #666;
}

/* --- Recently: pinned, with the cards sweeping sideways ---
   The one section that holds the viewport instead of using a sticky column:
   a 100dvh pinned box never scrolls internally, so the two cannot coexist. */
.pin {
  position: relative;
}

/* Sticks to the top of the viewport while .pin scrolls past behind it. */
.pin-inner {
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100dvh;          /* dvh accounts for mobile browser chrome */
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.card-grid--track {
  flex-wrap: nowrap;
  overflow-x: auto;
  scrollbar-width: none;
}

.card-grid--track::-webkit-scrollbar {
  display: none;
}

.card-grid--track .card {
  flex: 0 0 20rem;
}

/* The sweep, and the height it needs. The pinned box is 100dvh and has to hold
   the eyebrow, the statement, the rule and a whole card inside it — about 680px.
   Below that the card's bottom edge is simply cut off, and because the box is
   pinned the page will not scroll to reveal it. */
@media (min-width: 701px) and (min-height: 700px) and (prefers-reduced-motion: no-preference) {
  /* The .rule under the statement carries this spacing. */
  #recently .statement { margin-bottom: 0; }

  /* Same gap Projects' sticky column uses, so the two sections set their rule
     at the same distance under the statement. Only the top: the rule and the
     card track below it keep their own spacing and simply follow it up. */
  #recently .rule { margin-top: var(--rule-gap); }

  /* Still a scroll container, so the JS can drive scrollLeft, but not one the
     user can drag — a swipe over it would fight the pinned sweep. */
  #recently .card-grid--track {
    overflow: hidden;

    /* Cards exit at the left edge and enter at the right; fade both rather than
       slicing them. The negative margin pulls the box out into the page gutter
       by exactly the padding, so the fade sits outside the content width and the
       first card still lines up under the heading. */
    margin-inline: -2rem;
    padding-inline: 2rem;
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 2rem,
                                        #000 calc(100% - 2rem), transparent 100%);
    mask-image: linear-gradient(to right, transparent 0, #000 2rem,
                                #000 calc(100% - 2rem), transparent 100%);
  }

  /* No top padding: the pinned viewport is 100dvh and holds its own content, so
     section padding here only pushes the cards below the fold. No bottom padding
     either — by then the pin has released and the cards have gone. */
  #recently {
    padding-top: 0;
    padding-bottom: 0;
  }

  /* Content near the top of the sticky box rather than centred, so it reads the
     same before and after the pin engages. */
  #recently .pin-inner {
    justify-content: flex-start;
    padding-top: var(--heading-top);
  }
}

/* Too cramped to pin on a phone, too short to pin on an awkward window, and
   disorienting for anyone who asked for less motion. The box stops holding the
   viewport and the section flows like any other. */
@media (max-width: 700px), (max-height: 699px), (prefers-reduced-motion: reduce) {
  .pin { height: auto !important; }
  .pin-inner { position: static; height: auto; }
}

/* --- Too short to sweep ---
   On a phone the strip still makes sense: one card is most of the screen, so
   swiping is how you would read it anyway. On a wide-but-short window it does
   not — there is room for three cards side by side, and a horizontal scroller
   just hides two of them behind a gesture nobody thinks to make.

   So here the track stops being a track: the cards wrap onto as many rows as
   they need, at their ordinary flexible width, and the page scrolls down past
   them the way it always could. Nothing is cropped because nothing is being
   held in a box too small for it. */
@media (min-width: 701px) and (max-height: 699px) {
  #recently .card-grid--track {
    flex-wrap: wrap;
    overflow-x: visible;
  }

  /* Back to the shared .card sizing: grow to share the row, wrap below 280px.
     The fixed 20rem exists for the sweep, where every card must be the same
     width to travel evenly. */
  #recently .card-grid--track .card {
    flex: 1 1 280px;
  }
}

/* --- Contact: "Write to me" + future whiteboard --- */
.contact-layout {
  display: grid;
  justify-items: start;
  gap: 1.5rem;
}

/* Placeholder for the collage canvas. Sized by ratio rather than height so the
   box holds its shape at every width — and so the <canvas> that replaces it can
   derive its bitmap size from one getBoundingClientRect() without relayout. */
.board {
  width: 100%;
  aspect-ratio: 21 / 9;
  margin-top: 1rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  padding: 1.5rem;
  text-align: center;
  background-color: var(--color-accent-wash);
  border: 2px dashed var(--color-accent-soft);
  border-radius: var(--radius-card);
}

.board-icon {
  color: var(--color-accent-soft);
}

.board-hint {
  margin: 0;
  max-width: 44ch;
  font-family: var(--font-mono);
  font-size: 0.8125rem;
  line-height: 1.6;
  color: var(--color-muted);
}

/* 21:9 collapses to a letterbox slot on a phone — give it room to breathe. */
@media (max-width: 600px) {
  .board {
    aspect-ratio: 4 / 3;
  }
}
