/* Type:
   --font-sans   Söhne (a licensed Klim face). It isn't hosted here, so when
                 it isn't installed we fall to Helvetica Neue — the neo-
                 grotesque Söhne descends from and reads almost identically —
                 then to Inter as a hosted web-font backstop for other OSes.
   --font-serif  Newsreader italic, for emphasized lead-ins.
   Both web fonts load from Google Fonts via <link> tags in each page's
   head (with preconnect), so the fetch starts alongside this stylesheet
   instead of being serialized behind it. */

:root {
  /* Dark theme + greys */
  --bg: #1a1a1a;
  --fg: #f2f2f2;
  --muted: #a0a0a0;         /* gray11 — descriptions and intro body */
  --faint: #7e7e7e;         /* gray10 — section labels */
  --line: rgba(242, 242, 242, 0.1);   /* hairline: artifact frames, spec rules */
  --head-size: 0.9375rem;   /* 15px — page title and its description, one size */
  --content-width: 48rem;   /* content column width (768px) */
  --work-width: 78rem;      /* wider column for the two-up work bands (1248px) */
  --ld-corner: 40px;        /* rounded corner on the demo preview boxes — the
                               single knob: the thumb and inline embed read it
                               directly, and live-demo.js reads it to scale the
                               parked live app's corner to match */
  --font-sans: "Sohne", "Helvetica Neue", "Inter", -apple-system,
    BlinkMacSystemFont, Helvetica, Arial, sans-serif;
  --font-serif: "Newsreader", "Signifier", "Times New Roman", Times, serif;
}

html, body {
  height: 100%;
  margin: 0;
  /* stop iOS Safari from inflating text in landscape — the site sets every
     size explicitly, so the "adjustment" only breaks the scale */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  font-family: var(--font-sans);
  font-feature-settings: "cv01", "ss03";  /* only affects the Inter fallback */
  font-optical-sizing: auto;
  background: var(--bg);
  color: var(--fg);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* plain links inherit the text color so they stay visible on the dark
   background; cards set their own link colors at higher specificity */
a {
  color: inherit;
}

/* Home: a greeting, the links, then work laid out
   across three labelled columns. Left-aligned in a fixed 640px column
   centered on the page. The greeting block is anchored near the top and
   the work grid floats in the space left below it, so the columns sit
   vertically centered on tall screens. */
body.home main {
  box-sizing: border-box;
  width: var(--content-width);
  max-width: calc(100vw - 3rem);
  min-height: 100vh;
  margin: 0 auto;
  padding: 20vh 1.5rem 4rem;
  font-size: 1.0625rem;   /* 17px body */
  line-height: 1.5;
  display: flex;
  flex-direction: column;
}

/* The page opens on the greeting and goes straight into the links */
.greeting {
  margin: 0 auto;
  max-width: 42rem;
  text-align: center;
  font-size: inherit;     /* sit at body size, like the old name line */
  font-weight: 400;
  line-height: 1.5;
}

/* The intro sits between the greeting and the links: body copy in the
   muted grey so the greeting above it still reads as the first line */
.intro {
  max-width: 42rem;
  margin: 0 auto;
  text-align: center;
  color: var(--muted);
}

.intro p {
  margin: 1.1rem 0 0;
  line-height: 1.5;
}

/* The one emphasized phrase lifts to full white against the muted body
   copy. A plain span, not <em> — the browser's default italic for <em>
   would need overriding, and a stale stylesheet would show it italic */
.intro .stress {
  color: var(--fg);
}

/* Three work columns */
.work-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 2rem;
  /* auto top/bottom absorbs the leftover height, centering the columns in
     the space under the links; the 3rem floor keeps them clear of the
     greeting when the window is too short to have any slack */
  margin: auto 0;
  padding-top: 3rem;
}

/* Only the column label is centered — it is the one line that has to sit
   on the same axis as the About link above it. Everything under it goes
   back to a left edge, which is what the stacked items want. */
.col > h2 {
  text-align: center;
  /* the link inside carries 0.45em of padding so the pill outline has
     somewhere to sit; the negative top and the trimmed bottom give that
     back, so the label and the items under it land where they did before
     the padding existed */
  margin: -0.45em 0 calc(1.25rem - 0.45em);
  font-size: 0.8125rem;   /* 13px — a step below the 17px item titles */
  font-weight: 400;
  color: var(--faint);
}

/* Each label links to the top of its own page and draws the same outline
   as About me on hover, brightening as the pill closes */
.col > h2 a {
  position: relative;
  display: inline-block;
  padding: 0.45em 1.15em;
  color: inherit;
  transition: color 0.2s ease;
}

.col > h2 a:hover,
.col > h2 a:focus-visible {
  color: var(--fg);
}

.col ul.stack {
  margin: 0;
  padding: 0;
  list-style: none;
}

.col ul.stack li {
  margin-bottom: 1.15rem;
}

.col ul.stack li:last-child {
  margin-bottom: 0;
}

/* Item: a bare title with a muted description stacked beneath it. No
   underline at rest — with nothing marking these as links, the hover
   draws one under the title so pointing at one still registers as a
   target. */
.col .item-title {
  position: relative;
  display: inline-block;
  color: var(--fg);
  text-decoration: none;
}

/* The rule is a pseudo-element rather than a text-decoration so it can be
   driven by transform: it wipes out of the left edge on hover, and the
   origin flip means it retracts to the right on the way out instead of
   rewinding the same stroke backwards. */
.col .item-title::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -0.18em;
  height: 1px;
  background: currentColor;
  /* keep the rule on its own compositing layer even at rest — without
     this, the layer the transform animation creates is dropped when the
     transition ends, and the re-rasterized 1px line can land on a
     subpixel boundary and antialias to grey (visible in Safari on the
     top row, whose baseline sits on a fraction) */
  will-change: transform;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}

