/* Font stacks. Space Grotesk sets the headings and, via .font-mono / .mono,
   the labels and lead-ins too; IBM Plex Sans sets the body copy. Inter stays
   available via .font-inter, IBM Plex Mono via --font-plex-mono -- nothing
   uses it now that .mono points at the grotesk, but it is the stack to reach
   for if real monospace (code, tabular figures) ever lands on the page. */
:root {
  --font-space-grotesk: "Space Grotesk Variable", "Space Grotesk", system-ui,
    sans-serif;
  --font-plex-sans: "IBM Plex Sans Variable", "IBM Plex Sans", system-ui,
    sans-serif;
  --font-inter: "Inter Variable", "Inter", system-ui, sans-serif;
  --font-plex-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo,
    Consolas, monospace;

  /* x-heights as a share of the em, read straight from the font files with
     fontTools. These are what font-size-adjust targets below. */
  --x-height-plex: 0.516; /* IBM Plex Sans and IBM Plex Mono, identical */
  --x-height-grotesk: 0.486; /* Space Grotesk */
  /* Unused while .mono is the grotesk. It was the target for a Plex Mono run
     set inside a Space Grotesk heading: between the two, for the reason given
     at .mono-optical below. */
  --x-height-mixed: 0.5;
}

:root,
[data-bs-theme="dark"] {
  --bs-font-sans-serif: var(--font-plex-sans);
  --bs-body-font-family: var(--bs-font-sans-serif);

  /* FastBootstrap ships --bs-dark as #091e42, a near-navy that reads as
     blue rather than black wherever it is used (buttons, navbar links,
     rules, selection). Back to Bootstrap's neutral charcoal. The -rgb
     companion has to move with it: the background and border utilities
     build their colors from rgba(var(--bs-dark-rgb), ...). */
  --bs-dark: #212529;
  --bs-dark-rgb: 33, 37, 41;
}

/* .btn-dark hardcodes the navy instead of reading --bs-dark, so the token
   override above does not reach it. Bootstrap's own neutral ramp. */
.btn-dark {
  --bs-btn-bg: #212529;
  --bs-btn-border-color: #212529;
  --bs-btn-hover-bg: #424649;
  --bs-btn-hover-border-color: #373b3e;
  --bs-btn-focus-shadow-rgb: 66, 70, 73;
  --bs-btn-active-bg: #4d5154;
  --bs-btn-active-border-color: #373b3e;
  --bs-btn-disabled-bg: #212529;
  --bs-btn-disabled-border-color: #212529;
}

/* Projects list: square bullet, centered on the h3 heading line.
   The square is drawn on the h3 itself, so its em-based size and
   offset resolve against the heading's font size and scale with it. */
.project-list {
  list-style: none;
  padding-left: 0;
}

.project-list > li {
  position: relative;
  --bullet-size: 0.55em;
  --bullet-gap: 0.4em;
}

.project-list > li > h3::before {
  content: "";
  position: absolute;
  /* Hangs left of the text, so all copy stays flush with the column edge */
  left: calc(-1 * (var(--bullet-size) + var(--bullet-gap)));
  top: 0.6em; /* half of the heading's 1.2 line-height */
  transform: translateY(-50%);
  width: var(--bullet-size);
  height: var(--bullet-size);
  background: currentColor;
  opacity: 0;
  transition: opacity 0.15s ease-in-out;
}

.project-list > li > h3:hover::before {
  opacity: 1;
}

/* Navbar links: match the brand's size, lowercase. Deliberately two classes
   deep and setting no padding: the md-and-up block further down owns the
   link's vertical padding, which aligns its baseline with the brand's and
   sets the navbar's distance to the dashed rule. The link also stays a
   block, so the whole row is a pointer target in the collapsed navbar; the
   rule below is drawn on the inner .nav-label instead, which shrink-wraps
   the word. */
.navbar-nav .nav-link {
  font-size: var(--bs-navbar-brand-font-size);
  text-transform: lowercase;
}

/* Shrink-wraps the word, so the rule spans the text rather than the link's
   box, and carries the rule's containing block. Its bottom edge is the
   link's content edge, so --rule-bottom measures up from the foot of the
   line box; the md-and-up block re-tunes it for the navbar, where the
   line-height is tighter and the link carries 0.75rem of padding below.

   The breadcrumb's link joins the navbar's here rather than underlining:
   same lowercase mono, same job, so it should answer the pointer the same
   way. It sets its own offset below. */
.navbar-nav .nav-label,
.back-link-label {
  display: inline-block;
  position: relative;
  --rule-bottom: 0.1em;
}

