* {
  box-sizing: border-box;
}

/* Values the page repeats, named once. The phone block below steps the
   spacing and the small fixed type down in one place, so the rules using
   them need no phone override of their own. */
:root {
  /* The page's side margin (header, footer bar, sections, side indexes,
     overlays) and the fixed bars' top/bottom padding. */
  --gutter: 2rem;
  --bar-pad: 1.5rem;
  /* Type sizes used in more than one place: the small fixed type (section
     index, footer bar, info cube index), which is also the smallest text on
     the page (small print, the info cube's bar labels); CV titles and their
     kin (the footer links); and the large "Über mich" text. */
  --fs-ui: 0.875rem;
  --fs-title: clamp(1.05rem, 1.6vw, 1.25rem);
  --fs-lead: clamp(2rem, 4.1vw, 3.75rem);
  /* The white rules of the CV tables and the info cube's panel. */
  --hairline: 1px solid #fff;
}

@media (max-width: 600px) {
  :root {
    --gutter: 1.25rem;
    --bar-pad: 1.15rem;
    --fs-ui: 0.75rem;
  }
}

/* Backs up js/copyProtection.js's dragstart handler: this is what actually
   suppresses the native drag "ghost" thumbnail the moment the gesture
   starts, rather than only cancelling the drag a frame later once JS has
   run. Also the only protection left if a visitor has JS disabled. */
img,
svg,
picture,
video {
  -webkit-user-drag: none;
  user-drag: none;
}

/* Selection: text stays selectable and copyable everywhere — an address, a
   quote, a line of the CV. What never gets the blue selection wash is
   everything that isn't reading matter: images and drawings (a tap-and-hold
   or a double click on a poster selected it like a word, which made the
   gallery feel like a document rather than an app), and the page's
   decorative and interface layers, which CMD+A would otherwise paint over
   in odd blue blocks:
   - the weather gradient and the giant "Hello!" over it,
   - the spirals, icons and morphing shapes in "Über mich",
   - the CV's shape markers (▲ ● ★) — copying a CV row gives its text alone,
   - the section index, the cube, the "Mehr anzeigen" letters, the loader,
     the overlay X and the lightbox.
   Slides of the pinned "Über mich" sequence that are faded out also drop out
   of the selection while they're invisible (.is-unselectable, set by
   js/pinnedTextSequence.js) — they're stacked in one grid cell, so a
   select-all highlighted every hidden paragraph on top of the visible one. */
img,
svg,
picture,
canvas,
video,
.weather-gradient,
.text-pressure-layer,
.word-spiral,
.about-inspires-morph,
.cv-marker,
.section-nav-line,
#section-nav,
#inspires-nav,
.scroll-cube-wrap,
.gallery-item,
.gallery-more,
.gallery-loader,
.overlay-close,
.lightbox,
.is-unselectable {
  -webkit-user-select: none;
  user-select: none;
}

/* Purely decorative elements take no pointer events, so a click, a hover or
   a long press goes straight through to whatever is underneath — no image
   context menu on the logo's P and H, no stray hit on a marker. (The weather
   gradient with everything in it, the "Hello!" layer, the "inspires" icon and
   label and the spirals already did; these join them.) Not the index's short
   lines: they're part of their link's clickable area. */
.cv-marker,
.about-name-letter,
.gallery-loader {
  pointer-events: none;
}

/* The custom cursor dot (js/customCursor.js) replaces the native cursor everywhere —
   the universal selector is needed because links/buttons would otherwise still
   show the browser's default pointer on hover. Scoped to a class customCursor.js only
   sets once the replacement dot actually exists, so if that script fails the
   page keeps the normal cursor instead of having none at all. */
body.has-custom-cursor,
body.has-custom-cursor * {
  cursor: none;
}