.col .item-title:hover::after,
.col .item-title:focus-visible::after {
  transform: scaleX(1);
  transform-origin: left;
}

.item-desc {
  display: block;
  margin-top: 0.35rem;
  font-size: 0.9375rem;   /* a step under the 17px title above it */
  color: var(--muted);
  line-height: 1.45;
}

/* About / LinkedIn / GitHub sit between the intro and the work columns.
   Keep the list narrow so the hover preview cards stay a sensible size
   within the wide layout */
/* Left-aligned, but floating in the gap: margin-top:auto takes an equal
   share of the page's leftover height to the auto margins on the work
   grid below, so the block settles midway between the intro and the
   columns instead of hugging the paragraph. The padding is the floor —
   on a short window there is no slack to share and this is all that
   keeps it off the text above. */
.elsewhere {
  margin-top: auto;
  padding-top: 1.9rem;
}

/* The block is centered in the column: the About link centered over the
   two marks, which center under it as a pair */
.elsewhere ul.links {
  max-width: 22rem;
  margin: 0 auto;
  text-align: center;
}

.elsewhere ul.links ul.social-row {
  justify-content: center;
}

/* Body size and body grey at rest. The padding is always there, so the
   outline that arrives on hover doesn't resize the row and shove the marks
   below it down a few pixels mid-gesture. */
.elsewhere .about a {
  position: relative;
  display: inline-block;
  padding: 0.5em 1.15em;
  color: var(--muted);
  transition: color 0.2s ease;
}

/* The outline is two SVG halves rather than a border, so it can be drawn
   instead of switched on: both start at the bottom centre and travel up
   the opposite sides to meet at the top. landing.js sizes the paths to
   their link, since the geometry depends on its rendered box. Shared by
   About me and the three column labels — anything that wraps one of these
   only has to be position:relative with the padding to hold the outline. */
.pill-border {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}

.pill-border path {
  fill: none;
  stroke: var(--fg);
  stroke-width: 1;
  /* dash 100, gap 200 — the gap is longer than the path on purpose. With
     an equal 100/100 pattern the *next* dash begins exactly at the far end
     of the path, and at rest it antialiases into a faint dot at the top
     where the two halves meet. An oversized gap puts that repeat out past
     the end of the path, so the stroke is the one dash and nothing else. */
  stroke-dasharray: 100 200;
  stroke-dashoffset: 100;
  transition: stroke-dashoffset 0.45s ease;
}

a:hover > .pill-border path,
a:focus-visible > .pill-border path {
  stroke-dashoffset: 0;
}

/* the label rides above the outline and brightens as it closes */
.elsewhere .about a .lbl {
  position: relative;
  z-index: 1;
  display: inline-block;
  transition: opacity 0.14s ease;
}

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

/* Click: the outline unwinds back down both sides on an accelerating
   curve — slow to let go, quick at the end — while the label drops out
   ahead of it. Navigation waits for it to finish (landing.js).

   .open is the starting frame, stamped on by landing.js and held for one
   reflow before .collapsing lands. Without it the retraction has nothing
   to travel from on any pointer that never hovers — a touch tap would set
   the offset back to 100 on a stroke that was already at 100, animating
   nothing. */
.elsewhere .about a.open .pill-border path {
  stroke-dashoffset: 0;
  transition: none;
}

.elsewhere .about a.collapsing {
  color: var(--fg);
}

.elsewhere .about a.collapsing .pill-border path {
  stroke-dashoffset: 100;
  transition: stroke-dashoffset 0.3s cubic-bezier(0.55, 0, 0.85, 0.2);
}

.elsewhere .about a.collapsing .lbl {
  opacity: 0;
  transition: opacity 0.12s ease;
}

/* Collapse the three columns to one on narrow screens */
@media (max-width: 40rem) {
  body.home main {
    width: auto;
    max-width: none;
    padding: 10vh 1.5rem 4rem;
    /* small viewport units: the phone browser chrome collapsing and
       expanding doesn't reflow the centered layout */
    min-height: 100svh;
  }
  .work-grid {
    grid-template-columns: 1fr;
    gap: 2.5rem;
  }
  /* a little more air between one column's last item and the next label */
  .col > h2 {
    margin-bottom: calc(1.4rem - 0.45em);
  }
}

body.home a {
  text-decoration: none;
}

/* Interior pages: block centered horizontally, anchored near the top */
body.page main {
  position: relative;
  max-width: 30em;
  margin: 0 auto;
  /* the back mark used to sit in the flow here and push the first line
     down; it's fixed to the viewport now, so the column carries that
     space itself and the page opens where it always did */
  padding-top: 11.25rem;
  padding-bottom: 4em;
}

main {
  max-width: 24em;
  padding: 0 1.5em;
}

/* Phones: the interior pages open closer to the top — 11.25rem of headroom
   was sized for a desktop window, and on a phone it reads as a blank
   first screen. The floating back disc sits in the top 3.5rem, so the
   column still clears it comfortably. */
@media (max-width: 40rem) {
  body.page main {
    padding-top: 7rem;
  }
}

h1 {
  margin: 0 0 2em;
  font-size: 1.25em;
  font-weight: normal;
  line-height: 1.4;
}

p {
  line-height: 1.6;
}

ul.links {
  margin: 0;
  padding: 0;
  list-style: none;
}

ul.links li {
  margin-bottom: 0.75em;
}

/* Home: LinkedIn and GitHub are icon-only marks sitting side by side
   under the About link. Each one expands into a rounded profile preview
   on hover, growing out of the icon to the right and down. */