/* The breadcrumb's offset, measured to match the navbar's md-and-up rule
   rather than inherit the default above -- on a wide screen the two sit on
   the same page, a few lines apart, so an eye can compare them directly.

   The offset is measured from the foot of the line box, which is not a
   fixed distance from the word: it moves with the line-height. Plex Mono's
   ascent and descent are 1.025em and 0.275em, a content area of 1.300em, so
   the baseline sits (line-height - 1.300) / 2 + 0.275 above that foot. The
   navbar at md and up has line-height 1.25, putting its baseline 0.250em up
   and its rule (at -0.3em) 0.550em below the word. The breadcrumb takes the
   body's 1.4286, so its baseline is 0.339em up, and the same 0.550em gap
   wants 0.339 - 0.550 = -0.211em.

   In em, so the gap stays proportional: the crumbs are set at 1rem against
   the navbar's 1.25rem, and a smaller word takes a proportionally closer
   rule. Inheriting the 0.1em default instead put it at 0.239em -- less than
   half the navbar's gap, which is what gave the two different voices. */
.back-link-label {
  --rule-bottom: -0.211em;
}

/* Rule under the word, growing left to right on hover and focus. Drawn as a
   border rather than a background because forced-colors mode (Windows High
   Contrast) overrides background-color, which would erase it -- border
   colors are forced to CanvasText instead, so the rule survives. */
.navbar-nav .nav-label::after,
.back-link-label::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: var(--rule-bottom);
  height: 0;
  border-bottom: 1.5px solid currentColor;
  transform: scaleX(0);
  transform-origin: left center;
  transition: transform 0.18s ease-out;
}

/* Gated on a pointer that can really hover, so a tap on a touch screen does
   not leave the rule drawn until the next tap elsewhere. */
@media (hover: hover) {
  .navbar-nav .nav-link:hover .nav-label::after,
  .back-link:hover .back-link-label::after {
    transform: scaleX(1);
  }
}

/* Focus draws the same rule, so the affordance survives keyboard
   navigation. Plain :focus first, then :focus-visible narrows it to
   keyboard focus: a browser without :focus-visible support -- Safari before
   15.4 -- cannot parse the second selector and drops that rule whole,
   leaving the :focus rule to carry the indicator there. */
.navbar-nav .nav-link:focus .nav-label::after,
.back-link:focus .back-link-label::after {
  transform: scaleX(1);
}

.navbar-nav .nav-link:focus:not(:focus-visible) .nav-label::after,
.back-link:focus:not(:focus-visible) .back-link-label::after {
  transform: scaleX(0);
}

/* The current page keeps its rule drawn at rest. Matching the attribute's
   presence alone would also mark an aria-current="false" item. */
.navbar-nav .nav-link[aria-current]:not([aria-current="false"]) .nav-label::after {
  transform: scaleX(1);
}

/* State change, not animation, when motion is unwelcome. */
@media (prefers-reduced-motion: reduce) {
  .navbar-nav .nav-label::after,
  .back-link-label::after {
    transition: none;
  }
}

/* Focus ring. Bootstrap drops the outline and marks focus with a 25%-alpha
   blue shadow, about 1.4:1 against the page -- under the 3:1 WCAG 2.4.11
   asks of a focus indicator, and absent altogether in forced-colors, where
   box-shadows are discarded but outlines are kept and recolored. A 2px
   outline held off the element by 2px has the page background on both of
   its edges, so it keeps that contrast over light and dark controls alike.
   Listed per selector rather than as a bare :focus-visible, which
   Bootstrap's own .nav-link:focus-visible { outline: 0 } would outrank.
   Keyboard focus only: nothing about the page at rest changes. */
:focus-visible,
.btn:focus-visible,
.navbar-brand:focus-visible,
.navbar .navbar-nav .nav-link:focus-visible {
  outline: 2px solid var(--bs-dark);
  outline-offset: 2px;
}

/* Bootstrap's blue halo went with its outline: the dark ring above carries
   the indicator, and the halo was the part that fell short of 3:1. The
   buttons keep theirs, which .btn-dark above already retunes to grey. */
.navbar .navbar-nav .nav-link:focus-visible {
  box-shadow: none;
}

/* Navbar links in dark body text rather than the muted default */
.navbar {
  --bs-navbar-color: var(--bs-dark);
  --bs-navbar-hover-color: var(--bs-dark);
  --bs-navbar-active-color: var(--bs-dark);
}

/* The hover color needs its own override: .navbar-nav declares
   --bs-nav-link-hover-color twice, first from --bs-navbar-hover-color and
   then from --ds-link, and the second declaration wins -- so retuning
   --bs-navbar-hover-color above leaves the link turning FastBootstrap's
   blue. Two classes to outrank that. The rule under the word is the whole
   hover signal; the text holds its color. It covers :focus as well, which
   Bootstrap colors from the same token. */
.navbar .navbar-nav {
  --bs-nav-link-hover-color: var(--bs-dark);
}