html {
  scroll-behavior: smooth;
  /* iOS enlarges text on its own when a page is rotated to landscape, which
     would break every clamp() and vw-based size on this page. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

/* Touch has no hover state to preview a link with, and the grey flash mobile
   browsers paint on tap is not part of this design — so taps are acknowledged
   by dimming the link instead. Only applied where there's no hover. */
a {
  -webkit-tap-highlight-color: transparent;
}

@media (hover: none) {
  a:active {
    opacity: 0.5;
  }
}

/* Remove default spacing; allow the one-pager to scroll vertically only. */
html,
body {
  margin: 0;
  padding: 0;
  width: 100%;
  min-height: 100%;
  overflow-x: hidden;
  /* The landing view stays put at the top: no rubber-band bounce (iOS) and
     no pull-to-refresh (Android) when pulling down past the start, which
     otherwise drags the whole hero down and shows a gap above it. Also
     stops the same bounce past the page's bottom end. */
  overscroll-behavior-y: none;
  background-color: #000;
  color: #fff;
  font-family: 'Montserrat', sans-serif;
  font-weight: 700;
}

/* Outbound links inside running text and the CV (Hej, ZHdK, …): they look
   exactly like the text around them. The hover feedback is the custom
   cursor dot growing over them, which js/customCursor.js does for every <a>;
   on touch, the global a:active dimming above acknowledges the tap. The
   name in the footer bar (a mailto, see index.html) is set the same way. */
.text-link,
.footer-bar-link {
  color: inherit;
  text-decoration: none;
}

/* The page's buttons, stripped of the UA's button look: each is set like
   the text or line it stands in, by its own rule further down. */
.gallery-more,
.footer-link--button,
.scroll-cube-wrap,
.footer-bar-weather,
.overlay-close,
.info-cube-index-item {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  -webkit-tap-highlight-color: transparent;
}

/* Keyboard focus: a thin white frame instead of the browser's ring — on
   everything Tab reaches, not just the custom controls: the header and
   footer links, the text links in "Über mich" and the CV, the section index
   and the links in the Impressum had kept the browser's own blue/black ring,
   so focus looked different depending on where it was. :where() keeps this
   at zero specificity, so the per-control offsets below still apply. */
:where(a[href], button, [role="button"], [tabindex="0"]):focus-visible,
.gallery-more:focus-visible,
.gallery-item:focus-visible,
.scroll-cube-wrap:focus-visible,
.footer-bar-weather:focus-visible,
.overlay-close:focus-visible,
.info-cube-index-item:focus-visible {
  outline: 1px solid #fff;
  outline-offset: 4px;
}

/* Visually hidden, but kept in the accessibility tree (see the rules for
   each below). `display: none` would drop them from it entirely. */
#arbeiten h2,
#brands h2,
.about-label,
.about-inspires-word {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The overlays (Impressum, info cube) while closed. */
.footer-legal[hidden],
.info-cube[hidden] {
  display: none;
}

/* The page underneath stays where it is while an overlay is up — the
   Impressum, the lightbox and the info cube alike leave only by their X or
   Escape, never by a scroll. */
html.is-overlay-open {
  overflow: hidden;
}

/* The X every overlay closes by: two of the page's white hairlines,
   crossed. Shared by the Impressum, the lightbox and the info cube; each
   places it in its own top right corner, at the same spot. */
.overlay-close {
  position: relative;
  width: 1.5rem;
  height: 1.5rem;
  cursor: pointer;
}

.overlay-close::before,
.overlay-close::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 1.5rem;
  height: 1.5px;
  background: currentColor;
  transform: translate(-50%, -50%) rotate(45deg);
}

.overlay-close::after {
  transform: translate(-50%, -50%) rotate(-45deg);
}

/* Same fingertip-sized hit area as the nav links. */
.overlay-close span {
  position: absolute;
  inset: -0.75rem;
}

.overlay-close:focus-visible {
  outline-offset: 0.3rem;
}

/* Where the info cube's grid puts its X (css/infoCube.css: the overlay's
   padding, less the same nudge), as a fixed spot for the two overlays
   without that grid. */
.footer-legal > .overlay-close,
.lightbox > .overlay-close {
  position: fixed;
  top: calc(var(--bar-pad) - 0.125rem);
  right: calc(var(--gutter) - 0.25rem);
  z-index: 1;
  color: #fff;
}

/* Remove inline spacing behavior from canvas elements. */
canvas {
  display: block;
}

/* ------------------------------ Weather gradient ----------------------------- */

/* The hero's background (js/weatherGradient.js): a base colour with three
   large radial blobs drifting over it, all four colours set from the light
   and weather in Zürich. The colours are registered properties so the
   gradients can crossfade when they change — an unregistered custom property
   inside a gradient would just jump. The initial values are the dusk palette,
   which is what shows if the script never runs. */
@property --wg-c0 {
  syntax: "<color>";
  inherits: true;
  initial-value: #ff5f6d;
}

@property --wg-c1 {
  syntax: "<color>";
  inherits: true;
  initial-value: #ffa940;
}

@property --wg-c2 {
  syntax: "<color>";
  inherits: true;
  initial-value: #b24bf3;
}

@property --wg-c3 {
  syntax: "<color>";
  inherits: true;
  initial-value: #3d3db8;
}

/* The weather's mood (see MOOD in weatherGradient.js): how far the blobs
   wander, how strong the grain is, and the shading's light and shadow.
   Registered for the same reason as the colours — so a change eases in. */
@property --wg-amp {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

@property --wg-grain {
  syntax: "<number>";
  inherits: true;
  initial-value: 0.26;
}

@property --wg-light {
  syntax: "<number>";
  inherits: true;
  initial-value: 0.1;
}

@property --wg-shadow {
  syntax: "<number>";
  inherits: true;
  initial-value: 0.3;
}

/* Fixed behind everything and faded out over the whole page once scrolled
   past the hero, revealing the plain black body background behind the rest.
   Toggled by js/sectionBackground.js. */
.weather-gradient {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  background-color: var(--wg-c0);
  opacity: 1;
  transition: opacity 1.6s ease;
}

/* Only once the first colours are painted (see weatherGradient.js), so the
   page doesn't open by fading from the defaults above. Slow on purpose: a
   change of weather or light should arrive like one. */
.weather-gradient.is-live {
  transition:
    opacity 1.6s ease,
    --wg-c0 8s ease,
    --wg-c1 8s ease,
    --wg-c2 8s ease,
    --wg-c3 8s ease,
    --wg-amp 8s ease,
    --wg-grain 8s ease,
    --wg-light 8s ease,
    --wg-shadow 8s ease;
}

body.is-blacked-out .weather-gradient {
  opacity: 0;
}

/* Each blob is two nested elements on animations of different lengths — the
   outer one wanders, the inner one breathes. With periods that don't divide
   into each other, the three blobs almost never repeat the same arrangement,
   which is what keeps it from looking like a loop. Only transforms move, so
   none of it touches layout or paint. How far they move scales with
   --wg-amp; how fast is set from JS through the animations' playback rate. */
.weather-gradient-blob {
  position: absolute;
  width: 95vmax;
  height: 95vmax;
  will-change: transform;
  animation: var(--wg-path) var(--wg-period) ease-in-out infinite alternate;
}

/* Each blob is shaded like a soft body lit from the upper left, the same
   direction the light in .weather-gradient-shade comes from: a brighter crown
   towards the light, the colour itself in the middle, a darker flank turning
   away from it, then the fade into the others. The flank is the same hue
   made deeper and more saturated, not mixed with black, so the shading adds
   colour rather than taking it away. That light-to-dark turn
   within every blob, rather than one flat colour per blob, is what gives the
   field volume. The crown's brightness follows the weather's light, so a
   sunny day models the forms strongly and an overcast one only softly. */
.weather-gradient-blob > div {
  /* The lit crown, shared with the richer version under @supports below. */
  --wg-crown: radial-gradient(
    circle closest-side at 40% 36%,
    color-mix(in oklch, var(--wg-color), white calc(10% + var(--wg-light) * 90%)) 0%,
    transparent 60%
  );
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background:
    var(--wg-crown),
    radial-gradient(
      closest-side,
      var(--wg-color) 0%,
      color-mix(in oklch, var(--wg-color) 85%, transparent) 32%,
      color-mix(in oklch, var(--wg-color) 55%, transparent) 62%,
      transparent 100%
    );
  animation: wg-breathe var(--wg-breathe) ease-in-out infinite alternate;
}

/* Grain as shading: extra grain on each blob's flank turning away from the
   light, none on its lit crown and none past its edge. Like stippling in a
   drawing, the texture thickens where the form turns into shadow, which
   models the blob's roundness far more than an even grain over the whole
   field can. The specks are the blob's own hue, deeper and more saturated —
   a colour accent, not a dark one — let through a noise mask
   (--wg-grain-mask, generated by js/weatherGradient.js). It sits inside the
   blob, so it travels and breathes with it.
   Three masks, intersected: the noise; a ring around the lit crown
   (off-centre towards the light); and a centred falloff that fades it out
   well inside the blob's own edge — the ring alone reaches past that edge on
   the light side, where it would end in a hard circular line. */
.weather-gradient-blob > div::after {
  /* Written once for the prefixed and the standard property. */
  --wg-masks:
    var(--wg-grain-mask, none),
    radial-gradient(circle at 40% 36%, transparent 14%, black 40%, black 50%, transparent 66%),
    radial-gradient(closest-side, black 45%, transparent 92%);
  --wg-mask-sizes: var(--wg-grain-mask-size, auto), 100% 100%, 100% 100%;
  content: "";
  position: absolute;
  inset: 0;
  opacity: calc(var(--wg-grain) * 2.2);
  background-color: var(--wg-color);
  -webkit-mask-image: var(--wg-masks);
  -webkit-mask-size: var(--wg-mask-sizes);
  -webkit-mask-repeat: repeat, no-repeat, no-repeat;
  -webkit-mask-composite: source-in;
  mask-image: var(--wg-masks);
  mask-size: var(--wg-mask-sizes);
  mask-repeat: repeat, no-repeat, no-repeat;
  mask-composite: intersect;
}

/* The shadow each blob casts, down and to the right — away from the light.
   Tinted from the base colour rather than black, so it reads as a deeper
   pool of the same light instead of dirt on it.
   Drawn behind the blob inside its own box, so it travels and breathes with
   it, and darkens whatever colour lies underneath: that overlap is what
   makes the blobs read as lying on top of one another instead of all at the
   same depth. */
.weather-gradient-blob::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  transform: translate(7%, 11%) scale(1.04);
  background: radial-gradient(
    closest-side,
    rgb(0 0 0 / calc(var(--wg-shadow) * 85%)) 0%,
    rgb(0 0 0 / calc(var(--wg-shadow) * 40%)) 45%,
    transparent 100%
  );
}

.weather-gradient-blob--1 {
  --wg-color: var(--wg-c1);
  --wg-path: wg-drift-1;
  --wg-period: 41s;
  --wg-breathe: 17s;
  top: -45vmax;
  left: -35vmax;
}

.weather-gradient-blob--2 {
  --wg-color: var(--wg-c2);
  --wg-path: wg-drift-2;
  --wg-period: 53s;
  --wg-breathe: 23s;
  top: -10vmax;
  right: -45vmax;
}

.weather-gradient-blob--3 {
  --wg-color: var(--wg-c3);
  --wg-path: wg-drift-3;
  --wg-period: 37s;
  --wg-breathe: 29s;
  bottom: -55vmax;
  left: -5vmax;
}

/* The richer shading, in browsers with relative colour syntax (oklch(from …);
   Safari from 18, Chrome from 119). The rules above are the fallback: flank
   and cast shadow mixed from plain transparency and black, grain specks in
   the blob's own colour. It has to be a feature query rather than a second
   declaration, because these values contain var() — a browser can't reject
   them when parsing, only find them invalid once applied, and by then the
   earlier declaration is already gone. */
@supports (color: oklch(from red l c h)) {
  .weather-gradient-blob > div {
    background:
      var(--wg-crown),
      radial-gradient(
        closest-side,
        var(--wg-color) 0%,
        color-mix(in oklch, var(--wg-color) 85%, transparent) 32%,
        oklch(from var(--wg-color) calc(l * 0.86) calc(c * 1.3) h / 0.55) 62%,
        transparent 100%
      );
  }

  .weather-gradient-blob > div::after {
    background-color: oklch(from var(--wg-color) calc(l * 0.9) calc(c * 1.85) h);
  }

  .weather-gradient-blob::before {
    background: radial-gradient(
      closest-side,
      oklch(from var(--wg-c0) calc(l * 0.45) calc(c * 1.3) h / calc(var(--wg-shadow) * 85%)) 0%,
      oklch(from var(--wg-c0) calc(l * 0.45) calc(c * 1.3) h / calc(var(--wg-shadow) * 40%)) 45%,
      transparent 100%
    );
  }
}

@keyframes wg-drift-1 {
  0% { transform: translate(0, 0); }
  35% { transform: translate(calc(22vw * var(--wg-amp)), calc(14vh * var(--wg-amp))); }
  70% { transform: translate(calc(8vw * var(--wg-amp)), calc(32vh * var(--wg-amp))); }
  100% { transform: translate(calc(34vw * var(--wg-amp)), calc(6vh * var(--wg-amp))); }
}

@keyframes wg-drift-2 {
  0% { transform: translate(0, 0); }
  30% { transform: translate(calc(-26vw * var(--wg-amp)), calc(18vh * var(--wg-amp))); }
  65% { transform: translate(calc(-12vw * var(--wg-amp)), calc(-14vh * var(--wg-amp))); }
  100% { transform: translate(calc(-34vw * var(--wg-amp)), calc(4vh * var(--wg-amp))); }
}

@keyframes wg-drift-3 {
  0% { transform: translate(0, 0); }
  40% { transform: translate(calc(24vw * var(--wg-amp)), calc(-22vh * var(--wg-amp))); }
  75% { transform: translate(calc(-10vw * var(--wg-amp)), calc(-30vh * var(--wg-amp))); }
  100% { transform: translate(calc(14vw * var(--wg-amp)), calc(-8vh * var(--wg-amp))); }
}

@keyframes wg-breathe {
  from { transform: scale(calc(1 - 0.1 * var(--wg-amp))); }
  to { transform: scale(calc(1 + 0.2 * var(--wg-amp))); }
}

/* Shading, over the colour and under the grain: light falling in from the
   upper left, strongest in sunshine, and a shadow pooling at the bottom and
   settling into the edges, deepest at night and under storm clouds. Gives the
   flat colour field a sense of depth, like a lit surface rather than a
   screen fill. */
.weather-gradient-shade {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(
      ellipse 75% 60% at 22% -8%,
      rgb(255 255 255 / calc(var(--wg-light) * 100%)) 0%,
      transparent 70%
    ),
    radial-gradient(
      ellipse 120% 65% at 50% 115%,
      rgb(0 0 0 / calc(var(--wg-shadow) * 100%)) 0%,
      transparent 70%
    ),
    radial-gradient(
      ellipse 85% 85% at 50% 45%,
      transparent 55%,
      rgb(0 0 0 / calc(var(--wg-shadow) * 70%)) 100%
    );
}

/* Grain, in two layers so the texture has depth of its own instead of one
   even film: a crisp fine grain at the screen's real pixel size, and a
   softer, clumpier one under it. The tiles are generated by
   js/weatherGradient.js at the device's pixel ratio, which is what keeps the
   fine grain sharp on a retina screen; a scaled-up tile would blur into a
   haze. Both are dark noise under color-dodge: dodge only ever brightens,
   and by the same factor in every channel, so each grain is a more luminous
   speck of the colour beneath it — the colours gain sparkle and depth
   without anything turning greyer or darker. Both are data: URIs, which the
   Content Security Policy's img-src allows. The grain stays still while the
   colour moves underneath, like print on paper, and costs no frames of its
   own. */
.weather-gradient-grain {
  position: absolute;
  inset: 0;
  pointer-events: none;
  mix-blend-mode: color-dodge;
}

.weather-gradient-grain--fine {
  opacity: var(--wg-grain);
  background-image: var(--wg-grain-fine, none);
  background-size: var(--wg-grain-fine-size, auto);
}

.weather-gradient-grain--coarse {
  opacity: calc(var(--wg-grain) * 0.6);
  background-image: var(--wg-grain-coarse, none);
  background-size: var(--wg-grain-coarse-size, auto);
}

/* Faded out below the hero: nothing to see, so nothing to animate. */
.weather-gradient.is-paused .weather-gradient-blob,
.weather-gradient.is-paused .weather-gradient-blob > div {
  animation-play-state: paused;
}

/* Still follows the weather, just without the drift. */
@media (prefers-reduced-motion: reduce) {
  .weather-gradient-blob,
  .weather-gradient-blob > div {
    animation: none;
  }
}

/* ----------------------------- One-pager layout ----------------------------- */

#sections {
  position: relative;
  z-index: 3;
}