ul.links ul.social-row {
  --icon: 1.4em;
  display: flex;
  gap: 0.6em;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* the marks are their own row, so the list's per-item gap doesn't
   reappear between them */
ul.links ul.social-row li {
  margin-bottom: 0;
}

/* The item is just the icon's footprint — the card is revealed as an
   overlay on top of the page, so nothing else shifts */
ul.links li.li-hover {
  position: relative;
  width: var(--icon);
  height: var(--icon);
}

ul.links li.li-hover:hover,
ul.links li.li-hover:focus-within {
  /* lift the hovered item so its expanding card paints over the links
     and columns below it instead of being covered by them */
  z-index: 10;
}

/* On hover the mark travels from its spot in the row onto the top-left
   of the card — over the banner photo for LinkedIn, into the header
   gutter for GitHub — while the card expands out from under it */
.li-link {
  display: block;
  height: 100%;
}

/* Positioned relative to the li so it can travel onto the card */
.li-logo {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--icon);
  height: var(--icon);
  z-index: 3;
  /* full strength: the marks carry their own white, so any dimming here
     reads as a gray logo against the dark page */
  opacity: 1;
  transition: top 0.5s ease, left 0.5s ease, filter 0.5s ease;
}

li.li-hover:hover .li-logo,
li.li-hover:focus-within .li-logo {
  top: 0.6em;
  left: 0.55em;
}

/* The LinkedIn card opens the other way — down and to the LEFT, into the
   empty page margin rather than over the work columns. Pinning the
   reveal's right edge to the mark makes the box grow leftward, and the
   mark lands inside the card's top-right corner, which sits right where
   the mark already is. landing.js only adds .opens-left when there's
   actually room to the left; otherwise the card opens right as usual. */
li.li-hover.opens-left .li-hover-reveal {
  left: auto;
  right: 0;
}

li.li-hover.opens-left:hover .li-logo,
li.li-hover.opens-left:focus-within .li-logo {
  left: -0.55em;
}

/* The reveal is an absolute overlay pinned to the icon, so growing it
   opens the card right and down over the page without moving any other
   text. It starts at the icon's own width and widens to the card's final
   width (--liw / --ghw); the grid row animates its height 0 -> auto. */
.li-hover-reveal {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--icon);
  z-index: 1;
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.6s ease, width 0.6s ease;
}

.li-hover-reveal > .li-hover-card {
  overflow: hidden;
  min-height: 0;
  opacity: 0;
  /* fills the box, which grows from the top-left — right and down */
  transition: opacity 0.5s ease 0.1s, transform 0.5s ease,
    box-shadow 0.5s ease;
}

/* Content is laid out at the card's final width (set by landing.js as
   --liw) and pinned top-left, so the growing box uncovers it rather than
   squishing it */
.li-card-inner {
  width: calc(var(--liw, 100%) - 2px);
  white-space: normal;
}

li.li-hover:hover .li-hover-reveal,
li.li-hover:focus-within .li-hover-reveal {
  grid-template-rows: 1fr;
  width: var(--liw, 22rem);
}

li.li-hover:hover .li-hover-card,
li.li-hover:focus-within .li-hover-card {
  opacity: 1;
}

.li-hover-card {
  margin-top: 0;
  /* the cards reproduce LinkedIn's and GitHub's own layouts, so they have
     to opt out of whatever the surrounding list is doing — the centered
     About block would otherwise centre the name, headline and location */
  text-align: left;
  border-radius: 0.6em;
  /* flat card: a soft drop shadow and a hairline border, no 3d tilt.
     Both cards are dark like the page, so the shadow has to be deep
     enough to separate them from it — each card sets its own border
     color from its site's dark palette */
  border: 1px solid rgba(255, 255, 255, 0.12);
  box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6);
}

.li-hover-card .li-banner {
  border-radius: 0.55em 0.55em 0 0;
}

/* Back: a bare chevron, no label and no underline, pinned to the top-left
   of the viewport rather than to the content column. The columns differ
   page to page — 30em on About, 78rem on the work pages, a centered block
   on the Work index — so a mark sitting in the flow lands somewhere
   different on each one; fixed to the viewport it's in the same place
   everywhere. On About that also puts it clear of the cliff face, which
   is drawn down the left edge of the text column.

   It's a direct child of <body>, not of <main>, so it positions against
   the page rather than the column — main is position:relative and would
   otherwise be its containing block, putting it back on a different x per
   page. Fixed rather than absolute so it stays pinned to the top-left of
   the viewport as the page scrolls, keeping the exit in reach anywhere
   down the page.

   z-index stays well below the live-demo overlay (1000) so a fullscreen
   demo still covers it. */
.back {
  position: fixed;
  top: 2.5rem;
  left: 2.5rem;
  z-index: 5;
  display: flex;
  width: max-content;
  color: var(--muted);
  text-decoration: none;
  transition: color 0.2s ease;
}

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

/* The mark swells on hover. The scale lives on the <svg> rather than the
   anchor so nothing around it reflows mid-gesture. Going in it overshoots
   a touch and settles back; coming out it just eases. */
.back-mark {
  width: 2.25rem;
  height: 2.25rem;
  transform-origin: 50% 50%;
  transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}