/* Headings in Space Grotesk. Drawn from Space Mono, so it carries the
   monospace skeleton into a proportional face -- the technical voice sits
   where the site's personality is, while body copy stays in Plex Sans,
   which is easier to read at length. Class selectors beat these element
   ones, so a heading marked .mono still comes out in the mono. */
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6,
.display-1, .display-2, .display-3,
.display-4, .display-5, .display-6 {
  font-family: var(--font-space-grotesk);
}

.font-grotesk {
  font-family: var(--font-space-grotesk);
}

.font-sans {
  font-family: var(--font-plex-sans);
}

/* Two faces set on one line look mismatched whenever their x-heights differ,
   because the eye measures the x-height, not the em box: Plex Mono at 0.516
   reads about 6% larger than Space Grotesk at 0.486 set to the same
   font-size. font-size-adjust fixes that without a magic ratio -- declare
   the x-height the text should have and the browser reads each font's real
   metrics and scales to hit it. So it holds at any font-size, survives a
   change of font, and catches the fallback faces too, which keeps the
   reflow small when a webfont arrives late.

   The property inherits, so declaring it per context is enough: a mono run
   nested in a heading picks the heading's target up on its own and needs no
   class. Each rule names its own context's face, so the primary face is
   scaled by 1 and only the odd one out moves. */
body {
  font-size-adjust: var(--x-height-plex);
}

h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6,
.display-1, .display-2, .display-3,
.display-4, .display-5, .display-6 {
  font-size-adjust: var(--x-height-grotesk);
}

/* Kept as a no-op now that .mono is the grotesk: a mono run and its
   surrounding heading are one and the same face, so there is no cap-height
   error left to split. The original reasoning, for the day a second face
   returns:

   Matching x-heights necessarily mismatches cap-heights: the two faces have
   near-identical caps (0.700 and 0.698), so scaling the mono down to match
   the grotesk's shorter x-height leaves its capitals 6% short. That trade is
   usually right, because most text is lowercase -- but the runs this class
   marks are capital-initial lead-ins like "Work / ", set directly beside a
   capital in the heading text, so the caps are the pair the eye actually
   compares and the mono reads small. Targeting a value between the two
   x-heights splits the error: x-heights still read as equal, and the caps
   come within about 1.5% instead of 6%. Checked at mobile and desktop
   against 0.486 and against no correction at all. */
.mono-optical {
  font-size-adjust: var(--x-height-grotesk);
}

/* Fallback for browsers without font-size-adjust (pre-127 Chrome, pre-16.4
   Safari). It used to hardcode the ratio 0.5 / 0.516 = 0.97; with one face
   there is nothing to correct, so it inherits. Guarded so supporting
   browsers never apply both. */
@supports not (font-size-adjust: 0.5) {
  .mono-optical {
    font-size: inherit;
  }
}

.font-inter {
  font-family: var(--font-inter);
}

.font-mono,
.mono {
  font-family: var(--font-space-grotesk);
}


/* Hyphenation for running copy. The measure here is often narrow -- a card
   in a third of a row, any paragraph on a phone -- and the vocabulary is
   long: "user-centered", "methodological", "Datenbeschaffung". Without
   breaks those words drop to the next line whole and leave a hole in the
   rag. hyphens: auto hands the break points to the browser's own hyphenation
   dictionary, chosen by the element's language: the pages declare lang="en"
   and the German titles carry lang="de", so each run is broken by its own
   rules. A run whose language is not declared is left alone by the browser
   rather than guessed at, which is the argument for marking it.

   Running copy only. Headings stay unbroken -- at display sizes a hyphen
   sits in the middle of the word and reads as part of it -- and so do the
   mono labels, tag lines and the footer's link lists, which are set to be
   scanned, not read.

   hyphenate-limit-chars keeps the breaks worth taking: at least 6 letters
   in the word, 3 before the hyphen and 2 after, so nothing breaks into
   two-letter scraps. Browsers without the property (Firefox before 126)
   apply their own minimums, looser but never worse than no hyphenation. */
p,
li,
dd,
blockquote,
figcaption,
.card-text {
  hyphens: auto;
  hyphenate-limit-chars: 6 3 2;
}

/* Opt-outs. hyphens inherits, so setting it on a container is enough to
   cover the labels inside it. `manual` rather than `none`: it still honours
   a soft hyphen typed into the markup, which is the escape hatch for a word
   the dictionary gets wrong. */
.mono,
.font-mono,
.tag-list,
.eyebrow,
.navbar,
footer {
  hyphens: manual;
}

/* Overrides for the markup, either direction */
.hyphens-auto {
  hyphens: auto;
}

.hyphens-manual {
  hyphens: manual;
}