.section {
  position: relative;
  /* svh = the viewport at its *smallest*, i.e. with the mobile URL bar shown.
     Plain vh is the largest state, so a full-height section on a phone gets
     cut off by the bar; dvh would fit but re-lays-out the page every time the
     bar slides away mid-scroll. svh is the stable one. */
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3rem;
  text-align: center;
  padding: 7rem 1.5rem;
}

/* The fixed header and footer bar shrink on phones, so the sections don't
   need to reserve as much room for them. */
@media (max-width: 600px) {
  .section {
    /* lvh = the viewport at its *largest*, i.e. with the URL bar retracted —
       which is the state the page is in for all but the first moment, since
       the bar slides away on the first scroll. svh (above) sizes for the bar
       being shown, so every section stopped short of the screen edge and the
       next one peeked in underneath: full-height sections that no longer read
       as full-screen. lvh fills it, and unlike dvh it's a fixed number, so the
       bar sliding in and out doesn't re-lay-out the page mid-scroll. */
    min-height: 100lvh;
    gap: 2rem;
    padding: 5rem 1.25rem 5rem;
  }
}

.section h2 {
  font-size: clamp(2.5rem, 6vw, 5rem);
  text-transform: uppercase;
  margin: 0;
}

/* The design doesn't show the "Arbeiten" / "Über mich" titles, but they stay
   in the accessibility tree so the page keeps a real heading outline (the nav
   links target the sections themselves). Hidden by the shared rule at the
   top of this file. */

/* Scroll-linked float (js/scrollFloat.js). No element currently opts in —
   the one candidate, the "Arbeiten" title, is visually hidden above — but the
   module stays wired up so a target can be added back at any time. */
.scroll-float {
  will-change: transform;
}

/* Generic scroll-in reveal (js/scrollReveal.js): hidden and shifted down,
   fades/slides into place once observed entering the viewport. Used by
   the gallery images and the "Über mich" content below. */
.reveal {
  opacity: 0;
  transform: translateY(3rem);
  transition: opacity 1.5s ease, transform 1.5s ease;
}

.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* The hero's interactive "Hello!" layer (js/textPressure.js) covers the whole
   section, but the type is sized against this gutter so it never runs into the
   viewport edge — the characters grow wider as the cursor nears them, and the
   line is justified edge to edge, so without a margin the outer glyphs clip. */
.text-pressure-layer {
  padding: 0 1.5rem;
}

/* These used to be injected as a <style> element by js/textPressure.js. They
   live here so the page never creates a stylesheet at runtime, which is what
   allows the Content Security Policy to keep style-src at 'self' with no
   inline exception. The two colours come in as custom properties the script
   sets on the layer. */
.text-pressure-flex {
  display: flex;
  justify-content: space-between;
}