.back:hover .back-mark,
.back:focus-visible .back-mark {
  transform: scale(1.14);
  transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Shallower than a 45° chevron — 8 across to 6 down each way, so it reads
   as a soft arrow rather than a bracket */
.back-chevron {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The ring is drawn on, not switched on: one dash the length of the
   circle, parked out of sight at rest and closed on hover. The gap is
   longer than the circumference on purpose — same reason as .pill-border,
   an equal pattern would repeat a second dash onto the far end. The
   rotate(-90) attribute in the markup starts the draw at the top.

   The dash values are duplicated as attributes in the markup so the ring
   is hidden at rest even on a stale stylesheet — the CSS here only has to
   win the transition, which it does, presentation attributes being the
   weakest thing in the cascade.

   Leaving is the fast half: the stroke lets go quickly on an accelerating
   curve, while arriving is a decelerating one that covers most of the
   circle early and closes the last arc slowly. */
.back-ring {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.1;
  stroke-linecap: round;
  stroke-dasharray: 98 200;   /* 2πr, r = 15.5 ≈ 97.4 */
  stroke-dashoffset: 98;
  transition: stroke-dashoffset 0.32s cubic-bezier(0.55, 0, 0.85, 0.2);
}

.back:hover .back-ring,
.back:focus-visible .back-ring {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 0.62s cubic-bezier(0.22, 1, 0.36, 1);
}

/* Phones: the viewport-pinned mark floats over the text column as the page
   scrolls (there is no spare margin for it to live in), so it carries its
   own contrast the way the live-demo overlay's back control does — a dark
   disc with a hairline and a shadow, legible over anything it crosses.
   The .ld-back variant opts out: it already has this styling at a larger
   size, and reshaping it here would shrink the one exit from a demo. */
@media (max-width: 40rem) {
  .back:not(.ld-back) {
    top: 0.9rem;
    left: 0.9rem;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
    border-radius: 50%;
    color: #fff;
    background: rgba(26, 26, 26, 0.72);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.18),
                0 0.3rem 1rem rgba(0, 0, 0, 0.4);
  }
  .back:not(.ld-back) .back-mark {
    width: 1.875rem;
    height: 1.875rem;
  }
}

/* ── On-page section rail ─────────────────────────────────────────────
   A table of contents for the work pages, built by toc.js from the band
   titles already in the markup. Like .back it's pinned to the viewport
   (position:fixed) so it rides along as the page scrolls, the list running
   down the left gutter.

   Each section is a plain title in the faint grey; the one you're currently
   reading lifts to full white (toc.js sets .active from scroll position),
   as does whichever you point at. Click one to scroll to its band.

   The two-up bands run in a wide 78rem column, so the titles only have
   margin to sit in on a wide viewport; the rail is dropped below the width
   where they'd start to reach the content (the query at the foot of the
   block). z-index sits above the parked demo previews (20) as a safety, so
   a title that reaches the column edge is never hidden behind one, but
   under the open live-demo overlay (1000) so a fullscreen demo covers it. */
.toc {
  position: fixed;
  left: 1rem;
  top: 50%;
  transform: translateY(-50%);
  z-index: 100;
}
.toc ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

.toc-link {
  display: block;
  max-width: 10rem;
  padding: 0.55rem 0;   /* the air between entries */
  overflow: hidden;
  font-size: 0.8125rem;
  line-height: 1.35;
  color: var(--faint);
  text-decoration: none;
  transition: color 0.2s ease,
              padding-left 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}

/* Pointing at an entry lifts its title toward white and scoots it aside,
   opening the space the dot will slide into if you click. The active entry
   holds that same offset, since its dot is already parked there. */
.toc-link:hover,
.toc-link:focus-visible,
.toc-item.active .toc-link {
  color: var(--fg);
  padding-left: 0.9rem;
}

/* A single white dot marks the active section. It's the only one on the
   rail, so when you jump to another entry it slides from the old section to
   the new one rather than blinking across. toc.js sets its vertical offset;
   this transform transition does the travelling. */
.toc-dot {
  position: absolute;
  left: 0;
  top: 0;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--fg);
  pointer-events: none;
  transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
}

/* Shown only where the margin can hold the titles clear of the 78rem
   column; below that the rail is dropped. */
@media (max-width: 94rem) { .toc { display: none; } }

/* Small lead-in text at the top of a page */
.kicker {
  margin: 0 0 2.5em;
  font-size: 1em;
  min-height: 1.6em;
}

/* Typing caret for the kicker */
.caret {
  display: inline-block;
  width: 1.5px;
  height: 1.1em;
  margin-left: 2px;
  vertical-align: -0.18em;
  background: currentColor;
  animation: caret-blink 1s steps(1) infinite;
}

.caret.done {
  animation: none;
  opacity: 0;
  transition: opacity 0.4s;
}

@keyframes caret-blink {
  50% { opacity: 0; }
}

/* Timeline: a vertical rule with a node marking each point in time.
   Each entry reveals itself as it scrolls into view. */
ul.timeline {
  margin: 0;
  padding: 0;
  list-style: none;
}

ul.timeline li {
  position: relative;
  padding-left: 1.75em;
  padding-bottom: 2.5em;
}

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

/* The node itself, centered on the rule. In stickman mode the JS sets
   --dotx per entry to the x of the cliff's inner line at that height,
   so each dot sits on the rock; without it the dot centers on the rule */
ul.timeline li::before {
  content: "";
  position: absolute;
  left: calc(var(--dotx, 0em) - 0.21em);
  top: 0.4em;
  width: 0.42em;
  height: 0.42em;
  background: #f2f2f2;
  border-radius: 50%;
}

/* The stretch of rule between this node and the next */
ul.timeline li::after {
  content: "";
  position: absolute;
  left: -1px;
  top: 0.4em;
  width: 2px;
  height: 100%;
  background: #f2f2f2;
  transform-origin: top;
}

/* Anchor bolts (stickman mode): every second dot is a ring on the face
   line the rope hangs threaded through. The ring is drawn in the rig
   svg by JS so it can show before its entry reveals; the CSS dot for
   those entries is dropped to avoid doubling up */
html.man ul.timeline li.anchor::before {
  display: none;
}

/* With JS, entries start hidden and reveal on scroll (.shown) */
html.js ul.timeline li {
  opacity: 0;
}

html.js ul.timeline li::after {
  transform: scaleY(0);
}

ul.timeline li.shown {
  animation: tl-fade 0.4s ease forwards;
}

ul.timeline li.shown::after {
  animation: tl-draw 0.6s ease forwards;
  animation-delay: 0.3s;
}

@keyframes tl-fade {
  to { opacity: 1; }
}

@keyframes tl-draw {
  to { transform: scaleY(1); }
}

/* The rope falls off the end of the timeline and runs straight down
   into the ground at the bottom of the page */
.rope {
  display: block;
  margin-left: -1px;
  overflow: visible;
}