/* Brand and links share a font-size but not a box: the brand has no vertical
   padding, nav links have 0.375rem. Flex centring then aligns the boxes, not
   the baselines, leaving a sub-pixel drift. Give both an identical line box
   so the baselines land on the same pixel. Horizontal padding is kept, and
   the rule is md-and-up only: when the nav collapses the links stack, and
   they need their vertical padding back as tap targets. */
@media (min-width: 768px) {
  /* Align on the text baseline rather than the box centre. Flexbox defaults
     to align-items: center, which lines up the boxes -- and the brand's box
     (no vertical padding) is ~12px shorter than a nav link's, so the text
     inside them never quite matches. Baseline alignment makes the browser
     match the letters themselves, whatever the padding or font-size. */
  .navbar > .container {
    align-items: baseline;
  }

  .navbar-collapse {
    align-items: baseline;
  }

  /* Equal padding on both keeps the baselines aligned. The bottom value
     also sets the distance to the dashed rule: the rule is clipped to the
     container's content box, so it is these boxes that push it down. */
  .navbar-brand,
  .navbar-nav .nav-link {
    padding-top: 0;
    padding-bottom: 0.75rem;
    line-height: 1.25;
  }

  /* Horizontal padding only, so the vertical values above keep their job.
     Three classes to outrank .navbar-expand-md .navbar-nav .nav-link, which
     sets 0.25rem here; this is the air between items on one row, and it is
     wanted only while they are on one row -- stacked in the collapsed
     navbar, the same padding would indent them away from the brand. */
  .navbar .navbar-nav .nav-link {
    padding-inline: 0.5rem;
  }

  /* The nav sits at the right end of the row, so the last link's half of
     that air would stand between the word and the container's edge and
     break the alignment the brand keeps on the left. The air belongs
     between the items, not outside them. */
  .navbar .navbar-nav .nav-item:last-child .nav-link {
    padding-inline-end: 0;
  }

  /* Negative, so the rule sits below the foot of the line box: the link's
     0.75rem of padding below the text is inside the link but outside the
     label, and the rule belongs at the same distance from the word as it
     keeps in the collapsed navbar. */
  .navbar-nav .nav-label {
    --rule-bottom: -0.3em;
  }
}

/* Sticky nav. body is a flex column and clips its overflow-x, but `clip`
   makes no scroll container, so the nav still sticks to the viewport rather
   than to the body box. An opaque background is what keeps the content from
   showing through as it passes underneath -- the nav's own pb-3 and the
   container's py-3 are part of the painted box, so the dashed rule keeps its
   distance from the text on both sides while scrolling.

   scroll-padding-top stops the in-page anchors (#about, #contact) from
   landing underneath it: the browser leaves that much room above the target.
   The value is the nav's own height, roughly brand line + the two paddings,
   with a little air. */
html {
  scroll-padding-top: 6rem;

  /* Reserve the scrollbar's track on every page, scrolling or not. Without
     it the viewport is narrower wherever the document overflows, and since
     .container centers its content, the short pages (imprint, thanks) paint
     their columns half a scrollbar further right than the long ones -- the
     whole layout, navbar and footer included, shifts sideways on navigation.
     `stable` keeps the gutter there the whole time, so the centering axis
     stays put. Only the classic overlay-less scrollbars (Windows, Linux) are
     affected; where the scrollbar overlays the content (macOS, mobile) the
     declaration is a no-op. */
  scrollbar-gutter: stable;
}

.navbar {
  position: sticky;
  top: 0;
  z-index: 1020;
  background-color: var(--bs-body-bg);
}

/* Nav over a light hero band (Cup Rings). The nav starts tinted so it reads
   as part of the hero's bg-light block, then fades to the body background
   once that block has scrolled past underneath it. The tint lives here
   rather than on a .bg-light in the markup because that utility is
   !important and so cannot be transitioned away from. A small JS watcher
   toggles .is-past-hero; without it the nav simply stays tinted, which is
   the current behaviour, so the fade is a progressive enhancement.

   180ms is deliberately short: scrolling back up, the nav fades to the tint
   while already sitting over the light band, and a longer fade makes that
   lag visible as a pale rectangle. */
.navbar-fade-light {
  background-color: var(--bs-light);
  transition: background-color 180ms ease;
}

.navbar-fade-light.is-past-hero {
  background-color: var(--bs-body-bg);
}

@media (prefers-reduced-motion: reduce) {
  .navbar-fade-light {
    transition: none;
  }
}

/* Below md the nav collapses to brand + toggler. Neither has bottom padding
   there -- the md-and-up rule that gives them 0.75rem is what holds the rule
   clear of the text -- so the toggler's outlined box sits flush on the
   container's content edge and the rule runs into it. Margin on both keeps
   the flex line taller than its contents by the same 0.75rem, so the rule
   clears the outline and the brand keeps its distance too. */