/* Only active when the component is configured with stroke: true — it isn't
   on this page, but the option is kept working. */
.text-pressure-stroke span {
  position: relative;
  color: var(--tp-text-color, #fff);
}

.text-pressure-stroke span::after {
  content: attr(data-char);
  position: absolute;
  left: 0;
  top: 0;
  color: transparent;
  z-index: -1;
  -webkit-text-stroke-width: 3px;
  -webkit-text-stroke-color: var(--tp-stroke-color, #f00);
}

@media (max-width: 600px) {
  .text-pressure-layer {
    padding: 0 1.25rem;
  }
}

/* --------------------------------- Gallery ----------------------------------- */

/* Match the header/footer's horizontal padding (2rem) instead of the
   generic .section padding (1.5rem), so the gallery's edges line up
   with the nav links above it. */
#arbeiten,
#brands,
#ueber-mich {
  /* Narrower on a phone (--gutter): at 2rem a side, a 375px screen loses a
     sixth of its width to margin, which the full-bleed poster grid can't
     spare. */
  padding-left: var(--gutter);
  padding-right: var(--gutter);
}

@media (max-width: 600px) {
  .gallery {
    gap: 1rem;
  }
}

.gallery {
  display: grid;
  grid-template-columns: 1fr;
  gap: 1.5rem;
  width: 100%;
}

@media (min-width: 600px) {
  .gallery {
    grid-template-columns: repeat(2, 1fr);
  }
}

@media (min-width: 900px) {
  .gallery {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (min-width: 1200px) {
  .gallery {
    grid-template-columns: repeat(4, 1fr);
  }
}

.gallery-item {
  position: relative;
  width: 100%;
  /* DIN / ISO 216 portrait ratio (e.g. A4: 210 x 297mm). */
  aspect-ratio: 1 / 1.41421356;
  overflow: hidden;
  background-color: rgba(255, 255, 255, 0.05);
}

/* The <picture> around a responsive image (js/gallery.js, the portrait in
   index.html) only picks the file; for layout it steps aside, so the <img>
   sizes against the frame exactly as it did on its own. */
.gallery-item picture,
.about-portrait-morph picture {
  display: contents;
}

/* Images that fill their frame: the posters, and the portrait (below). */
.gallery-item img,
.about-portrait-morph img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Brand logos (#brands) reuse the poster grid — same columns, same gaps, same
   scroll-in reveal — and change only the tile itself: a square instead of the
   DIN sheet above, and the logo fitted inside it rather than cropped to fill
   it, since a mark that's been cut off isn't the mark any more.

   No tile background either. The posters' faint panel is a placeholder that
   their `cover` image then hides completely; here it would stay visible around
   every logo as a grey square, and these are meant to read as white marks
   sitting directly on the page's black. */
.gallery--logos .gallery-item {
  aspect-ratio: 1;
  background-color: transparent;
  /* Share of the tile's width the mark is fitted into. A percentage resolves
     against the tile itself, so the breathing room around each logo stays
     proportional at every column count instead of swallowing the logo once
     the tiles get small. */
  --logo-fill: 50.4%;
  /* Per-logo correction, set from the manifest's "scale" by js/gallery.js.
     Logos are drawn to different optical weights — a wide wordmark fitted
     into the same square as a compact monogram reads smaller than it is —
     so single marks can be sized up or down without touching the rest. */
  --logo-scale: 1;
  /* The logo gets the square between the paddings, so each side takes half of
     whatever is left over. Sizing the box rather than transforming the image
     keeps an SVG rendered at its true size instead of scaled after the fact. */
  padding: calc((100% - var(--logo-fill) * var(--logo-scale)) / 2);
  /* The logo is centred by the tile itself, not by the image filling it:
     some browsers (Safari) leave a child's height: 100% unresolved inside a
     box whose height comes from aspect-ratio, which dropped wide wordmarks
     to the top of the square instead of its middle. */
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Full width, height from the image's own ratio (its width/height attributes,
   taken from the manifest), capped to the square for the rare mark that's
   taller than wide. */
.gallery--logos .gallery-item img {
  height: auto;
  max-height: 100%;
  object-fit: contain;
}

/* Folded grid: four rows at most until "Mehr anzeigen" is pressed
   (js/gallery.js adds .gallery--collapsed and the button). Four rows means a
   different count per column layout — 4, 8, 12, 16 — so each range below
   mirrors one of the .gallery column breakpoints above and must move with
   them. The button only shows while its grid actually has more tiles than
   fit, so a short grid (or a wide screen) never offers it. A hidden tile's
   lazy image isn't fetched either, which spares a phone the data. */
.gallery-more {
  display: none;
  margin: 2rem auto 0;
  padding: 0.5rem 0;
  font-size: 0.875rem;
  font-weight: 300;
  cursor: pointer;
}

/* The label builds itself letter by letter the first time the button
   scrolls into view (js/gallery.js splits it into these spans and adds
   .is-built): each letter drops in, overshoots the baseline and settles,
   one after another via --i. Until then the letters wait invisible, so the
   button never flashes its finished text first. */
.gallery-more-char {
  display: inline-block;
  opacity: 0;
}

.gallery-more.is-built .gallery-more-char {
  animation: gallery-more-bounce 0.7s cubic-bezier(0.25, 0.8, 0.35, 1) both;
  animation-delay: calc(var(--i) * 0.045s);
}

@keyframes gallery-more-bounce {
  0% {
    opacity: 0;
    transform: translateY(-0.9em);
  }
  45% {
    opacity: 1;
    transform: translateY(0.18em);
  }
  70% {
    transform: translateY(-0.08em);
  }
  85% {
    transform: translateY(0.03em);
  }
  100% {
    opacity: 1;
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .gallery-more-char {
    opacity: 1;
  }

  .gallery-more.is-built .gallery-more-char {
    animation: none;
  }
}

@media (max-width: 599.98px) {
  .gallery--collapsed > .gallery-item:nth-child(n + 5) {
    display: none;
  }

  .gallery--collapsed:has(> .gallery-item:nth-child(5)) + .gallery-more {
    display: block;
  }
}

@media (min-width: 600px) and (max-width: 899.98px) {
  .gallery--collapsed > .gallery-item:nth-child(n + 9) {
    display: none;
  }

  .gallery--collapsed:has(> .gallery-item:nth-child(9)) + .gallery-more {
    display: block;
  }
}

@media (min-width: 900px) and (max-width: 1199.98px) {
  .gallery--collapsed > .gallery-item:nth-child(n + 13) {
    display: none;
  }

  .gallery--collapsed:has(> .gallery-item:nth-child(13)) + .gallery-more {
    display: block;
  }
}

@media (min-width: 1200px) {
  .gallery--collapsed > .gallery-item:nth-child(n + 17) {
    display: none;
  }

  .gallery--collapsed:has(> .gallery-item:nth-child(17)) + .gallery-more {
    display: block;
  }
}

/* ---------------------------------- Lightbox -------------------------------- */

/* One poster or logo alone, centred on black over everything — header and
   footer bar included; only the cursor dot stays above it. js/lightbox.js
   opens and closes it; the fade is a transition on is-open, with visibility
   delayed on the way out so the box stops catching clicks only once it has
   faded. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  /* Clear of the X in the top right corner, and as much again below so
     the image stays centred. */
  padding: calc(var(--bar-pad) + 2.5rem) var(--gutter);
  background-color: #000;
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 0.35s ease,
    visibility 0s linear 0.35s;
}

.lightbox.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.35s ease;
}

.lightbox:focus {
  outline: none;
}

/* As large as the screen allows without cropping, never past it. */
.lightbox-img {
  display: block;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  transform: scale(0.97);
  transition: transform 0.35s ease;
}

.lightbox.is-open .lightbox-img {
  transform: none;
}

/* A logo is an SVG with no useful pixel size of its own, so it's given one:
   about half the shorter side of the screen, the same generous margin it
   has in its tile. */
.lightbox--logo .lightbox-img {
  width: min(55vw, 55vh);
}

@media (prefers-reduced-motion: reduce) {
  .lightbox,
  .lightbox.is-open,
  .lightbox-img {
    transition: none;
  }
}

.gallery-empty,
.gallery-loader {
  grid-column: 1 / -1;
}

.gallery-empty {
  font-size: 0.9rem;
  font-weight: 400;
  text-transform: none;
  opacity: 0.6;
}

.gallery-loader {
  display: flex;
  justify-content: center;
  padding: 3rem 0;
}

/* Newton's cradle loader, shown in the gallery while manifest.json is
   fetched (js/gallery.js). Source: https://uiverse.io/dovatgabriel/jolly-kangaroo-36
   — dots recoloured to white to match the rest of the site. */
.newtons-cradle {
  --uib-size: 50px;
  --uib-speed: 1.2s;
  --uib-color: #fff;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--uib-size);
  height: var(--uib-size);
}

.newtons-cradle__dot {
  position: relative;
  display: flex;
  align-items: center;
  height: 100%;
  width: 25%;
  transform-origin: center top;
}

.newtons-cradle__dot::after {
  content: "";
  display: block;
  width: 100%;
  height: 25%;
  border-radius: 50%;
  background-color: var(--uib-color);
}

.newtons-cradle__dot:first-child {
  animation: newtonsCradleSwing var(--uib-speed) linear infinite;
}

.newtons-cradle__dot:last-child {
  animation: newtonsCradleSwing2 var(--uib-speed) linear infinite;
}

@keyframes newtonsCradleSwing {
  0% {
    transform: rotate(0deg);
    animation-timing-function: ease-out;
  }

  25% {
    transform: rotate(70deg);
    animation-timing-function: ease-in;
  }

  50% {
    transform: rotate(0deg);
    animation-timing-function: linear;
  }
}

@keyframes newtonsCradleSwing2 {
  0% {
    transform: rotate(0deg);
    animation-timing-function: linear;
  }

  50% {
    transform: rotate(0deg);
    animation-timing-function: ease-out;
  }

  75% {
    transform: rotate(-70deg);
    animation-timing-function: ease-in;
  }
}

/* -------------------------------- Über mich ----------------------------------- */

.section--about {
  align-items: stretch;
  justify-content: flex-start;
  text-align: left;
  /* No top padding: the pinned .about-text below is a full screen tall (the
     "inspires" slide sets its height) and centres the first slide in it, so
     with the section's top edge at the top of the screen that slide already
     sits dead centre and the pin (js/about.js, start "top top") takes over
     at once — no stretch of scrolling before the animation begins. Placed
     after .section's phone override on purpose, so it wins at every width. */
  padding-top: 0;
}

.about-intro {
  width: 100%;
}

/* All slides share the same grid cell (grid-area: 1 / 1 below) and are
   centered within it via place-items — the row/column auto-sizes to the
   tallest/widest one, so there's no manual height measurement (see
   js/pinnedTextSequence.js) and no absolute-position centering math to get
   wrong. Only opacity is animated to switch between them. */
.about-text {
  display: grid;
  place-items: center;
  max-width: 1100px;
  margin: 0 auto;
  font-size: var(--fs-lead);
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: -0.015em;
  text-align: center;
}

.about-text > * {
  grid-area: 1 / 1;
  margin: 0;
}

/* First slide: the logo's "P", the portrait, the logo's "H" side by side —
   the two halves of assets/logo/logo-ph-white.svg split into their own files
   so the portrait can sit between them, echoing the site's own PH logo mark
   instead of just setting the letters in the body typeface. The portrait's
   own frame (not the photo itself) grows open from nothing as the user
   scrolls (js/about.js: initPortraitMorph, via gsap.matchMedia() for
   per-breakpoint sizing). No gap here on the row itself — spacing between the
   frame and the letters is the frame's own left/right margin instead, which
   that same function animates from negative (overlapping the letters at
   rest) to a small positive gap once fully open, so this one property alone
   carries the whole opening move without a separate position tween on
   either letter. */
.about-name-slide {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0;
}

.about-name-letter {
  display: block;
  height: clamp(4rem, 12.5vw, 10.25rem);
  width: auto;
}

.about-portrait-morph {
  overflow: hidden;
  /* A % radius resolves horizontal/vertical separately against width/height,
     so on a very narrow (near-zero-width) frame it would draw an ellipse,
     not a true round corner. A fixed size keeps the corner circular
     throughout the grow. */
  border-radius: clamp(11px, 2.5vw, 25px);
  background-color: rgba(255, 255, 255, 0.05);
}

.about-portrait-morph img {
  /* The frame's height is fixed while its width grows (js/about.js:
     initPortraitMorph), so cover only ever crops horizontally while the frame
     is narrower than the photo scaled to that height — but once the frame
     grows past that point, width becomes the binding dimension and cover
     starts cropping top/bottom instead. Anchoring to the top keeps that later
     crop centred on the face instead of drifting down toward the shoulders
     the way a centred crop would as the frame keeps widening. */
  object-position: top;
}

/* Inline spiral→word reveal (js/about.js: initWordSpirals). The text span
   sizes the wrapper; the spiral is absolutely positioned over it so fading
   between the two doesn't reflow the surrounding paragraph.

   nowrap keeps a two-word term like "Visuelle Kommunikation" on one line.
   Without it the inline-block wraps its own text on a narrow screen, the box
   becomes two lines tall, and the single spiral drawn across it is both far
   squatter than every other one and laid over the neighbouring lines. The
   mobile type size is held small enough for the longest term to fit — see
   the .about-text rule in the max-width:600px block. */
.word-swap {
  position: relative;
  display: inline-block;
  white-space: nowrap;
}

/* The hand-drawn loop each word hides behind until the scroll unwinds it
   (js/about.js: buildWordSpiral). Same absolute overlay as the icon above, so
   it fills exactly the gap the hidden word leaves without reflowing the
   sentence around it. Built and sized from JS rather than sitting in the
   markup: the number of loops is chosen per word so the doodle spans a short
   word and a long one at the same proportions instead of being stretched to
   fit — which also means no spiral (and a plainly readable paragraph) if
   scripting is off. */
/* Inset vertically rather than filling the whole inline box: that box is a
   full line height, so a spiral drawn to its edges would tower over the
   letters either side of it and crowd the line above. This keeps it inside
   roughly the band the type itself occupies. */
.word-spiral {
  position: absolute;
  inset: 12.5% 0;
  pointer-events: none;
}

.word-spiral svg {
  display: block;
  width: 100%;
  height: 100%;
  /* The round caps and the outer edge of the stroke sit slightly outside the
     viewBox the curve was measured into — without this they'd be clipped. */
  overflow: visible;
}

/* Set to about the stem width of the surrounding paragraph, so the line reads
   as the same weight as the words it stands in for rather than a hairline
   sketch.
   Given in the paragraph's own em and held outside the viewBox scaling by
   non-scaling-stroke, which is what keeps every spiral on the same weight: a
   single loop can't be drawn narrower than about 2:1, so a short word like
   "Hej" fits its spiral by width and leaves height over, and a stroke living
   in viewBox units would be scaled down along with it and come out visibly
   finer than its neighbours'. In em it also tracks the type across
   breakpoints instead of needing a second value for the phone. */
.word-spiral path {
  fill: none;
  stroke: #fff;
  stroke-width: 0.07em;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}

/* .about-inspires-heading — the standalone "What inspires me?" opener before
   the terms, centered and alone like its own slide. It has no rule of its own
   here, and that is the point: size, weight and tracking all come from
   .about-text, so it is set exactly like the paragraph that runs before it and
   reads as the closing line of that thought rather than a headline over the
   list. That also means it follows .about-text across breakpoints without a
   second set of values here.
   It is deliberately *not* sized with the terms any more (js/about.js:
   fitInspiresWords no longer includes it) — those are fitted to fill their
   slide, which is a different job. */

/* "What inspires me" slide: one term at a time, set large and centered over
   its own icon, the two advancing together as the page scrolls
   (js/pinnedTextSequence.js's exclusive stagger + js/about.js's morph).
   Full viewport height so both can run as large as the screen allows.
   Stays within .about-text's normal 1100px column on purpose — a 100vw
   full-bleed breakout was tried here and blew up the shared crossfade grid
   cell (all slides share one grid-area), pushing the other slides' text
   off-screen. Not safe to retry without restructuring that grid. */
.about-inspires-slide {
  position: relative;
  width: 100%;
  height: 100vh;
  height: 100svh;
}

/* Every term sits in the same spot, dead centre over the icon; only one is
   ever visible, so they never collide. The size here is a fallback for the
   moment before js/about.js (fitInspiresWords) measures the slide and sets one
   shared px size on all of them — which is why this is a clamp and not a
   fixed value: without JS the terms still scale with the viewport. */
.about-inspires-word {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 1;
  text-align: center;
  line-height: 1;
  font-size: clamp(1.5rem, 8.4vw, 8rem);
  /* At this size the typeface's normal tracking reads as visibly loose
     between letters — tightened here rather than site-wide since no other
     text runs this large. */
  letter-spacing: -0.04em;
}

/* The terms aren't drawn over the icon: the one showing is set on the left
   edge instead, like the section index (#inspires-nav, js/about.js:
   initInspiresLabel) — at every viewport width, phones included. They stay in
   the page, visually hidden, because they still drive the sequence's timing
   and are still what a screen reader reads out (the shared visually-hidden
   rule at the top of this file). */

/* The morphing icon each term is shown over (js/about.js: initInspiresMorph,
   via GSAP MorphSVGPlugin) — one path, stroked white and never filled, that
   re-shapes into the next term's symbol as that term fades in. Terms drawn
   from real artwork morph into a contour of it and then fade a full-colour
   <image> in on top (see index.html).
   Sized off whichever runs out first, the column's width or the screen's
   height, so the icon is as large as it can be without being cropped at any
   viewport — no breakpoint list to keep in sync. The 0.8 then holds it a fifth
   short of that maximum: the icon is a backdrop for the term set over it, and
   at full size it crowded the type. Scaling the <svg> rather than the drawing
   inside it takes the artwork <image>s and the stroke weight down with it (the
   stroke is in user units), so every icon keeps its proportions — this factor
   is the one number to turn to resize the whole set. */
.about-inspires-morph {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: calc(min(100%, 95svh) * 0.8);
  aspect-ratio: 924 / 928;
  height: auto;
  pointer-events: none;
  z-index: 0;
}

/* stroke-width is in user units, so it scales with the SVG and holds the same
   visual weight at every viewport — one value covers all seven icons, because
   each drawing was scaled to a common size with its transforms baked in (see
   INSPIRES_ICON_PATHS in js/about.js) rather than kept at its own scale.
   Deliberately lighter than the weight the sources carry natively, which
   works out to roughly 9–12 units here once they're fitted (Family, drawn on
   a much larger canvas, is the outlier at ~4). Going below about 5 is where
   the densest drawing — the frog — starts to look brittle rather than fine. */
.about-inspires-morph path {
  fill: none;
  stroke: #fff;
  stroke-width: 6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Every clamp() in this section bottoms out at its floor on a phone, so the
   type stopped scaling with the viewport there: sizes chosen as a safe minimum
   left each slide sitting as a small block in the middle of the screen instead
   of filling it the way it does on a desktop. Here the sizes are tied back to
   the viewport — the longest paragraph goes from about a third of the screen to
   two thirds of it — with ceilings that keep them from running away on the
   wider phones and small tablets that also fall inside this breakpoint. */
@media (max-width: 600px) {
  /* Sized off the one thing that actually constrains this paragraph on a
     phone: "Visuelle Kommunikation" is its longest unbreakable run (see
     .word-swap — the spiral words can't wrap), and it measures about 12.2×
     the font size in width. Dividing the column by 12.5 therefore keeps it on
     one line with a little margin, on a 320px screen as much as a 430px one.
     The paragraph's height follows from that on its own: at these sizes it
     lands well inside the gap between the fixed header and the footer bar.

     The previous 10.1vw was set before the spiral words existed and is far
     too large now — it ran the longest term past the right edge and the
     paragraph itself both under the header and off the bottom of the screen
     on every phone size tested (390x664, 375x667, 320x568). The 40px is this
     breakpoint's left+right section padding; the svh term stays as the guard
     for unusually short screens. */
  .about-text {
    font-size: clamp(1.4rem, min(calc((100vw - 40px) / 12.5), 5.3svh), 3rem);
  }

  .about-name-letter {
    height: clamp(4rem, 17vw, 6.3rem);
  }

  /* Matches .section above: fill the screen in the state the page is actually
     scrolled in, rather than stopping short where the URL bar would be. */
  .about-inspires-slide {
    height: 100lvh;
  }

  /* No icon override here any more: the width/height pair this used to pin to
     a clamp() is now derived from the column and the viewport, which already
     lands on the full column width at this breakpoint. */
}

.cv-group {
  width: 100%;
  margin-top: 5rem;
}

.cv-group + .cv-group {
  margin-top: 1.5rem;
}

.cv-label {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.3rem 1.1rem;
  font-size: 0.875rem;
  font-weight: 300;
  color: #fff;
  margin: 0 0 0.25rem;
}

/* One legend entry (shape + category name) in the merged Awards/Exhibitions/
   Lectures label — grouped in its own inline-flex so .cv-label's gap sits
   *between* entries, not between each entry's shape and its word. */
.cv-label-item {
  display: inline-flex;
  align-items: center;
}

/* Flags the merged list as a curated pick rather than a complete record —
   set off from the shape/name entries next to it by wide letter-spacing
   rather than de-emphasis, so it reads as a qualifier on the heading
   rather than a fourth category. */
.cv-label-note {
  letter-spacing: 0.22em;
}

/* The shape that ties a row to its category in the legend above — reused
   as-is in .cv-title (js: none, plain HTML). Sized in rem rather than em so
   the glyph is the same physical size in both places despite .cv-label and
   .cv-title running at different font-sizes; em would scale the marker with
   whichever text it sits in instead. Plain white glyphs rather than an
   inline SVG each — solid Unicode shapes at this size render more reliably
   crisp than a fill:#fff SVG would at a handful of pixels. */
.cv-marker {
  display: inline-block;
  margin-right: 0.55em;
  line-height: 1;
}

/* The three Unicode shapes (triangle, circle, star) don't draw at the same
   apparent size at one shared font-size — each glyph carries its own
   built-in padding within its own box, the star most of all — so each gets
   its own size/baseline nudge here to actually read as "the same tiny mark"
   rather than three different sizes. Tuned by eye against the rendered
   page, not derived from a formula. */
.cv-marker--triangle {
  font-size: 0.46rem;
  vertical-align: 0.19em;
}

.cv-marker--circle {
  font-size: 0.71rem;
  vertical-align: 0.1em;
}

.cv-marker--star {
  font-size: 0.52rem;
  vertical-align: 0.12em;
}

/* Agentur Awards: the Awards triangle turned upside down — the same glyph at
   the same size, only rotated, so the two read as a pair. Combined with
   .cv-marker--triangle rather than a ▼ glyph, which the font draws at a
   different size and weight. */
.cv-marker--flipped {
  transform: rotate(180deg);
}

.cv-list {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: var(--hairline);
}

.cv-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.1rem 1.5rem;
  padding: 0.5rem 0;
  border-bottom: var(--hairline);
}

@media (min-width: 700px) {
  .cv-row {
    grid-template-columns: 2.5fr 1.4fr 0.7fr 1.4fr;
    align-items: center;
  }

  .cv-date {
    text-align: right;
  }

  /* The merged Awards/Exhibitions/Lectures block (see index.html) has no date
     column. It keeps the same four tracks as the tables above, so title and
     org line up with theirs exactly (a 3-track grid of the same fr total came
     out slightly off, having one column gap fewer), and the city simply runs
     on across the empty date track — room for a row like "Berlin, Essen,
     Nürnberg, Wien, Zürich, Luzern" to fit on one line without wrapping. */
  .cv-list--no-date .cv-city {
    grid-column: 3 / span 2;
  }

  /* Awards carry only their title (no org, no city), so their rows are a
     single column across the table's full width instead of a title squeezed
     into the first track beside two empty ones. */
  .cv-list .cv-row--single {
    grid-template-columns: 1fr;
  }
}

.cv-title {
  font-size: var(--fs-title);
  font-weight: 400;
}

.cv-org,
.cv-city,
.cv-date,
.info-cube-row-label {
  font-size: 0.95rem;
  font-weight: 400;
  opacity: 0.85;
}

/* ------------------------------- Header / nav -------------------------------- */

#site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 20;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: start;
  padding: var(--bar-pad) var(--gutter);
}

.nav-left {
  justify-self: start;
}

.nav-right {
  justify-self: end;
}

.logo {
  justify-self: center;
  display: flex;
  text-decoration: none;
}

.logo-mark {
  display: block;
  height: 2.625rem;
  width: auto;
}

.nav-link {
  color: #fff;
  text-decoration: none;
}

/* A fingertip needs roughly 44px; these links are ~14px of text. Rather than
   padding them out — which would push the header and footer layout around —
   an invisible pseudo-element extends the hit area beyond the glyphs. Only on
   coarse pointers, so mouse users keep the tight original hit boxes. */
@media (pointer: coarse) {
  .nav-link,
  .footer-link,
  .footer-bar-link,
  .logo,
  .scroll-cube-wrap {
    position: relative;
  }

  /* The nav links' own text box is ~19px tall, so -0.85rem top and bottom
     lands the hit area just past the 44px guideline. */
  .nav-link::after,
  .footer-link::after,
  .footer-bar-link::after,
  .logo::after {
    content: "";
    position: absolute;
    inset: -0.85rem -0.7rem;
  }
}

@media (max-width: 600px) {
  .logo-mark {
    height: 2rem;
  }
}

/* ------------------------------- Section index ------------------------------- */

/* Right-edge index of the page's parts (js/sectionNav.js). The layer itself is
   a zero-height strip at the top of the viewport; the script moves each entry
   down to where it belongs, so nothing here depends on the viewport height.
   Hidden until the script has placed the entries, so they don't flash stacked
   at the top on load. difference keeps the white type legible where it
   crosses a poster or logo.
   Kept out of the hero entirely: the index only fades in once the weather
   gradient has faded out (body.is-blacked-out, js/sectionBackground.js), so
   "Poster" never appears over the colour. The delay matches the gradient's
   1.6s fade; going back up, it leaves at once, before the colour returns. */
#section-nav {
  position: fixed;
  top: 0;
  right: var(--gutter);
  z-index: 19;
  mix-blend-mode: difference;
  visibility: hidden;
  opacity: 0;
  transition: opacity 0.2s ease;
}

#section-nav.is-ready {
  visibility: visible;
}

