/* David Lammering — portfolio
   One column, a system sans, generous space, two themes. No gradients, no
   shadows, no scroll animation. Separation comes from space and size contrast,
   helped by hairline rules between the blocks of the page — never boxes.

   Nothing is fetched from outside this folder: the type is whatever the
   platform already ships, and colour lives in the tokens below.

   Colour appears in exactly one place on the page — the timeline strip — and is
   kept muted there on purpose. Everything else is paper and ink. */

/* ---------- tokens ---------- */

:root {
  color-scheme: light dark;

  --bg:      #fbfcfd;   /* paper */
  --surface: #ffffff;   /* plates: placeholders, the lightbox panel */
  --ink:     #11161d;
  --muted:   #5b6676;
  --rule:    rgba(17, 22, 29, .08);   /* hairlines that only need to be felt */
  --line:    rgba(17, 22, 29, .13);   /* hairlines that have to be seen */
  --link:    #1f5fd0;

  /* Timeline. Blue for what has happened, a warm amber for what has not, so
     the open internship is the one thing on the strip that catches the eye.
     --c-nest sits *inside* --c-done, so those two have to separate from each
     other as well as from the page. */
  --c-done:    #457fba;
  --c-nest:    #102c51;
  --c-plan:    #937539;
  --c-plan-bg: #f2ead9;

  --measure: 36rem;     /* text column — line length, not page width */
  --wide:    68rem;     /* page — wide enough for the project media */

  --r-sm: 8px;
  --r:    12px;
  --r-lg: 16px;

  /* SF Pro on Apple, Segoe UI Variable on Windows, Roboto on Android. Zero
     bytes, and every rung is a face drawn for screens in this decade. */
  --sans: system-ui, -apple-system, "Segoe UI Variable Display", "Segoe UI",
          Roboto, "Helvetica Neue", Arial, sans-serif;
}

/* Both themes are declared twice: once behind prefers-color-scheme for the
   visitor's own setting, once behind [data-theme] so a theme can be forced —
   which is what makes dark mode screenshottable, and leaves room for a toggle
   later without touching anything below this block. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg:      #0d1117;
    --surface: #161b22;
    --ink:     #e6e9ef;
    --muted:   #93a0b4;
    --rule:    rgba(255, 255, 255, .09);
    --line:    rgba(255, 255, 255, .15);
    --link:    #7fb2ff;

    /* The nesting logic is unchanged — the inner spell is still the darker of
       the two blues — but both are re-mixed for a dark ground. */
    --c-done:    #4a86c9;
    --c-nest:    #0f2744;
    --c-plan:    #c79a4e;
    --c-plan-bg: #2a2214;
  }
}

:root[data-theme="dark"] {
  --bg:      #0d1117;
  --surface: #161b22;
  --ink:     #e6e9ef;
  --muted:   #93a0b4;
  --rule:    rgba(255, 255, 255, .09);
  --line:    rgba(255, 255, 255, .15);
  --link:    #7fb2ff;

  --c-done:    #4a86c9;
  --c-nest:    #0f2744;
  --c-plan:    #c79a4e;
  --c-plan-bg: #2a2214;
}

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

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 16.5px;
  line-height: 1.6;
  font-kerning: normal;
  font-optical-sizing: auto;
  -webkit-font-smoothing: antialiased;
  /* Deliberately NOT old-style figures. The prose is full of STM32, ROS2, A*,
     C++ and 3D, and text figures make a part number look like a typo. */
}

.wrap { max-width: var(--wide); margin: 0 auto; padding: 0 20px; }
@media (min-width: 620px) { .wrap { padding: 0 32px; } }

/* ---------- text ---------- */

/* Tracking is optical: the bigger the type, the tighter it wants to be. */
h1, h2, h3 { line-height: 1.18; text-wrap: balance; }

h1 { font-size: clamp(2.1rem, 1.5rem + 2.4vw, 3.25rem);
     font-weight: 600; letter-spacing: -.03em; margin: 0 0 .5rem; }
h2 { font-size: 1.35rem; font-weight: 600; letter-spacing: -.02em;
     margin: 0 0 1.1rem; }
h3 { font-size: 1.1rem; font-weight: 600; letter-spacing: -.01em;
     margin: 2.4rem 0 .5rem; }
@media (min-width: 620px) {
  h2 { font-size: 1.5rem; }
  h3 { font-size: 1.15rem; }
}