@media (max-width: 767.98px) {
  .navbar-brand,
  .navbar-toggler {
    margin-bottom: 0.75rem;
  }
}

/* Underscore rule: long dashes with hairline gaps, the look of "_________".
   border-style: dashed can't do this -- the browser fixes the dash length at
   roughly 2-3x the border width and offers no way to change it.

   One tile = one dash + one gap, repeated with `round`, which scales the tile
   so a whole number fits the width. That is what stops the final dash being
   clipped mid-stroke: the leftover is shared out across the gaps instead,
   shifting each by a fraction of a pixel. Dash length stays put.

   content-box origin/clip keeps the rule inside the element's padding, so on
   a .container it lines up with the gutter automatically. */
.rule-underscore {
  --rule-dash: 18px;
  --rule-gap: 1px;
  --rule-thickness: 1px;
  --rule-color: var(--bs-dark);

  background-image: linear-gradient(
    to right,
    var(--rule-color) 0,
    var(--rule-color) var(--rule-dash),
    transparent var(--rule-dash)
  );
  background-repeat: round no-repeat;
  background-size: calc(var(--rule-dash) + var(--rule-gap))
    var(--rule-thickness);
  background-position: left bottom;
  background-origin: content-box;
  background-clip: content-box;
}

/* Dark rule: the solid counterpart to .rule-underscore. Same background
   technique rather than border-bottom, so it inherits the content-box
   origin/clip and lines up with the gutter on a .container or .col the
   same way the dashed version does. */
.rule-dark {
  --rule-thickness: 1px;
  --rule-color: var(--bs-dark);

  background-image: linear-gradient(var(--rule-color), var(--rule-color));
  background-repeat: no-repeat;
  background-size: 100% var(--rule-thickness);
  background-position: left bottom;
  background-origin: content-box;
  background-clip: content-box;
}

/* Text selection: dark instead of the browser's default blue */
::selection {
  background-color: var(--bs-dark);
  color: #fff;
}

/* Sticky column inside a .row.
   position: sticky needs two things Bootstrap's .position-sticky does not
   give it: an offset to stick at, and room to move. The row is a flex
   container, so its items stretch to full height by default and a sticky
   item has nowhere to travel -- align-self: flex-start shrinks it to its
   content. Only applies from lg up, where the columns are side by side;
   below that they stack and sticking would just cover the next block. */
@media (min-width: 992px) {
  .sticky-col {
    position: sticky;
    top: 2rem;
    align-self: flex-start;
  }
}

/* Grey block standing in for artwork not yet made. aspect-ratio keeps it
   proportional at every width instead of a fixed pixel height. */