body.is-blacked-out #section-nav {
  opacity: 1;
  transition: opacity 0.6s ease 1.6s;
}

/* An index entry, and the "inspires" label below set exactly like one: a
   short line with the word under it. Same size and cut as the footer bar
   ("Verwurzelt in Zürich"). */
.section-nav-item,
#inspires-nav {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  color: #fff;
  font-size: var(--fs-ui);
  font-weight: 300;
  white-space: nowrap;
  will-change: transform;
}

.section-nav-item {
  position: absolute;
  top: 0;
  right: 0;
  align-items: flex-end;
  text-decoration: none;
}

/* Same short hairline as .dash in the footer bar. */
.section-nav-line {
  display: block;
  width: 20px;
  height: 1px;
  background-color: #fff;
}

/* The current "What inspires me?" term, on the left edge opposite the
   index's "Inspiration" entry and set exactly like it: a short line with the
   word below, at the same height. The text and opacity are copied over from
   the term the sequence is showing (js/about.js: initInspiresLabel), so it
   needs no transitions of its own. Its position comes from js/sectionNav.js,
   the same way as the index entries (data-start/data-end in index.html), so
   it scrolls out together with "Inspiration" at the end of "Über mich". */
#inspires-nav {
  position: fixed;
  top: 0;
  left: var(--gutter);
  z-index: 19;
  align-items: flex-start;
  mix-blend-mode: difference;
  pointer-events: none;
  opacity: 0;
}