/* Let the rope reach the very bottom edge of the page */
body.page main:has(.rope) {
  padding-bottom: 0;
}

/* Stickman mode (html.man): the rope he rides in on IS the timeline rule,
   so the per-entry rule segments are hidden and a single rope line grows
   down the page behind him. */
html.man ul.timeline li::after {
  display: none;
}

/* The rope hangs from the ceiling through the header area,
   so keep the intro text clear of it */
html.man .kicker {
  margin-left: 1.75em;
}

.stickman {
  display: none;
  position: absolute;
  overflow: visible;
  pointer-events: none;
}

html.man .stickman.on {
  display: block;
}

/* The rope rig: full-length rope and anchor rings, drawn behind the
   climber and on the wall from page load */
.rig {
  display: none;
  position: absolute;
  overflow: visible;
  pointer-events: none;
}

html.man .rig {
  display: block;
}

/* A cleaned ring eases into a bare bolt instead of flipping */
.rig circle {
  transition: fill 0.3s ease, r 0.3s ease;
}

/* The wall he rappels down: the timeline rule itself, hatched on its
   far side like a cross-section */
/* The cliff face: jagged edge and rock texture are drawn into the svg
   by JS, since the profile depends on the page height */
.wall {
  position: absolute;
  top: 0;
  display: none;
  pointer-events: none;
}

html.man .wall {
  display: block;
}

/* Room in the margin for the rope beside the wall */
html.man body.page main:has(.rope) {
  padding-left: 3.5em;
}

/* The floor: the campground meadow drawn across the full viewport width
   at the base of the descent */
.floor {
  position: absolute;
  overflow: visible;
  pointer-events: none;
}

/* Stickman mode draws the rope live all the way into the turf, so the
   static tail only reserves the layout space — its stroke is hidden */
html.man .rope {
  visibility: hidden;
}

.date {
  display: block;
  margin-bottom: 0.25em;
  font-style: italic;
}

.entry-title {
  margin: 0 0 0.35em;
  font-size: 1em;
  font-weight: bold;
}

ul.timeline p {
  margin: 0;
}

/* ── Work pages: alternating artifact / prose bands ───────────────────
   Each project is a two-column band, and the columns swap sides down the
   page: artifact left on odd bands, right on even ones.

   Every band reads the same way: a title, a short summary, then the
   technical specifications under a rule. The summary is what a skimmer
   takes; the spec sheet is the reference layer for anyone who wants the
   build details.

   The artifact column carries the evidence — a live preview of the thing
   running (see live-demo.css). A project with no interface to show — a
   pipeline, an agent, a stub — drops the column entirely and runs as a
   .solo band, because the spec sheet already states the stack and a
   plate repeating it was the same facts twice.

   Adding a project means copying an <li>; alternation is positional, so
   nothing downstream needs renumbering. */

body.page.work main {
  max-width: var(--work-width);
  padding-top: 9.5rem;   /* as above: absorbs the space the back mark held */
}

.page-head {
  margin-bottom: 5rem;
}

/* The page label repeats the landing page's column heading verbatim —
   you clicked "Personal" over there, you're under "Personal" here. */
.page-title {
  margin: 0;
  font-size: var(--head-size);
  font-weight: 400;
  line-height: 1.5;
  color: var(--muted);
}

/* Title over description, centered on the page. Scoped to .page-line so
   the pages that are still a bare left-aligned label are left alone. */
.page-line {
  text-align: center;
}

.page-sub {
  margin: 0.45rem 0 0;
  font-size: var(--head-size);
  line-height: 1.5;
  color: var(--faint);
}

ul.bands {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The artifact takes the larger share: a live demo parked at half the band
   was too small to read anything in. Prose still clears ~440px, which keeps
   the summary and the spec sheet at a sane measure. */
ul.bands > li {
  display: grid;
  grid-template-columns: minmax(0, 1.75fr) minmax(0, 1fr);
  gap: 4.5rem;
  align-items: center;
  margin-bottom: 5.5rem;
}

ul.bands > li:last-child {
  margin-bottom: 0;
}

/* Deep-linked from the landing grid: leave air above so the band doesn't
   land jammed against the top of the viewport */
ul.bands > li[id] {
  scroll-margin-top: 3rem;
}

/* Placed explicitly rather than by source order, so the columns can swap
   without the markup moving — the prose always follows the title for a
   screen reader, whichever side it's painted on. */
.band-artifact { grid-row: 1; grid-column: 1; }
.band-prose    { grid-row: 1; grid-column: 2; }
ul.bands > li:nth-child(even) .band-artifact { grid-column: 2; }
ul.bands > li:nth-child(even) .band-prose    { grid-column: 1; }

/* Sides swap on even bands, so the wide track has to swap with them —
   otherwise every other artifact lands in the narrow column. */
ul.bands > li:nth-child(even) {
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.75fr);
}

/* A project with no interface to show: one column at a reading measure, wide
   enough that the spec sheet below the summary still gets a label column and a
   value column. The :nth-child(even) duplicate isn't redundant — the even rule
   above out-specifies a bare .solo, so a solo band sitting in an even slot
   would otherwise get handed the two-column track. */
ul.bands > li.solo,
ul.bands > li.solo:nth-child(even) {
  /* minmax(0, …) like the two-up tracks: a bare 1fr means minmax(auto, 1fr),
     and a fixed-width embed inside would push the track past the column */
  grid-template-columns: minmax(0, 1fr);
}

ul.bands > li.solo .band-prose,
ul.bands > li.solo:nth-child(even) .band-prose {
  grid-column: 1;
  max-width: 42rem;
}

.band-title {
  margin: 0 0 0.8rem;
  font-size: 1.375rem;
  font-weight: 400;
  line-height: 1.3;
}

/* Which part of a multi-band project this is. Set down a size and into the
   faint colour so three consecutive bands read as one project seen three
   ways, not as three projects that happen to share a name. */