/* Small tracked capitals — the register for anything that labels rather than
   says. Used for the .facts keys and for section eyebrows. Capitals need air,
   so the tracking runs positive. */
.eyebrow {
  font-size: .72rem; font-weight: 500; text-transform: uppercase;
  letter-spacing: .1em; color: var(--muted); margin: 0 0 .5rem;
}

/* A section label is still an h2 for structure, so it needs more air beneath
   it than an inline eyebrow does. */
h2.eyebrow { margin-bottom: 1.35rem; }

p, ul, ol, dl { max-width: var(--measure); }
p { margin: 0 0 1.05rem; text-wrap: pretty; }

ul, ol { margin: 0 0 1.05rem; padding-left: 1.25rem; }
li { margin-bottom: .4rem; }

a { color: var(--link); text-underline-offset: 3px;
    text-decoration-thickness: 1px; }
/* No purple. A visited link in body prose just sits back a little. */
main a:visited, footer a:visited { opacity: .8; }
a:hover { text-decoration-thickness: 2px; }

strong, .key { font-weight: 600; }

small, .small { font-size: .875rem; color: var(--muted); }

::selection { background: color-mix(in srgb, var(--link) 22%, transparent); }

:focus-visible { outline: 2px solid var(--link); outline-offset: 3px;
                 border-radius: 2px; }

.skip { position: absolute; left: -9999px; }
.skip:focus { left: 12px; top: 12px; z-index: 20; background: var(--surface);
              padding: .5rem .9rem; border-radius: var(--r-sm);
              border: 1px solid var(--line); }

/* ---------- top bar ---------- */

.topbar {
  position: sticky; top: 0; z-index: 10;
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  -webkit-backdrop-filter: saturate(180%) blur(10px);
  backdrop-filter: saturate(180%) blur(10px);
  border-bottom: 1px solid var(--rule);
}
.topbar .wrap {
  display: flex; align-items: center; gap: 1rem;
  min-height: 3.25rem; font-size: .875rem;
}
.topbar .brand { font-weight: 600; color: var(--ink); text-decoration: none;
                 white-space: nowrap; letter-spacing: -.01em; }
/* A quiet label after the name, so the bar says what the page is without
   competing with it. Same register as the eyebrows, one step down in weight. */
.topbar .brand-note { color: var(--muted); font-size: .8rem; letter-spacing: 0;
                      white-space: nowrap; }
.topbar nav { margin-left: auto; display: flex; gap: 1.25rem; }
.topbar a { color: var(--muted); text-decoration: none; white-space: nowrap;
            transition: color .15s ease; }
.topbar a:visited { opacity: 1; }
.topbar a:hover, .topbar .brand:hover { color: var(--ink); }

@media (max-width: 479px) {
  .topbar .brand, .topbar .brand-note { display: none; }
  .topbar nav { margin-left: 0; margin-right: auto; gap: 1.1rem; }
}
@media print { .topbar { display: none; } }

/* ---------- intro ---------- */

.top { padding-top: 3.5rem; padding-bottom: 1rem; }
@media (min-width: 620px) { .top { padding-top: 5.5rem; } }

.intro { display: flex; gap: 1.5rem; align-items: center; }
.intro-text { flex: 1 1 auto; min-width: 0; }

/* A rectangle, not a squircle: right-angled corners with only the 2px needed
   to keep the edge from looking chipped at this size. A portrait crop (4:5)
   because that is how the photograph is framed. */
.portrait {
  order: 2; flex: none;
  width: 96px; aspect-ratio: 4 / 5; object-fit: cover; object-position: 50% 18%;
  border-radius: 2px;
}
/* On a dark ground a photograph needs an edge or it floats. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .portrait { box-shadow: inset 0 0 0 1px var(--line); }
}
:root[data-theme="dark"] .portrait { box-shadow: inset 0 0 0 1px var(--line); }

@media (min-width: 620px) { .portrait { width: 132px; } .intro { gap: 2.5rem; } }

.lede { font-size: 1.05rem; line-height: 1.5; margin-bottom: .75rem;
        color: var(--muted); text-wrap: pretty; max-width: 32rem; }
.meta { color: var(--muted); font-size: .9rem; margin-bottom: 0; }

@media (min-width: 620px) { .lede { font-size: 1.25rem; } }

/* ---------- sections ---------- */

section { margin-top: 4.5rem; scroll-margin-top: 4.5rem; }
section:first-of-type { margin-top: 2rem; }