/* Shown on a phone too: moved in to the phone gutter and set a step smaller
   (--gutter, --fs-ui), so it takes as little of the narrow screen as
   possible. difference keeps it legible where it runs over a poster. */
@media (max-width: 600px) {
  .section-nav-item,
  #inspires-nav {
    gap: 0.5rem;
  }

  .section-nav-line {
    width: 14px;
  }
}

/* Fixed bottom bar, mirrors #site-header: always visible, independent of
   the scrolled-to #site-footer section further down the page.
   Five positions: name, role, the scroll cube, the live weather, "Verwurzelt
   in Zürich.". Three columns: a half either side of the cube, both the same
   1fr, so the cube stays exactly centred whatever the text beside it. Within
   each half the gaps are spaced evenly — name to role is the same distance
   as role to the cube, and likewise on the right — rather than each word
   centred in a column of its own, which left the inner two visibly closer to
   the cube than to the outer ones. No column gap: the halves' own spacing
   measures right up to the cube's edge. */
#site-footer-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 20;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  grid-template-areas: "left cube right";
  align-items: end;
  padding: var(--bar-pad) var(--gutter);
  font-size: var(--fs-ui);
}

#site-footer-bar p {
  margin: 0;
  font-weight: 300;
}

/* The outer two never give way. The inner two may wrap when space runs out,
   which is exactly what js/footerBarWrap.js (role) and js/zurichStatus.js
   (weather) watch for — they then hide or shorten them instead. */