.band-part {
  font-size: 0.9375rem;
  color: var(--faint);
}

.band-part::before {
  content: "\00b7";
  margin: 0 0.5em;
}

/* When the project was built, riding just off the end of the title. Small and
   faint so it reads as a footnote to the name, not part of it; nowrap keeps the
   month and year together when a long title pushes it onto its own line. */
.band-date {
  margin-left: 0.7em;
  font-size: 0.8125rem;
  color: var(--faint);
  font-style: normal;
  white-space: nowrap;
}

/* On the multi-part Unpak titles a second, slightly smaller dot separates the
   part label from the date, echoing the one between name and part. */
.band-part + .band-date::before {
  content: "\00b7";
  margin-right: 0.7em;
  font-size: 0.85em;
}

/* An accolade riding after the date — same size and position in the title
   line, but the text itself is a slowly drifting rainbow gradient (clipped to
   the glyphs) so it glints without shouting. A leading dot separates it from
   the date the way the date is separated from a part label. */
.band-award {
  margin-left: 0.7em;
  font-size: 0.75rem;
  font-weight: 700;
  white-space: nowrap;
}

.band-award::before {
  content: "\00b7";
  margin-right: 0.7em;
  font-size: 0.85em;
  color: var(--faint);
  font-weight: 400;
}

/* Each letter carries its own copy of the gradient and its own hop, both
   staggered by --i so the colour and the jump travel down the word as one
   wave. font-style resets the <i> wrappers, which are only there because
   background-clip and transform need a per-letter box. */
.band-award i {
  display: inline-block;
  font-style: normal;
  background: linear-gradient(
    90deg,
    #d97757, #c9a24f, #5aa87a, #4f9cc9, #8b6fc9, #c1699e, #d97757
  );
  background-size: 900% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation:
    award-drift 14s linear calc(var(--i) * -0.08s) infinite,
    award-jump 2.4s ease-in-out calc(var(--i) * 0.08s) infinite;
}

@keyframes award-drift {
  to { background-position: -900% 0; }
}

@keyframes award-jump {
  0%, 12%, 100% { transform: translateY(0); }
  6% { transform: translateY(-0.22em); }
}

@media (prefers-reduced-motion: reduce) {
  .band-award i { animation: none; }
}

/* A caveat under a demo: what the visitor is looking at isn't real data */
.band-note {
  margin-top: 0.9rem;
  font-size: 0.875rem;
  color: var(--faint);
}

.band-prose p {
  margin: 0;
  color: var(--muted);
  line-height: 1.55;
}

.band-prose p + p {
  margin-top: 0.9rem;
}

/* Title row: the project name on the left, its repo link pinned to the right
   edge of the prose column, both sharing the title's baseline. */
.band-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
}

/* A quiet way to the source, riding alongside the title. Muted at rest like
   the label it sits under; on hover it brightens and the arrow nudges
   up-and-right, the same gesture an external link makes. */
.repo-link {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  flex: none;
  font-size: 0.8125rem;
  letter-spacing: 0.01em;
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
  transition: color 0.2s ease;
}

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

.repo-link svg {
  width: 0.85em;
  height: 0.85em;
  transition: transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.repo-link:hover svg,
.repo-link:focus-visible svg {
  transform: translate(1px, -1px);
}

/* Live preview sits flush in the band, framed by a hairline */
.band-artifact .ld-thumb {
  margin: 0;
  border: 1px solid var(--line);
  border-radius: var(--ld-corner);
}

/* An always-live embed: same hairline frame as a thumb, but the demo runs
   in place — no expansion step, the iframe itself is the interface. */
.band-artifact .ld-inline {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 10;
  border: 1px solid var(--line);
  border-radius: var(--ld-corner);
  background: #FCFCFB;
}

/* With JS, live-demo.js wraps the inline embed in .ld-inline-box and lays the
   iframe out at a desktop width, scaled down to fit — so a narrow column gets
   a shrunken desktop view instead of a squeezed one. The frame chrome moves
   up to the box: on the iframe it would scale down with the content. */
.band-artifact .ld-inline-box {
  /* clip, not hidden: a hidden-overflow box is still a scroll container,
     and anything that scrolls it (scrollIntoView, a focus jump) would
     shove the scaled canvas off its top-left anchor */
  overflow: clip;
  /* anchors the absolutely-positioned iframe below */
  position: relative;
  aspect-ratio: 16 / 10;
  border: 1px solid var(--line);
  border-radius: var(--ld-corner);
  background: #FCFCFB;
}

.band-artifact .ld-inline-box .ld-inline {
  /* Fill the box outright — without an explicit height the iframe falls to
     its 150px default and the demo's own vertical centering happens inside
     that sliver at the top of the box. live-demo.js overrides the size
     with a desktop layout (scaled down) when the box is narrow.
     Absolute, so the oversized layout height never counts as content:
     in flow it would stretch the box past its 16/10 aspect, leaving a
     band of bare background under the scaled canvas. */
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  aspect-ratio: auto;
  border: none;
  border-radius: 0;
}

/* The system map's page shares the site background, so a frame would draw a
   box around what should read as part of the page itself — and for the same
   reason it stays square while the other previews round off. */
#unpak-system .ld-thumb {
  border: none;
  border-radius: 0;
}

/* Technical specifications: the reference layer under a band's summary.
   Set a step down in size and colour from the prose so a skimmer reads the
   summary and stops, and anyone who wants the build details reads on. */
.band-spec {
  margin-top: 1.6rem;
  padding-top: 1.4rem;
  border-top: 1px solid var(--line);
}

.band-spec h3 {
  margin: 0 0 1.25rem;
  font-size: 0.8125rem;
  font-weight: 400;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--faint);
}

/* Label column fixed rather than auto-sized, so the values line up on one
   edge instead of stepping in and out with each label's length. The row
   wrappers are display:contents — they exist to pair a dt with its dd for
   the single-column collapse below, not to lay anything out. */