/* Gentle separators between the blocks of the home page — intro, projects,
   path, contact. Space still does most of the work: the hairline is the
   quietest token on the page (--rule, meant to be felt rather than seen) and
   it sits centred in the gap, so it reads as organisation, not as a frame.
   Scoped to main's own children, so the sections inside a project article —
   which are chapters of one piece of writing — keep their open spacing. */
main > section {
  border-top: 1px solid var(--rule);
  margin-top: 3rem;
  padding-top: 3rem;
  /* the section box now starts at the rule, so the heading lands clear of the
     sticky bar with much less offset than a bare section needs */
  scroll-margin-top: 1.25rem;
}
main > section:first-of-type { margin-top: 1.75rem; }

/* ---------- projects ---------- */
/* Still no frames around the card itself: the media sits straight on the paper
   with its label underneath. What marks a card as a thing to click is kept
   small on purpose — a hairline on the inside edge of the media slot, an arrow
   after the title, and both of those firming up on hover. */

/* Two columns, not auto-fit. There are four projects: auto-fit finds room for
   three at this width and leaves the fourth alone on its own row, which reads
   as a gap rather than a grid. Two columns gives 2x2 and large media. */
.pgrid { display: grid; gap: 2.5rem 1.75rem; grid-template-columns: 1fr 1fr; }
@media (max-width: 519px) { .pgrid { grid-template-columns: 1fr; gap: 2.25rem; } }

.pbox {
  display: block;
  color: inherit;
  text-decoration: none;
}
.pbox:visited { color: inherit; opacity: 1; }

/* The slot carries a hairline on its own inside edge rather than a border, so
   the ring cannot change the slot's size or push the media around. It is the
   faint token at rest — just enough to say "tile" — and the visible one on
   hover. An inset shadow also rounds with the slot, which a border on the
   media itself would not. */
.pbox .media {
  display: block; aspect-ratio: 3 / 2; overflow: hidden;
  border-radius: var(--r);
  box-shadow: inset 0 0 0 1px var(--rule);
  transition: box-shadow .2s ease;
}
.pbox .media img, .pbox .media video {
  display: block; width: 100%; height: 100%; object-fit: cover;
  transition: transform .25s ease;
}
/* A cut-out is letterboxed into the slot rather than cropped, so the machine
   never loses a corner. There is nothing to see in the letterbox — the file is
   transparent — so the slot reads as exactly the object. */
.pbox .media.bleed img, .pbox .media.bleed video { object-fit: contain; }

.pbox:hover .media img, .pbox:hover .media video { transform: scale(1.015); }
.pbox:hover .media, .pbox:focus-visible .media {
  box-shadow: inset 0 0 0 1px var(--line);
}
.pbox:hover h3 { text-decoration: underline; text-underline-offset: 3px; }

.pbox h3 {
  font-size: 1.05rem; line-height: 1.3; margin: .85rem 0 .3rem;
  color: var(--ink); letter-spacing: -.011em;
}

/* The one cue that does not need a pointer: an arrow that is there at rest, so
   a card reads as somewhere to go on a phone too, where nothing ever hovers.
   inline-block does two jobs — transform does nothing to an inline box, and an
   atomic inline stays out of the underline the title picks up on hover. */
.pbox h3::after {
  content: "\2192";
  display: inline-block;
  margin-left: .35em;
  font-weight: 400;
  color: var(--muted);
  transition: transform .2s ease, color .2s ease;
}
.pbox:hover h3::after, .pbox:focus-visible h3::after {
  transform: translateX(3px);
  color: var(--link);
}
.pbox p {
  font-size: .925rem; line-height: 1.5; margin: 0 0 .25rem; max-width: none;
  color: var(--muted);
}
.pbox .where { font-size: .82rem; color: var(--muted); margin: 0; opacity: .85; }

/* ---------- my path: one slim strip ---------- */
/* September 2023 to September 2028, 60 months. Every segment carries --a
   (where it starts) and --b (how long it lasts) as a percentage of that span,
   so a segment is as long as the thing it stands for. */

.tl { max-width: var(--measure); padding-top: .5rem; }

.tl-bar { position: relative; height: 14px; }

.seg {
  position: absolute; top: 0; bottom: 0;
  left: var(--a); width: var(--b);
  border-radius: 3px;
}
.seg.done { background: var(--c-done); }
/* a spell inside a longer one — the exchange semester inside the degree */
.seg.nest { top: 3px; bottom: 3px; background: var(--c-nest); border-radius: 2px;
            z-index: 1; }