.footer-bar-name,
.footer-bar-root {
  white-space: nowrap;
}

/* space-between over two words and an empty marker at the cube's side:
   three items, so the two gaps come out equal. */
.footer-bar-half {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  min-width: 0;
}

.footer-bar-half--left {
  grid-area: left;
}

.footer-bar-half--right {
  grid-area: right;
}

.footer-bar-half--left::after,
.footer-bar-half--right::before {
  content: "";
}

.scroll-cube-wrap {
  grid-area: cube;
}

.dash {
  display: inline-block;
  width: 20px;
  height: 1px;
  background-color: #fff;
  vertical-align: middle;
  margin-bottom: 0.15em;
}

/* The live weather is a button (it opens the info cube on "Live Wetter"),
   but set exactly like the text it was: same alignment as its column. */
.footer-bar-weather {
  text-align: inherit;
}

/* The live time and weather (js/zurichStatus.js). The separators are drawn
   here rather than written into the text, so a part the script drops to
   keep the line from wrapping takes its dot with it. */
.zurich-status-temp::before,
.zurich-status-word::before {
  content: " · ";
}

.zurich-status--no-word .zurich-status-word,
.zurich-status--time-only .zurich-status-temp {
  display: none;
}

/* Hidden by js/footerBarWrap.js if the role no longer fits its place in the
   five-position layout —
   measured rather than set at a breakpoint, since the font, padding and
   cube all shift where that happens. visibility, not display, so the gaps
   around it stay as they were. */
.footer-bar-role--hidden {
  visibility: hidden;
}