.band-spec dl {
  display: grid;
  grid-template-columns: 6rem minmax(0, 1fr);
  gap: 0.85rem 1.4rem;
  margin: 0;
  font-size: 0.875rem;
  line-height: 1.6;
}

.band-spec dl > div {
  display: contents;
}

.band-spec dt {
  color: var(--faint);
}

.band-spec dd {
  margin: 0;
  color: var(--muted);
}

/* Narrow screens: bands stack, prose above artifact every time — the
   title and summary introduce the demo before it appears. The threshold
   tracks the band width — once the two-up can't give the prose ~440px it
   stops being two-up. */
@media (max-width: 64rem) {
  body.page.work main {
    padding-top: 4em;
  }
  .page-head {
    margin-bottom: 3rem;
  }
  /* :nth-child(even) is listed too — the wide layout's even-band rule
     carries that same specificity, so a bare `ul.bands > li` here would
     lose to it and leave every second band on the two-column track,
     squeezing its stacked children into the narrow 1fr share. */
  ul.bands > li,
  ul.bands > li:nth-child(even),
  ul.bands > li.solo {
    /* minmax(0, …), not 1fr: same guard as the wide tracks, so the
       desktop-width demo canvases can't push the band past the column */
    grid-template-columns: minmax(0, 1fr);
    gap: 1.5rem;
    margin-bottom: 4rem;
  }
  .band-prose,
  ul.bands > li:nth-child(even) .band-prose {
    grid-row: 1;
    grid-column: 1;
  }
  .band-artifact,
  ul.bands > li:nth-child(even) .band-artifact {
    grid-row: 2;
    grid-column: 1;
  }
  /* Too narrow for a label column — stack each label over its value. The row
     wrappers stop being display:contents here and become the blocks that
     carry the spacing, since a dt and its dd are never DOM siblings. */
  .band-spec dl {
    display: block;
  }
  .band-spec dl > div {
    display: block;
    margin-bottom: 0.9rem;
  }
  .band-spec dl > div:last-child {
    margin-bottom: 0;
  }
  .band-spec dt {
    margin-bottom: 0.2rem;
  }
}

/* Phones proper. The 40px preview corner was drawn for a 700px-wide
   artifact column; on a 360px thumb it eats the picture, so the one knob
   steps down and the parked live app follows it (live-demo.js reads the
   computed value). Title rows are allowed to wrap — "Agricultural
   Analytics Platform" plus a date and a repo link is more than one phone
   line holds. */
@media (max-width: 40rem) {
  :root {
    --ld-corner: 22px;
  }
  .band-head {
    flex-wrap: wrap;
    row-gap: 0.15rem;
  }
  .band-title {
    font-size: 1.25rem;
  }
}

/* LinkedIn profile header, replicated 1:1 (font stack, type scale,
   colors, and layout matching linkedin.com) — in LinkedIn's own dark
   theme, since the site around it is dark: #1d2226 surfaces, #38434f
   hairlines, and the lighter #70b5f9 for the blue accents */
.li-card {
  font-family: -apple-system, system-ui, "Segoe UI", Roboto,
    "Helvetica Neue", Helvetica, Arial, sans-serif;
  background: #1d2226;
  color: rgba(255, 255, 255, 0.9);
  border-color: #38434f;
  -webkit-font-smoothing: antialiased;
}

.li-banner {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 1; /* LinkedIn banners are 1584x396 */
  object-fit: cover;
}

.li-avatar {
  display: block;
  width: 72px;
  height: 72px;
  margin: -38px 0 0 16px;
  object-fit: cover;
  /* the ring is the card surface punched through the banner */
  background: #1d2226;
  border: 2px solid #1d2226;
  border-radius: 50%;
}

.li-info {
  padding: 4px 16px 16px;
}

.li-name {
  margin: 8px 0 0;
  font-size: 22px;
  font-weight: 600;
  line-height: 1.25;
}

.li-headline {
  margin: 0;
  font-size: 15px;
  font-weight: 400;
  line-height: 1.4;
}

.li-meta {
  margin: 4px 0 0;
  font-size: 13px;
  line-height: 1.4;
  color: rgba(255, 255, 255, 0.6);
}

.li-connections {
  margin: 4px 0 0;
  font-size: 13px;
  font-weight: 600;
  color: #70b5f9;
}

/* GitHub card: 53 weeks of boxes need more room than the text column,
   so on hover this card grows past the list width — as wide as the year
   wants, capped by the viewport. One shared value (--ghw) sizes both
   the growing box and the content laid out inside it, so the card can
   never clip its own right edge. */
ul.links li.li-hover.gh {
  /* compact: only as wide as the calendar needs, never past the right
     viewport edge (the card's left edge sits at the octocat, one icon
     plus the row gap right of the text column) */
  --ghw: min(29em, 100vw - 6em, 50vw + 9.5em);
}

ul.links li.li-hover.gh:hover .li-hover-reveal,
ul.links li.li-hover.gh:focus-within .li-hover-reveal {
  width: var(--ghw);
}

.gh-card .li-card-inner {
  width: calc(var(--ghw) - 2px);
}

/* GitHub profile card, replicated like the LinkedIn one (GitHub's font
   stack, type scale, and colors), with a live contribution calendar */
.gh-card {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
    "Noto Sans", Helvetica, Arial, sans-serif;
  /* GitHub's own dark theme, to match the page: #0d1117 canvas, #30363d
     hairlines, #e6edf3 ink — the green calendar still carries the color,
     on the darker ramp landing.js draws */
  background: #0d1117;
  color: #e6edf3;
  border-color: #30363d;
  -webkit-font-smoothing: antialiased;
}

.gh-inner {
  /* padding counts inside the width, so the inner layout is exactly as
     wide as the card box and nothing clips at the right edge */
  box-sizing: border-box;
  padding: 10px 12px 12px;
}