.placeholder-rect {
  width: 100%;
  aspect-ratio: var(--placeholder-ratio, 5 / 4);
  /* Without this the automatic minimum size lets content stretch the box
     taller than the ratio, so a block with a heading in it stops being
     square. min-height: 0 makes aspect-ratio authoritative. */
  min-height: 0;
  background-color: var(--bs-gray-200, #e9ecef);

  /* Content sits at the foot of the block. justify-content works down the
     column axis, so flex-end pushes children to the bottom edge. */
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding-bottom: 2rem;
}

/* Full-bleed section: breaks out of the .container to span the viewport.
   The negative inline margin is the difference between half the parent's
   width and half the viewport, which pulls each edge out to the screen.
   An inner .container keeps the content on the same grid as every other
   section -- drop it on a child that should run edge to edge. */
.full-bleed {
  width: auto;
  margin-inline: calc(50% - 50vw);
}

/* 100vw includes the scrollbar, so the break-out overhangs by its width and
   would add a horizontal scrollbar. clip trims it without creating a scroll
   container, which hidden would do -- that breaks position: sticky. */
body {
  overflow-x: clip;
}

/* Container gutter, matched to the one the rows actually use.
   A .row cancels its columns' padding with a negative margin of half the
   gutter, and relies on the container's padding -- also half the gutter --
   to absorb it. The two only cancel while both read the same value, and the
   layouts here set gx-8/g-8 (2rem, so 1rem each side) on the rows while the
   container kept the 1.5rem default and offered 0.75rem. Every such row hung
   4px past the container on both edges. Above sm the container's max-width
   left enough slack to hide it; below it the container is fluid, so the
   overhang reached the viewport and the document came out 4px wider than
   the screen -- silently, since the clip above swallowed the scrollbar.
   Column content was never affected, but anything painted on the column box
   was, the focus ring below included.

   Setting it here rather than per row keeps every container on one value,
   so the navbar, the grid and the footer stay aligned to the same edge. */
.container,
.container-fluid,
.container-sm,
.container-md,
.container-lg,
.container-xl,
.container-xxl {
  --bs-gutter-x: 2rem;
}

/* Linked article titles: the whole article is the hit area (via Bootstrap's
   .stretched-link), so the title itself shouldn't shout "link" in blue. It
   reads as a heading, and underlines on hover or keyboard focus to signal
   that the block is clickable. */
article .stretched-link {
  color: inherit;
  text-decoration: none;
}

article:hover .stretched-link,
article .stretched-link:focus-visible {
  text-decoration: underline;
}

/* The stretched link's hit area is the whole article, but the browser draws
   the focus ring around the inline heading text only -- so keyboard users
   see a ring that doesn't match what they'd be activating. Move the ring to
   the article and suppress the inner one. :focus-within is what catches it,
   since the focused element is the <a> inside.

   The offset is negative, so the ring is drawn inside the column's padding
   box rather than outside it. A positive offset puts it in territory the
   column does not own: on a phone the column is full-width and its padding
   box reaches the viewport edge, so an outward ring lands off-screen and
   body's overflow-x: clip cuts both of its sides away -- the keyboard user
   is left with the top and bottom edges only. On a wide screen the same
   ring eats into the gutter from both sides and collides with the next
   card's. Drawn inward it can do neither, and the column's own 1rem padding
   still holds it a comfortable distance clear of the artwork. */
article:focus-within {
  outline: 2px solid currentColor;
  outline-offset: -0.25rem;
}

article .stretched-link:focus-visible {
  outline: none;
}

/* Prose links. FastBootstrap sets a { text-decoration: none } where upstream
   Bootstrap underlines, which left an inline link marked by its color alone.
   Measured, the link blue (#0c66e4) against the body text (#172b4d) is
   2.71:1 -- under the 3:1 WCAG 1.4.1 asks before color may carry that
   distinction unaided -- so on a page of running text the links stop being
   findable at rest. The underline comes back to carry it instead.

   Only the resting state needs this: FastBootstrap's own a:hover already
   underlines, and the ring above answers keyboard focus.

   Scoped to main, and the three link patterns inside it that have their own
   affordance are held out: .btn carries its own shape, .back-link draws the
   navbar's rule under the word, and an article's .stretched-link reads as a
   heading and underlines when the block is hovered or focused. Underlining
   any of them at rest would undo a deliberate treatment above. Prose links
   outside a main landmark are untouched -- see the note on about/me.html. */
main a:not(.btn):not(.back-link):not(.stretched-link):not(.text-decoration-none) {
  text-decoration: underline;
}

/* Long unbroken tokens -- the bare URLs that stand in as link text in the
   privacy policy, which is legal copy and cannot be reworded to something
   shorter. overflow-wrap: normal only breaks at spaces, so
   "https://www.dataprivacyframework.gov/list" sets to a min-content 311px
   against the 296px a 320px viewport leaves inside the container, and pushed
   15px of horizontal scroll onto the whole document -- WCAG 1.4.10. anywhere
   rather than break-word because only anywhere also lowers the element's
   min-content width, which is the part that drives the overflow; break-word
   would wrap the glyphs but still report the full token width to the layout.
   Confined to links, so prose and headings keep hyphens: auto above and are
   never broken mid-word. */
main a {
  overflow-wrap: anywhere;
}

/* Footer column labels. They were <p class="fw-bold">, so heading navigation
   ran out at the end of main and the footer's three columns were
   indistinguishable to anyone moving by heading. They are <h2> now, which
   only costs the font: every other heading on the site sets in the grotesk,
   and these sit in the body stack with the list beneath them. The classes
   beside this one already hold the size and weight, so the family is all
   there is to put back -- the footer looks exactly as it did. */
.footer-col-heading {
  font-family: var(--font-plex-sans);
}

/* Footer links drop their underline to stay quiet in the layout, so hover
   and keyboard focus have to put it back -- otherwise nothing marks them as
   links at all. */
footer a:hover,
footer a:focus-visible {
  text-decoration: underline !important;
}

/* Phones: the block goes full-width and its heading wraps onto many lines,
   so it needs more height than the desktop ratio gives. aspect-ratio is
   width / height, so 3 / 5 makes it about 1.7 times as tall as wide.
   Limited to the xs breakpoint: on tablets the block is also full-width,
   but 700-900px wide, and a portrait box would be far taller than the
   screen. */
@media (max-width: 575.98px) {
  .placeholder-mobile-portrait {
    --placeholder-ratio: 3 / 5;
  }
}

/* Featured placeholder in the writings grid: taller than the default 5 / 4,
   which left it much shorter than the column of two stacked articles beside
   it. 1 / 1 is the ceiling here, not a free choice: the article is sticky
   (see .sticky-col), and a sticky element can only travel as far as its
   neighbour column is taller than it. At lg the block is about 610px wide,
   so a square is ~610px against a right-hand column of roughly 800px --
   ~190px of scroll to move through. Going to 4 / 5 made it ~760px and the
   sticking became imperceptible. From lg up only, since that is where it is
   eight columns wide; below that it goes full-width and
   .placeholder-mobile-portrait already sets the height it needs. */
@media (min-width: 992px) {
  .placeholder-feature {
    --placeholder-ratio: 1 / 1;
  }
}

/* Dark variant of the placeholder, for sections on a light background
   (e.g. #generative-art on bg-light), where the grey one would vanish. */
.placeholder-dark {
  background-color: var(--bs-dark);
}

/* Work-grid variant of the placeholder: one box for every card whatever the
   artwork will be. 5 / 4 makes it four fifths as tall as it is wide -- 20%
   shorter than the square it started as. */
.placeholder-square {
  --placeholder-ratio: 5 / 4;
}

/* Card artwork sitting in the same grid as the placeholders: crops a
   square source to the placeholder's ratio instead of running taller than
   the boxes beside it. cover fills the box and trims the overflow; the
   60% on object-position takes that trim slightly more off the top than
   the bottom, which is where the ring in weibo-cup-ring.jpg sits. */
.img-card {
  width: 100%;
  aspect-ratio: var(--placeholder-ratio, 5 / 4);
  object-fit: cover;
  object-position: center 60%;
}

/* Image backdrop: a darker gray than .bg-light, so screenshots with white
   or near-white edges read as objects on the page rather than bleeding
   into it. */
.bg-gray {
  background-color: #dee2e6;
}

/* Sticky footer: on short pages (a stub article, a wide screen) the content
   ends well above the fold and the footer would sit mid-screen with blank
   page under it. Making the body a full-height column and giving the footer
   margin-top: auto hands it all the leftover space, so it rests on the
   bottom edge. On pages long enough to scroll there is no leftover space
   and nothing changes -- the footer simply follows the content. 100dvh
   tracks the mobile browser's collapsing toolbars; the 100vh line before it
   is the fallback for browsers without dvh. */
body {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  min-height: 100dvh;
}

body > footer {
  margin-top: auto;
}

/* Tag lines like "[concept · design · development]": when the list wraps,
   the opening bracket should hang in the margin so the wrapped words line
   up under the first word rather than under the bracket. The negative
   text-indent pulls the first line left by exactly one character (1ch is
   the character width in a monospace face, so it matches "[" precisely)
   while the matching padding keeps every other line at the original edge. */
.tag-list {
  padding-left: 1ch;
  text-indent: -1ch;
}

/* Breadcrumb at the head of a project page. One list serves both views
   rather than two hidden copies: below md the trail's second item and the
   separator are dropped and an arrow appears, leaving a back-link -- the
   form that earns its place on a phone, where the nav sits behind the
   toggler and the <h1> directly underneath already names the page. From md
   up the arrow goes and the trail reads in full, "work / this project",
   next to a navbar that offers the same route.

   The utility classes do the switching, so the breakpoint stays Bootstrap's
   own md and nothing here has to restate it. Both directions use display,
   so what is hidden is hidden from assistive technology too and neither
   view is announced twice.

   Lowercase and in the mono, like the navbar and the footer lists.
   Character-width gaps track the face rather than drifting from it. */
.crumbs {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75ch;
  list-style: none;
  margin: 0;
  padding: 0;
  text-transform: lowercase;
}

/* The current page: present for orientation, not a destination, so it is
   set a step quieter than the link beside it. gray-600 holds 4.5:1 on the
   page background, so "quieter" stays readable rather than decorative. The
   li carries the gap between the separator and the words; .d-md-flex in the
   markup is what turns the box on, so display is not declared here. */
.crumbs [aria-current="page"] {
  align-items: center;
  gap: 0.75ch;
  color: var(--bs-gray-600, #6c757d);
}

/* Separator. A real element rather than an ::after, which some screen
   readers do announce from generated content, and aria-hidden in the markup
   so none of them announce this one. */
.crumbs-sep {
  color: var(--bs-gray-500, #adb5bd);
}

/* The link itself: inline-flex so the arrow and the word share one line box
   and the hit area shrink-wraps them both. */
.back-link {
  display: inline-flex;
  align-items: center;
  gap: 0.75ch;
  color: var(--bs-dark);
  text-decoration: none;
  text-transform: lowercase;
}

/* The framework underlines every link on hover, from a bare `a:hover` --
   one element and one pseudo-class, which outranks the single class above,
   so the declaration there holds at rest and loses the moment the pointer
   arrives. That put a browser underline under the drawn rule: two lines,
   at two different distances from the word. Restated against :hover to
   outrank it. The navbar never showed this because Bootstrap's own
   .nav-link:hover carries the same override for its links. */
.back-link:hover {
  text-decoration: none;
}

/* Arrow, below md only. The wrapper is what the d-md-none in the markup
   switches off, so the visually-hidden "Back to" it holds goes with it --
   the accessible name is "Back to work" on a phone and plain "work" in the
   trail, each matching what the link does there.

   Drawn as an SVG rather than typed as an arrow character. U+2190 is not in
   every face in the stack, so a fallback would supply it from some other
   font at some other weight; and as the only text in its span it is read
   aloud, which is why the markup hides it and leaves the word as the
   accessible name. At 1em with currentColor it follows the link's size and
   colour like a glyph would. */
.back-link-arrow-wrap {
  display: inline-flex;
  align-items: center;
}

.back-link-arrow {
  flex: none;
  width: 1em;
  height: 1em;
  transition: transform 0.18s ease-out;
}

/* The word's own answer to hover and focus is the navbar's rule, shared
   from the block above; what is left here is the arrow, which nudges back
   the way it points -- the direction the click goes. Gated on a real
   hovering pointer, so a tap does not leave it displaced until the next tap
   elsewhere. The link holds its colour either way: the rule is the whole
   signal, as it is in the navbar. */
@media (hover: hover) {
  .back-link:hover {
    color: var(--bs-dark);
  }

  .back-link:hover .back-link-arrow {
    transform: translateX(-0.25em);
  }
}

@media (prefers-reduced-motion: reduce) {
  .back-link-arrow {
    transition: none;
  }
}

/* Skip link. Bootstrap's .visually-hidden-focusable keeps it out of the way
   until it is tabbed to, but unhidden it would land in the flow and shove the
   navbar down, so it is pinned over the page instead. Styled as a solid dark
   chip to clear 3:1 against the light header, and it inherits the same
   :focus-visible ring as every other control above. */
.skip-link:focus {
  position: absolute;
  z-index: 1050;
  top: 0.5rem;
  left: 0.5rem;
  padding: 0.5rem 0.75rem;
  background-color: var(--bs-dark);
  color: #fff;
  text-decoration: none;
  border-radius: 0.25rem;
}

/* Browsers italicise <address>, which fights the body copy wherever it is used
   for a postal block. Reset to normal and let the surrounding type size and
   margins carry it, so it sets like the paragraphs either side of it. */
address {
  font-style: normal;
  margin-bottom: 1rem;
}

/* <cite> carries the meaning "title of a work" around the publication titles
   in the work pages' reference lists, which is why the element is there
   rather than a span. Its default italic is the part that has to go: every
   face on the site is a sans, the italics are synthesized obliques rather
   than drawn, and a slanted run in the middle of a citation reads as
   emphasis the citation does not intend. The element stays, the slant does
   not -- the reference list sets in one upright voice. */
cite {
  font-style: normal;
}

/* Prose lists in the running copy of a work page. Bootstrap's ul carries
   padding-left: 2rem, which stepped the whole list 32px right of the
   paragraphs above and below it, so the column had two left edges. The
   padding goes, and the list draws its own marker: a native disc with
   padding-left: 0 would be drawn outside the box and hang past the column,
   and list-style-position: inside would bring it in but run wrapped lines
   back under the marker. A dot on ::before does neither -- it sits on the
   paragraph edge, and the li's padding holds the hanging indent so wrapped
   lines align under the first line's text.

   Round, so it reads as the disc it replaces rather than as .project-list's
   square above, which is that list's own hover affordance. A circle reads
   lighter than a square of the same measure, so the size is set a touch
   above the square's to match its weight on the page. Everything here is
   em, resolving against the li's own font size, so the marker and the
   indent track .fs-5 or whatever size the list is given -- and since
   nothing is drawn outside the box, no size can push past the container
   gutter. */
.prose-list {
  list-style: none;
  padding-left: 0;
}

.prose-list > li {
  position: relative;
  --bullet-size: 0.35em;
  padding-left: 0.8em;
  margin-bottom: 0.5rem;
}

.prose-list > li::before {
  content: "";
  position: absolute;
  left: 0;
  /* half of the body copy's line-height, so the dot centers on the first
     line rather than on its box */
  top: 0.75em;
  transform: translateY(-50%);
  width: var(--bullet-size);
  height: var(--bullet-size);
  border-radius: 50%;
  background: currentColor;
}

/* The break between the case study and the "Other work" list that closes a
   work page. The aside carried my-5, which in FastBootstrap's scale is
   1.25rem rather than Bootstrap's 3rem -- the spacers here step by 0.25rem
   and run to 20 -- so the list sat 20px under the last section and read as
   part of the article rather than after it.

   8rem, which is past the 5rem the scale tops out at, so it is set here
   rather than with a utility. One value in one place also keeps the three
   work pages that carry this aside from drifting apart. The rule itself is
   the h2's border-top, so this margin is the whole of the gap above it. */
#other-work {
  margin-top: 8rem;
}