/* Five positions need room. Below this, the bar folds into three columns of
   two lines each, mirrored around the cube: "Pascal Hartmann" over "Visuelle
   Kommunikation" on the left, "Verwurzelt in Zürich" over the weather on the
   right. The halves dissolve (display: contents) so their words can be
   placed on the grid individually. */
@media (max-width: 900px) {
  #site-footer-bar {
    grid-template-areas:
      "name cube root"
      "role cube status";
    column-gap: 1rem;
  }

  .footer-bar-half {
    display: contents;
  }

  .footer-bar-half--left::after,
  .footer-bar-half--right::before {
    display: none;
  }

  .footer-bar-name {
    grid-area: name;
    justify-self: start;
  }

  .footer-bar-role {
    grid-area: role;
    justify-self: start;
  }

  .footer-bar-status {
    grid-area: status;
    justify-self: end;
    text-align: right;
  }

  .footer-bar-root {
    grid-area: root;
    justify-self: end;
    text-align: right;
  }
}

/* On a phone the bar's padding and gap ease off, and the type steps down to
   the same 0.75rem as the side indexes (#section-nav, #inspires-nav), so all
   the fixed small type on the screen reads as one size. "Visuelle
   Kommunikation" is the widest thing in a column that only gets half the
   screen minus half the cube: at 12px it needs about 147px, which is just
   about what a 375px phone has; on narrower screens it may wrap onto a
   second line rather than disappear (js/footerBarWrap.js only hides it in
   the five-position layout). All four texts share the size, so neither side
   reads smaller than the other. */
@media (max-width: 600px) {
  #site-footer-bar {
    column-gap: 0.5rem;
  }

  .scroll-cube-wrap {
    --cube-size: 1.5rem;
  }

  .dash {
    width: 12px;
  }
}

/* Wireframe cube, a touch smaller than the header's .logo-mark so it reads
   as a quiet marker rather than a second logo. perspective lives on the
   wrapper (not the cube) so the faces below get 3D depth;
   preserve-3d on the cube keeps the faces positioned in 3D relative to it.
   js/scrollCube.js only ever writes the cube's rotateX transform, driven by
   total page scroll distance rather than by section. */
.scroll-cube-wrap {
  /* Each face is pushed out by half the cube's edge length. Deriving that from
     one variable keeps the six faces in sync when the size changes (e.g. on
     phones below) — hardcoding both meant a resize silently pulled the cube
     apart. */
  --cube-size: 1.9rem;
  --ic-half: calc(var(--cube-size) / 2);
  /* The wrapper is also the button that opens the info cube
     (js/infoCube.js); the custom cursor dot growing over it is the hover
     feedback, as for every other link on the page. */
  display: block;
  justify-self: center;
  width: var(--cube-size);
  height: var(--cube-size);
  perspective: 12rem;
}

.scroll-cube-wrap:focus-visible {
  outline-offset: 0.4rem;
}

/* ~24–30px is too small a target for a fingertip: same invisible hit-area
   extension as the nav links above. */
@media (pointer: coarse) {
  .scroll-cube-wrap::after {
    content: "";
    position: absolute;
    inset: -0.7rem;
  }
}

.scroll-cube {
  display: block;
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
}

.scroll-cube-face {
  position: absolute;
  inset: 0;
  border: 0.9pt solid #fff;
}

/* One flat side of something cubic, pushed out from its centre by half its
   size (--ic-half, set by whatever it belongs to): the footer bar's cube
   here, and the info cube's wireframe, cubelets and faces (.ic-side--*). */
.scroll-cube-face--front,
.ic-side--front { transform: translateZ(var(--ic-half)); }
.scroll-cube-face--back,
.ic-side--back { transform: rotateY(180deg) translateZ(var(--ic-half)); }
.scroll-cube-face--right,
.ic-side--right { transform: rotateY(90deg) translateZ(var(--ic-half)); }
.scroll-cube-face--left,
.ic-side--left { transform: rotateY(-90deg) translateZ(var(--ic-half)); }
.scroll-cube-face--top,
.ic-side--top { transform: rotateX(90deg) translateZ(var(--ic-half)); }
.scroll-cube-face--bottom,
.ic-side--bottom { transform: rotateX(-90deg) translateZ(var(--ic-half)); }

/* -------------------------------- Footer / info ------------------------------- */

/* Unlike the header, this isn't pinned to the viewport: it's the page's
   actual closing section, reached at the bottom of the scroll. */
#site-footer {
  position: relative;
  z-index: 3;
  /* The bottom padding keeps the links well clear of the fixed footer bar
     that sits over the page's last few pixels. */
  padding: 5rem var(--gutter) 5.5rem;
}

.footer-links {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 1rem 2rem;
  margin-bottom: 2.5rem;
}

/* Set like the CV's titles (the tables' left column), each with its number
   in front the way the info cube's index has them ("01 Email"): same size,
   same white, no brackets. */
.footer-link {
  display: inline-flex;
  align-items: baseline;
  gap: 0.35em;
  color: #fff;
  text-decoration: none;
  font-size: var(--fs-title);
  font-weight: 400;
}

.footer-link-index {
  font-weight: 400;
}

/* The fourth footer entry opens the Impressum instead of going anywhere, so
   it's a button — stripped back to look exactly like the three links. */
.footer-link--button {
  cursor: pointer;
}

/* Impressum and privacy notes: the page's small print, folded away behind
   "04 Impressum" (js/footerLegal.js). It opens as a full-screen black
   overlay, like the lightbox, with the text centred on screen. The script
   moves it to <body> so #site-footer's stacking context can't trap it under
   the pinned bars. Too tall for the screen (phones, small windows), the
   overlay scrolls itself; the inner block's auto margins centre it while it
   fits and let it start at the top once it doesn't, so nothing is cut off.
   overscroll-behavior keeps that scroll from carrying on to the page. */
.footer-legal {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* Clear of the X in the top right corner. */
  padding: calc(var(--bar-pad) + 2.5rem) 2rem 2rem;
  background: #000;
  /* The site's smallest text size (--fs-ui), shared with the fixed bars and
     side indexes, so the small print is never smaller still. */
  font-size: var(--fs-ui);
  font-weight: 300;
  line-height: 1.5;
  color: #fff;
  outline: none;
}

/* One column per section: all six side by side on a wide screen, then
   three, two and one as the window narrows. Every step divides the six
   sections evenly (2 × 3, 3 × 2, 6 × 1), so no row is left half-empty.
   Fixed counts rather than auto-fit, which let a lone block drift onto a
   row of its own in an odd spot. If a section is added or removed, revisit
   these counts. */
.footer-legal-inner {
  margin: auto;
  width: 100%;
  max-width: 90rem;
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  gap: 1.5rem 2rem;
}

@media (max-width: 1200px) {
  .footer-legal-inner {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

@media (max-width: 900px) {
  .footer-legal-inner {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (max-width: 600px) {
  .footer-legal {
    padding: calc(var(--bar-pad) + 2.5rem) 1.25rem 2.5rem;
  }

  .footer-legal-inner {
    grid-template-columns: minmax(0, 1fr);
    gap: 1.25rem;
  }
}

.footer-legal-title {
  margin: 0 0 0.4em;
  font-size: inherit;
  font-weight: 700;
}

.footer-legal p {
  margin: 0 0 0.6em;
}

.footer-legal a {
  color: inherit;
  text-decoration: underline;
  text-underline-offset: 0.15em;
}

/* Spread across a phone, the three contact links end up as cramped fragments
   on two ragged rows. Stacking them gives each a full-width row — the easiest
   possible tap target for the page's only real actions. */
@media (max-width: 600px) {
  #site-footer {
    padding-top: 3.5rem;
    padding-bottom: 6rem;
  }

  .footer-links {
    flex-direction: column;
    gap: 0.35rem;
    margin-bottom: 2rem;
  }

  .footer-link {
    padding: 0.5rem 0;
  }
}