.gh-head {
  display: flex;
  align-items: center;
  gap: 9px;
  margin-bottom: 8px;
  /* clear the octocat, which lands in the card's top-left on hover */
  padding-left: 28px;
}

/* The octocat is near-black (#1f2328), so it's forced to pure white —
   brightness(0) crushes it to black first, because inverting #1f2328 on
   its own only reaches an off-white #e0dcd7. It keeps that white on the
   dark card too, parked vertically centered on the avatar */
.li-logo[src*="gh-logo"] {
  filter: brightness(0) invert(1);
}

li.li-hover:hover .li-logo[src*="gh-logo"],
li.li-hover:focus-within .li-logo[src*="gh-logo"] {
  top: 0.9em;
  left: 0.45em;
}

.gh-avatar {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.15);
}

.gh-name {
  margin: 0;
  font-size: 14px;
  font-weight: 600;
  line-height: 1.25;
}

.gh-login {
  margin: 0;
  font-size: 12px;
  color: #8b949e;
}

.gh-graph {
  position: relative; /* anchors the day tooltip */
  background: #0d1117;
  border: 1px solid #30363d;
  border-radius: 6px;
  padding: 6px;
}

/* The hovered day lights up its outline immediately */
.gh-graph rect:hover {
  stroke: rgba(255, 255, 255, 0.5);
}

/* Instant per-day tooltip (a step up from the canvas so the bubble reads
   against the dark card) */
.gh-tip {
  position: absolute;
  transform: translate(-50%, -100%);
  background: #30363d;
  color: #e6edf3;
  font-size: 12px;
  line-height: 1;
  padding: 6px 8px;
  border-radius: 6px;
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  z-index: 2;
}

.gh-tip::after {
  content: "";
  position: absolute;
  /* landing.js sets --ax so the arrow tracks the hovered cell even
     when the bubble itself is clamped at the graph's edge */
  left: var(--ax, 50%);
  bottom: -4px;
  transform: translateX(-50%);
  border: 4px solid transparent;
  border-top-color: #30363d;
  border-bottom: none;
}

.gh-tip.on {
  opacity: 1;
}

.gh-graph svg {
  display: block;
  width: 100%;
  height: auto;
}

.gh-month {
  font-size: 9px;
  fill: #8b949e;
}

.gh-graph-fallback {
  font-size: 13px;
  color: #8b949e;
  text-align: center;
  padding: 1.5em 8px;
}

/* The headline stat rides the header row, pushed to the right edge */
.gh-total {
  margin: 0 0 0 auto;
  font-size: 13px;
  color: #8b949e;
}

/* Page transitions, driven by transition.js. Forward navigation slides the
   page left as it fades; the new page arrives from the right. Going back
   mirrors it. The html background stops the white flash between documents. */
html {
  background: #1a1a1a;
}

html.page-exit body,
html.page-exit-back body {
  animation: page-fade-out 180ms ease-in both;
}

html.page-enter body,
html.page-enter-back body {
  animation: page-fade-in 320ms ease-out both;
}

/* On arrival each top-level block rises into place, one after another. The
   work bands list is skipped as a slab so its items can stagger on their
   own instead. About's cliff scenery (.wall, .floor, .rig, rope, stickman)
   is pinned to measured positions in the margin, so rising it would tear
   it away from the timeline it's anchored to — it just fades in with the
   page instead. */
html.page-enter main > :not(.bands, .wall, .floor, .rig, .rope, .stickman),
html.page-enter-back main > :not(.bands, .wall, .floor, .rig, .rope, .stickman),
html.page-enter .bands > li,
html.page-enter-back .bands > li {
  animation: page-rise 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}

html.page-enter main > :nth-child(2),
html.page-enter-back main > :nth-child(2),
html.page-enter .bands > li:nth-child(1),
html.page-enter-back .bands > li:nth-child(1) { animation-delay: 70ms; }
html.page-enter main > :nth-child(3),
html.page-enter-back main > :nth-child(3),
html.page-enter .bands > li:nth-child(2),
html.page-enter-back .bands > li:nth-child(2) { animation-delay: 140ms; }
html.page-enter main > :nth-child(4),
html.page-enter-back main > :nth-child(4),
html.page-enter .bands > li:nth-child(3),
html.page-enter-back .bands > li:nth-child(3) { animation-delay: 210ms; }
html.page-enter main > :nth-child(5),
html.page-enter-back main > :nth-child(5),
html.page-enter .bands > li:nth-child(4),
html.page-enter-back .bands > li:nth-child(4) { animation-delay: 280ms; }
html.page-enter main > :nth-child(n+6),
html.page-enter-back main > :nth-child(n+6),
html.page-enter .bands > li:nth-child(n+5),
html.page-enter-back .bands > li:nth-child(n+5) { animation-delay: 350ms; }

/* The pinned scenery still joins the entrance, but with a pure fade —
   no translate — so it never leaves its measured spot. Delayed to land
   with the tail of the stagger instead of popping in first. */
html.page-enter main > :is(.wall, .floor, .rig, .rope, .stickman),
html.page-enter-back main > :is(.wall, .floor, .rig, .rope, .stickman) {
  animation: page-fade-in 520ms ease-out both;
  animation-delay: 210ms;
}

@keyframes page-fade-out {
  to { opacity: 0; }
}

@keyframes page-fade-in {
  from { opacity: 0; }
}

@keyframes page-rise {
  from {
    opacity: 0;
    transform: translateY(18px);
  }
}

@media (prefers-reduced-motion: reduce) {
  html.page-exit body,
  html.page-exit-back body,
  html.page-enter body,
  html.page-enter-back body,
  html.page-enter main > *,
  html.page-enter-back main > *,
  html.page-enter .bands > li,
  html.page-enter-back .bands > li {
    animation: none;
  }
}