/* not happened yet */
.seg.plan { border: 1px dashed var(--c-plan); background: var(--c-plan-bg); }
/* runs off the end of the axis, so it fades out instead of stopping */
.seg.open {
  border-right: 0; border-radius: 3px 0 0 3px;
  -webkit-mask-image: linear-gradient(90deg, #000 60%, transparent);
  mask-image: linear-gradient(90deg, #000 60%, transparent);
}

.tl-now {
  position: absolute; left: var(--a); top: -5px; bottom: -5px;
  width: 0; border-left: 1px solid var(--ink); z-index: 2;
}
.tl-now i {
  position: absolute; bottom: 100%; right: 1px;
  padding: 0 .25rem .1rem;
  font-style: normal; font-size: .65rem; font-weight: 500; letter-spacing: .09em;
  text-transform: uppercase; color: var(--muted);
}

.tl-years { position: relative; height: 1.1rem; margin-top: .4rem; }
.tl-years span {
  position: absolute; left: var(--a); top: 0;
  padding-left: .3rem;
  border-left: 1px solid var(--rule);
  font-size: .72rem; color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.tl-key {
  list-style: none; margin: 1.1rem 0 0; padding: 0;
  font-size: .9rem; line-height: 1.5;
  display: grid; gap: .3rem 1.5rem;
}
@media (min-width: 620px) { .tl-key { grid-template-columns: 1fr 1fr; } }
.tl-key li { margin: 0; }
.tl-key i {
  display: inline-block; width: .7rem; height: .7rem;
  margin-right: .5rem; vertical-align: -1px; border-radius: 3px;
}
.tl-key .done { background: var(--c-done); }
.tl-key .nest { background: var(--c-nest); }
.tl-key .plan { border: 1px dashed var(--c-plan); background: var(--c-plan-bg); }
.tl-key span { color: var(--muted); }

.tl-note { font-size: .9rem; color: var(--muted); margin: 1.4rem 0 0; }
.tl-note strong { color: var(--ink); }

/* ---------- skills ---------- */

.skills h3 { margin: 1.6rem 0 .25rem; font-size: 1.05rem; }
.skills h3:first-of-type { margin-top: 0; }
.skills p { margin-bottom: 0; }

/* ---------- project pages ---------- */

/* Each fact is its own small plate, and they tile — so the block is two or
   three across instead of one tall column, on the phone as much as here. A
   value too long to read in half a width takes the whole row. */
.facts {
  display: grid; gap: .5rem;
  grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
  margin: 1.75rem 0 2.5rem; max-width: var(--measure);
}
.facts .fact {
  background: var(--surface); border: 1px solid var(--rule);
  border-radius: var(--r-sm); padding: .6rem .75rem;
}
.facts .fact.wide { grid-column: 1 / -1; }
.facts dt {
  font-size: .66rem; font-weight: 500; text-transform: uppercase;
  letter-spacing: .09em; color: var(--muted); margin-bottom: .2rem;
}
.facts dd { margin: 0; font-size: .925rem; line-height: 1.45; }

@media print {
  .facts .fact { background: none; border: 0; padding: 0; }
}

article section { margin-top: 4.5rem; }
article h1 { margin-top: 2.5rem; }

/* A project page can hold two spells of work that are not the same piece of
   work — a thesis and, under it, the semester project it grew out of. Those
   get a chapter break: far more air than a section, and the page's only
   horizontal rule, because here the break really is structural. */
article section.part { margin-top: 6rem; padding-top: 3rem;
                       border-top: 1px solid var(--line); }

/* The chapter's own heading sits a tier above the h2s inside the chapter. */
.part-title { font-size: 1.75rem; margin-bottom: 1.1rem; letter-spacing: -.024em; }
@media (min-width: 620px) {
  .part-title { font-size: 2.1rem; letter-spacing: -.028em; }
}

/* ---------- figures ---------- */

img, video { max-width: 100%; height: auto; }

/* A figure runs wider than the prose it sits in, but not the full width of
   the page: at 68rem a costmap screenshot stops being a figure and starts
   being a backdrop. 52rem is about half again the text measure, which is the
   most a figure can take and still read as belonging to the paragraph above
   it. --wide is for the project grid, not for figures. */
figure { margin: 2.25rem 0; max-width: 52rem; }
figure img, figure video {
  display: block; width: 100%; height: auto; border-radius: var(--r);
}

/* Media with no edges of its own, sitting straight on the page. Two kinds:
   .bleed    a cut-out with real transparency (the CAD renders). On a dark
             ground these read better than they ever did on a light one — bare
             aluminium against near-black is what the renders were made for;
   .on-paper a clip whose backdrop was matted into the file itself, because
             MP4 carries no alpha channel. See "Cut-out renders" in the README. */
figure .bleed, figure .on-paper { border-radius: 0; }
.on-paper { background: transparent; }

/* Plots and line diagrams that were authored on white.

   In light mode, multiplying them into the page turns a white plate into a
   native figure: white is the identity colour for multiply, so the plate drops
   out and the ink and the author's own colours come through unchanged.

   In dark mode multiply would black the whole figure out, so the plate is
   inverted away instead. The hue-rotate is what makes this safe on the ROS
   figures: inversion alone takes the costmap's pink, cyan and red somewhere
   else entirely, and rotating the wheel back by half a turn returns them.
   Flat figures only — never a photo or a render. */
figure img.diagram {
  mix-blend-mode: multiply;
  border-radius: 0;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) figure img.diagram {
    mix-blend-mode: normal;
    filter: invert(1) hue-rotate(180deg);
  }
}
:root[data-theme="dark"] figure img.diagram {
  mix-blend-mode: normal;
  filter: invert(1) hue-rotate(180deg);
}

figcaption { font-size: .875rem; color: var(--muted); margin-top: .7rem;
             line-height: 1.55; text-wrap: pretty; max-width: var(--measure); }

.narrow { max-width: 28rem; }

/* Two frames that only mean anything side by side — the before/after pair on
   the navigation page. One figure, one caption, two images. Stacked on a
   phone, where half the column is too narrow to read a map in. */
.duo { display: grid; gap: .75rem; }
@media (min-width: 560px) { .duo { grid-template-columns: 1fr 1fr; align-items: start; } }

/* placeholder for a figure that does not exist yet */
.ph {
  display: flex; align-items: center; justify-content: center; text-align: center;
  aspect-ratio: 16 / 9; padding: 1.5rem;
  border: 1px dashed var(--line); border-radius: var(--r);
  background: var(--surface);
  color: var(--muted); font-size: .9rem;
}

/* ---------- enlarge dialog ---------- */

.zoom { cursor: zoom-in; }
dialog.lightbox { border: 0; padding: 0; background: transparent;
                  max-width: 94vw; max-height: 94vh; }
/* The backdrop is dark in both themes. A silver cut-out and a white plot both
   read better against it than against paper — in dark mode it is simply more
   of what the page already is. */
dialog.lightbox::backdrop { background: rgba(9, 13, 18, .94); }
dialog.lightbox img { display: block; max-width: 94vw; max-height: 86vh;
                      width: auto; height: auto; border-radius: var(--r-sm); }
dialog.lightbox p { color: #e6ebf4; font-size: .875rem; margin: .9rem auto 0;
                    max-width: 40rem; text-align: center; }

/* ---------- prev / next, footer ---------- */

.prevnext { border-top: 1px solid var(--rule); margin-top: 5rem; padding-top: 1.5rem;
            font-size: .95rem; }
.prevnext p { margin-bottom: .35rem; }

footer { border-top: 1px solid var(--rule); margin-top: 5rem;
         padding-top: 1.5rem; padding-bottom: 3rem;
         color: var(--muted); font-size: .875rem; }
footer p { margin: 0; max-width: none; }

/* Printing is always on white paper, so the dark theme must not follow a
   visitor there: a dark-mode reader printing a project page would otherwise
   get white text on a flooded dark ground. The tokens are reset to the light
   set, and the diagram treatment goes back to multiply with them. */
@media print {
  :root, :root[data-theme="dark"] {
    --bg:      #ffffff;
    --surface: #ffffff;
    --ink:     #11161d;
    --muted:   #5b6676;
    --rule:    rgba(17, 22, 29, .18);
    --line:    rgba(17, 22, 29, .28);
    --link:    #1f5fd0;
    --c-done:    #457fba;
    --c-nest:    #102c51;
    --c-plan:    #937539;
    --c-plan-bg: #f2ead9;
  }
  :root[data-theme="dark"] figure img.diagram,
  figure img.diagram { mix-blend-mode: multiply; filter: none; }
  :root[data-theme="dark"] .portrait { box-shadow: none; }
  body { background: #fff; }
}

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